feat: Phase 3 - filter bar, keyboard navigation, notifications, comments
- FilterBar component with text search, label chips, due date and priority dropdowns - "/" keyboard shortcut and toolbar button to toggle filter bar - Keyboard card navigation with J/K/H/L keys, Enter to open, Escape to clear - Focus ring on keyboard-selected cards with auto-scroll - Desktop notifications for due/overdue cards via tauri-plugin-notification - CommentsSection component with add/delete and relative timestamps - Filtered card count display in column headers
This commit is contained in:
57
package-lock.json
generated
57
package-lock.json
generated
@@ -11,9 +11,11 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -22,6 +24,8 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
|
"overlayscrollbars": "^2.14.0",
|
||||||
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -3678,6 +3682,31 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/typography": {
|
||||||
|
"version": "0.5.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||||
|
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postcss-selector-parser": "6.0.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||||
|
"version": "6.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||||
|
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cssesc": "^3.0.0",
|
||||||
|
"util-deprecate": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/vite": {
|
"node_modules/@tailwindcss/vite": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
|
||||||
@@ -3938,6 +3967,15 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.3",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
@@ -4865,7 +4903,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"cssesc": "bin/cssesc"
|
"cssesc": "bin/cssesc"
|
||||||
@@ -8089,6 +8126,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/overlayscrollbars": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/overlayscrollbars-react": {
|
||||||
|
"version": "0.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz",
|
||||||
|
"integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"overlayscrollbars": "^2.0.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
@@ -9322,7 +9375,6 @@
|
|||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
@@ -9762,7 +9814,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/validate-npm-package-name": {
|
"node_modules/validate-npm-package-name": {
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -24,6 +26,8 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
|
"overlayscrollbars": "^2.14.0",
|
||||||
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ tauri-plugin-opener = "2"
|
|||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,12 @@
|
|||||||
"dialog:default",
|
"dialog:default",
|
||||||
"shell:default",
|
"shell:default",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
|
"fs:read-all",
|
||||||
|
"fs:write-all",
|
||||||
"core:window:allow-set-size",
|
"core:window:allow-set-size",
|
||||||
"core:window:allow-set-position",
|
"core:window:allow-set-position",
|
||||||
"core:window:allow-outer-size",
|
"core:window:allow-outer-size",
|
||||||
"core:window:allow-outer-position"
|
"core:window:allow-outer-position",
|
||||||
|
"notification:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Get portable data directory next to the exe
|
// Get portable data directory next to the exe
|
||||||
let exe_path =
|
let exe_path =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||||
import { staggerContainer } from "@/lib/motion";
|
import { staggerContainer } from "@/lib/motion";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
ColumnOverlay,
|
ColumnOverlay,
|
||||||
} from "@/components/board/DragOverlayContent";
|
} from "@/components/board/DragOverlayContent";
|
||||||
import { CardDetailModal } from "@/components/card-detail/CardDetailModal";
|
import { CardDetailModal } from "@/components/card-detail/CardDetailModal";
|
||||||
|
import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar";
|
||||||
|
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
||||||
import type { Board } from "@/types/board";
|
import type { Board } from "@/types/board";
|
||||||
|
|
||||||
function findColumnByCardId(board: Board, cardId: string) {
|
function findColumnByCardId(board: Board, cardId: string) {
|
||||||
@@ -63,9 +66,77 @@ export function BoardView() {
|
|||||||
const moveColumn = useBoardStore((s) => s.moveColumn);
|
const moveColumn = useBoardStore((s) => s.moveColumn);
|
||||||
|
|
||||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
|
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
|
||||||
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
||||||
|
const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, handleCardClick);
|
||||||
const [addingColumn, setAddingColumn] = useState(false);
|
const [addingColumn, setAddingColumn] = useState(false);
|
||||||
const [newColumnTitle, setNewColumnTitle] = useState("");
|
const [newColumnTitle, setNewColumnTitle] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||||
|
|
||||||
|
// Track columns that existed on initial render (for stagger vs instant appearance)
|
||||||
|
const initialColumnIds = useRef<Set<string> | null>(null);
|
||||||
|
if (initialColumnIds.current === null && board) {
|
||||||
|
initialColumnIds.current = new Set(board.columns.map((c) => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardClick(cardId: string) {
|
||||||
|
setSelectedCardId(cardId);
|
||||||
|
setFocusedCardId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCards(cardIds: string[]): string[] {
|
||||||
|
if (!isFilterActive(filters) || !board) return cardIds;
|
||||||
|
return cardIds.filter((id) => {
|
||||||
|
const card = board.cards[id];
|
||||||
|
if (!card) return false;
|
||||||
|
if (filters.text && !card.title.toLowerCase().includes(filters.text.toLowerCase())) return false;
|
||||||
|
if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false;
|
||||||
|
if (filters.priority !== "all" && card.priority !== filters.priority) return false;
|
||||||
|
if (filters.dueDate !== "all") {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
if (filters.dueDate === "none" && card.dueDate != null) return false;
|
||||||
|
if (filters.dueDate === "overdue" && (!card.dueDate || new Date(card.dueDate) >= today)) return false;
|
||||||
|
if (filters.dueDate === "today") {
|
||||||
|
if (!card.dueDate) return false;
|
||||||
|
const d = new Date(card.dueDate);
|
||||||
|
if (d.toDateString() !== today.toDateString()) return false;
|
||||||
|
}
|
||||||
|
if (filters.dueDate === "week") {
|
||||||
|
if (!card.dueDate) return false;
|
||||||
|
const d = new Date(card.dueDate);
|
||||||
|
const weekEnd = new Date(today);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||||
|
if (d < today || d > weekEnd) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcut: "/" to open filter bar
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
||||||
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||||
|
e.preventDefault();
|
||||||
|
setShowFilterBar(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for toggle-filter-bar custom event from TopBar
|
||||||
|
useEffect(() => {
|
||||||
|
function handleToggleFilter() {
|
||||||
|
setShowFilterBar((prev) => !prev);
|
||||||
|
}
|
||||||
|
document.addEventListener("toggle-filter-bar", handleToggleFilter);
|
||||||
|
return () => document.removeEventListener("toggle-filter-bar", handleToggleFilter);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Listen for custom event to open card detail from command palette
|
// Listen for custom event to open card detail from command palette
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,6 +176,15 @@ export function BoardView() {
|
|||||||
addColumn(trimmed);
|
addColumn(trimmed);
|
||||||
setNewColumnTitle("");
|
setNewColumnTitle("");
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
|
// Force OverlayScrollbars to detect the new content and scroll to show it
|
||||||
|
setTimeout(() => {
|
||||||
|
const instance = osRef.current?.osInstance();
|
||||||
|
if (instance) {
|
||||||
|
instance.update(true);
|
||||||
|
const viewport = instance.elements().viewport;
|
||||||
|
viewport.scrollTo({ left: viewport.scrollWidth, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,168 +199,174 @@ export function BoardView() {
|
|||||||
|
|
||||||
// --- Drag handlers ---
|
// --- Drag handlers ---
|
||||||
|
|
||||||
|
// Debounce cross-column moves to prevent oscillation crashes
|
||||||
|
const lastCrossColumnMoveRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const clearDragState = useCallback(() => {
|
||||||
|
setActiveId(null);
|
||||||
|
setActiveType(null);
|
||||||
|
lastCrossColumnMoveRef.current = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
const { active } = event;
|
const { active } = event;
|
||||||
const type = active.data.current?.type as "card" | "column" | undefined;
|
const type = active.data.current?.type as "card" | "column" | undefined;
|
||||||
setActiveId(active.id as string);
|
setActiveId(active.id as string);
|
||||||
setActiveType(type ?? null);
|
setActiveType(type ?? null);
|
||||||
|
lastCrossColumnMoveRef.current = 0;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback(
|
const handleDragOver = useCallback(
|
||||||
(event: DragOverEvent) => {
|
(event: DragOverEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || !board) return;
|
if (!over) return;
|
||||||
|
|
||||||
|
// Always read fresh state to avoid stale-closure bugs
|
||||||
|
const currentBoard = useBoardStore.getState().board;
|
||||||
|
if (!currentBoard) return;
|
||||||
|
|
||||||
const activeType = active.data.current?.type;
|
const activeType = active.data.current?.type;
|
||||||
if (activeType !== "card") return; // Only handle card cross-column moves here
|
if (activeType !== "card") return;
|
||||||
|
|
||||||
const activeCardId = active.id as string;
|
const activeCardId = active.id as string;
|
||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
|
if (overId === activeCardId) return;
|
||||||
|
|
||||||
// Determine the source column
|
const activeColumn = findColumnByCardId(currentBoard, activeCardId);
|
||||||
const activeColumn = findColumnByCardId(board, activeCardId);
|
|
||||||
if (!activeColumn) return;
|
if (!activeColumn) return;
|
||||||
|
|
||||||
// Determine the target column
|
|
||||||
let overColumn: ReturnType<typeof findColumnByCardId>;
|
let overColumn: ReturnType<typeof findColumnByCardId>;
|
||||||
let overIndex: number;
|
let overIndex: number;
|
||||||
|
|
||||||
// Check if we're hovering over a card
|
|
||||||
const overType = over.data.current?.type;
|
const overType = over.data.current?.type;
|
||||||
if (overType === "card") {
|
if (overType === "card") {
|
||||||
overColumn = findColumnByCardId(board, overId);
|
overColumn = findColumnByCardId(currentBoard, overId);
|
||||||
if (!overColumn) return;
|
if (!overColumn) return;
|
||||||
overIndex = overColumn.cardIds.indexOf(overId);
|
overIndex = overColumn.cardIds.indexOf(overId);
|
||||||
} else if (overType === "column") {
|
} else if (overType === "column") {
|
||||||
// Hovering over the droppable area of a column
|
|
||||||
const columnId = over.data.current?.columnId as string | undefined;
|
const columnId = over.data.current?.columnId as string | undefined;
|
||||||
if (columnId) {
|
if (columnId) {
|
||||||
overColumn = board.columns.find((c) => c.id === columnId);
|
overColumn = currentBoard.columns.find((c) => c.id === columnId);
|
||||||
} else {
|
} else {
|
||||||
overColumn = board.columns.find((c) => c.id === overId);
|
overColumn = currentBoard.columns.find((c) => c.id === overId);
|
||||||
}
|
}
|
||||||
if (!overColumn) return;
|
if (!overColumn) return;
|
||||||
overIndex = overColumn.cardIds.length; // Append to end
|
overIndex = overColumn.cardIds.length;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only move if we're going to a different column or different position
|
// Only move cross-column (within-column handled by sortable transforms + dragEnd)
|
||||||
if (activeColumn.id === overColumn.id) return;
|
if (activeColumn.id === overColumn.id) return;
|
||||||
|
|
||||||
|
// Debounce: prevent rapid oscillation between columns
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCrossColumnMoveRef.current < 100) return;
|
||||||
|
lastCrossColumnMoveRef.current = now;
|
||||||
|
|
||||||
moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex);
|
moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex);
|
||||||
},
|
},
|
||||||
[board, moveCard]
|
[moveCard]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
try {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over || !board) {
|
// Always read fresh state
|
||||||
setActiveId(null);
|
const currentBoard = useBoardStore.getState().board;
|
||||||
setActiveType(null);
|
if (!over || !currentBoard) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = active.data.current?.type;
|
const type = active.data.current?.type;
|
||||||
|
|
||||||
if (type === "column") {
|
if (type === "column") {
|
||||||
// Column reordering
|
const activeColumnId = active.id as string;
|
||||||
const activeColumnId = active.id as string;
|
const overColumnId = over.id as string;
|
||||||
const overColumnId = over.id as string;
|
|
||||||
|
|
||||||
if (activeColumnId !== overColumnId) {
|
if (activeColumnId !== overColumnId) {
|
||||||
const fromIndex = board.columns.findIndex(
|
const fromIndex = currentBoard.columns.findIndex(
|
||||||
(c) => c.id === activeColumnId
|
(c) => c.id === activeColumnId
|
||||||
);
|
);
|
||||||
const toIndex = board.columns.findIndex(
|
const toIndex = currentBoard.columns.findIndex(
|
||||||
(c) => c.id === overColumnId
|
(c) => c.id === overColumnId
|
||||||
);
|
);
|
||||||
if (fromIndex !== -1 && toIndex !== -1) {
|
if (fromIndex !== -1 && toIndex !== -1) {
|
||||||
moveColumn(fromIndex, toIndex);
|
moveColumn(fromIndex, toIndex);
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type === "card") {
|
|
||||||
// Card reordering within same column (cross-column already handled in onDragOver)
|
|
||||||
const activeCardId = active.id as string;
|
|
||||||
const overId = over.id as string;
|
|
||||||
|
|
||||||
const activeColumn = findColumnByCardId(board, activeCardId);
|
|
||||||
if (!activeColumn) {
|
|
||||||
setActiveId(null);
|
|
||||||
setActiveType(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overType = over.data.current?.type;
|
|
||||||
|
|
||||||
if (overType === "card") {
|
|
||||||
const overColumn = findColumnByCardId(board, overId);
|
|
||||||
if (!overColumn) {
|
|
||||||
setActiveId(null);
|
|
||||||
setActiveType(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeColumn.id === overColumn.id) {
|
|
||||||
// Within same column, reorder
|
|
||||||
const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
|
|
||||||
const newIndex = activeColumn.cardIds.indexOf(overId);
|
|
||||||
if (oldIndex !== newIndex) {
|
|
||||||
moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (overType === "column") {
|
} else if (type === "card") {
|
||||||
// Dropped on an empty column droppable
|
const activeCardId = active.id as string;
|
||||||
const columnId = over.data.current?.columnId as string | undefined;
|
const overId = over.id as string;
|
||||||
const targetColumnId = columnId ?? (over.id as string);
|
|
||||||
const targetColumn = board.columns.find(
|
|
||||||
(c) => c.id === targetColumnId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetColumn && activeColumn.id !== targetColumn.id) {
|
const activeColumn = findColumnByCardId(currentBoard, activeCardId);
|
||||||
moveCard(
|
if (!activeColumn) return;
|
||||||
activeCardId,
|
|
||||||
activeColumn.id,
|
const overType = over.data.current?.type;
|
||||||
targetColumn.id,
|
|
||||||
targetColumn.cardIds.length
|
if (overType === "card") {
|
||||||
|
const overColumn = findColumnByCardId(currentBoard, overId);
|
||||||
|
if (!overColumn) return;
|
||||||
|
|
||||||
|
if (activeColumn.id === overColumn.id) {
|
||||||
|
const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
|
||||||
|
const newIndex = activeColumn.cardIds.indexOf(overId);
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (overType === "column") {
|
||||||
|
const columnId = over.data.current?.columnId as string | undefined;
|
||||||
|
const targetColumnId = columnId ?? (over.id as string);
|
||||||
|
const targetColumn = currentBoard.columns.find(
|
||||||
|
(c) => c.id === targetColumnId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (targetColumn && activeColumn.id !== targetColumn.id) {
|
||||||
|
moveCard(
|
||||||
|
activeCardId,
|
||||||
|
activeColumn.id,
|
||||||
|
targetColumn.id,
|
||||||
|
targetColumn.cardIds.length
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
clearDragState();
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveId(null);
|
|
||||||
setActiveType(null);
|
|
||||||
},
|
},
|
||||||
[board, moveCard, moveColumn]
|
[moveCard, moveColumn, clearDragState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [announcement, setAnnouncement] = useState("");
|
const [announcement, setAnnouncement] = useState("");
|
||||||
|
|
||||||
const handleDragEndWithAnnouncement = useCallback(
|
const handleDragEndWithAnnouncement = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
|
// Read board BEFORE handleDragEnd potentially modifies it
|
||||||
|
const currentBoard = useBoardStore.getState().board;
|
||||||
handleDragEnd(event);
|
handleDragEnd(event);
|
||||||
|
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (over && board) {
|
if (over && currentBoard) {
|
||||||
const type = active.data.current?.type;
|
const type = active.data.current?.type;
|
||||||
if (type === "card") {
|
if (type === "card") {
|
||||||
const card = board.cards[active.id as string];
|
const card = currentBoard.cards[active.id as string];
|
||||||
const targetCol = over.data.current?.type === "column"
|
const targetCol = over.data.current?.type === "column"
|
||||||
? board.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id))
|
? currentBoard.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id))
|
||||||
: findColumnByCardId(board, over.id as string);
|
: findColumnByCardId(currentBoard, over.id as string);
|
||||||
if (card && targetCol) {
|
if (card && targetCol) {
|
||||||
setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`);
|
setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`);
|
||||||
}
|
}
|
||||||
} else if (type === "column") {
|
} else if (type === "column") {
|
||||||
const col = board.columns.find((c) => c.id === (active.id as string));
|
const col = currentBoard.columns.find((c) => c.id === (active.id as string));
|
||||||
if (col) {
|
if (col) {
|
||||||
setAnnouncement(`Reordered column "${col.title}"`);
|
setAnnouncement(`Reordered column "${col.title}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleDragEnd, board]
|
[handleDragEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!board) {
|
if (!board) {
|
||||||
@@ -311,31 +397,53 @@ export function BoardView() {
|
|||||||
>
|
>
|
||||||
{announcement}
|
{announcement}
|
||||||
</div>
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showFilterBar && board && (
|
||||||
|
<FilterBar
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
onClose={() => { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
|
||||||
|
boardLabels={board.labels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={closestCorners}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEndWithAnnouncement}
|
onDragEnd={handleDragEndWithAnnouncement}
|
||||||
|
onDragCancel={clearDragState}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={columnIds}
|
items={columnIds}
|
||||||
strategy={horizontalListSortingStrategy}
|
strategy={horizontalListSortingStrategy}
|
||||||
>
|
>
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
ref={osRef}
|
||||||
|
className="h-full"
|
||||||
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { y: "hidden" } }}
|
||||||
|
defer
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex h-full overflow-x-auto"
|
className="flex h-full"
|
||||||
style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))`, ...getBoardBackground(board) }}
|
style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))`, ...getBoardBackground(board) }}
|
||||||
variants={staggerContainer(0.06)}
|
variants={staggerContainer(0.06)}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
>
|
>
|
||||||
{board.columns.map((column) => (
|
<AnimatePresence>
|
||||||
<KanbanColumn
|
{board.columns.map((column) => (
|
||||||
key={column.id}
|
<KanbanColumn
|
||||||
column={column}
|
key={column.id}
|
||||||
onCardClick={setSelectedCardId}
|
column={column}
|
||||||
/>
|
filteredCardIds={isFilterActive(filters) ? filterCards(column.cardIds) : undefined}
|
||||||
))}
|
focusedCardId={focusedCardId}
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
isNew={!initialColumnIds.current?.has(column.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Add column button / inline input */}
|
{/* Add column button / inline input */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
@@ -384,6 +492,7 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|
||||||
{/* Drag overlay - renders a styled copy of the dragged item */}
|
{/* Drag overlay - renders a styled copy of the dragged item */}
|
||||||
@@ -400,7 +509,7 @@ export function BoardView() {
|
|||||||
|
|
||||||
<CardDetailModal
|
<CardDetailModal
|
||||||
cardId={selectedCardId}
|
cardId={selectedCardId}
|
||||||
onClose={() => setSelectedCardId(null)}
|
onClose={() => { setSelectedCardId(null); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
|
import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
|
||||||
@@ -65,9 +65,10 @@ interface CardThumbnailProps {
|
|||||||
boardLabels: Label[];
|
boardLabels: Label[];
|
||||||
columnId: string;
|
columnId: string;
|
||||||
onCardClick?: (cardId: string) => void;
|
onCardClick?: (cardId: string) => void;
|
||||||
|
isFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: CardThumbnailProps) {
|
export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocused }: CardThumbnailProps) {
|
||||||
const prefersReducedMotion = useReducedMotion();
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -82,6 +83,14 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
|||||||
data: { type: "card", columnId },
|
data: { type: "card", columnId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cardRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFocused && cardRef.current) {
|
||||||
|
cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [isFocused]);
|
||||||
|
|
||||||
const hasDueDate = card.dueDate != null;
|
const hasDueDate = card.dueDate != null;
|
||||||
const dueDateStatus = getDueDateStatus(card.dueDate);
|
const dueDateStatus = getDueDateStatus(card.dueDate);
|
||||||
|
|
||||||
@@ -111,7 +120,10 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<motion.button
|
<motion.button
|
||||||
ref={setNodeRef}
|
ref={(node) => {
|
||||||
|
setNodeRef(node);
|
||||||
|
(cardRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@@ -119,7 +131,9 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
|||||||
opacity: getAgingOpacity(card.updatedAt),
|
opacity: getAgingOpacity(card.updatedAt),
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
|
className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${
|
||||||
|
isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
|
||||||
|
}`}
|
||||||
layoutId={`card-${card.id}`}
|
layoutId={`card-${card.id}`}
|
||||||
variants={fadeSlideUp}
|
variants={fadeSlideUp}
|
||||||
initial={prefersReducedMotion ? false : "hidden"}
|
initial={prefersReducedMotion ? false : "hidden"}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { Column, ColumnWidth } from "@/types/board";
|
|||||||
interface ColumnHeaderProps {
|
interface ColumnHeaderProps {
|
||||||
column: Column;
|
column: Column;
|
||||||
cardCount: number;
|
cardCount: number;
|
||||||
|
filteredCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_PRESETS = [
|
const COLOR_PRESETS = [
|
||||||
@@ -35,7 +36,7 @@ const COLOR_PRESETS = [
|
|||||||
{ hue: "0", label: "Slate" },
|
{ hue: "0", label: "Slate" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
|
export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState(column.title);
|
const [editValue, setEditValue] = useState(column.title);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -107,7 +108,7 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
|
|||||||
? "text-[oklch(65%_0.15_70)]"
|
? "text-[oklch(65%_0.15_70)]"
|
||||||
: "text-pylon-text-secondary"
|
: "text-pylon-text-secondary"
|
||||||
}`}>
|
}`}>
|
||||||
{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
|
{filteredCount != null ? `${filteredCount} of ` : ""}{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
146
src/components/board/FilterBar.tsx
Normal file
146
src/components/board/FilterBar.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { springs } from "@/lib/motion";
|
||||||
|
import { X, Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Label, Priority } from "@/types/board";
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
text: string;
|
||||||
|
labels: string[];
|
||||||
|
dueDate: "all" | "overdue" | "week" | "today" | "none";
|
||||||
|
priority: "all" | Priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_FILTER: FilterState = {
|
||||||
|
text: "",
|
||||||
|
labels: [],
|
||||||
|
dueDate: "all",
|
||||||
|
priority: "all",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isFilterActive(f: FilterState): boolean {
|
||||||
|
return f.text !== "" || f.labels.length > 0 || f.dueDate !== "all" || f.priority !== "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
filters: FilterState;
|
||||||
|
onChange: (filters: FilterState) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
boardLabels: Label[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBarProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [textDraft, setTextDraft] = useState(filters.text);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTextChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTextDraft(value);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
onChange({ ...filters, text: value });
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
[filters, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleLabel(labelId: string) {
|
||||||
|
const labels = filters.labels.includes(labelId)
|
||||||
|
? filters.labels.filter((l) => l !== labelId)
|
||||||
|
: [...filters.labels, labelId];
|
||||||
|
onChange({ ...filters, labels });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
setTextDraft("");
|
||||||
|
onChange(EMPTY_FILTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={springs.snappy}
|
||||||
|
className="overflow-hidden border-b border-border bg-pylon-surface"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2">
|
||||||
|
{/* Text search */}
|
||||||
|
<div className="flex items-center gap-1.5 rounded-md bg-pylon-column px-2 py-1">
|
||||||
|
<Search className="size-3.5 text-pylon-text-secondary" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={textDraft}
|
||||||
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
|
placeholder="Search cards..."
|
||||||
|
className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label filter chips */}
|
||||||
|
{boardLabels.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{boardLabels.map((label) => (
|
||||||
|
<button
|
||||||
|
key={label.id}
|
||||||
|
onClick={() => toggleLabel(label.id)}
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs transition-all ${
|
||||||
|
filters.labels.includes(label.id)
|
||||||
|
? "text-white"
|
||||||
|
: "opacity-40 hover:opacity-70"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: label.color }}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Due date filter */}
|
||||||
|
<select
|
||||||
|
value={filters.dueDate}
|
||||||
|
onChange={(e) => onChange({ ...filters, dueDate: e.target.value as FilterState["dueDate"] })}
|
||||||
|
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All dates</option>
|
||||||
|
<option value="overdue">Overdue</option>
|
||||||
|
<option value="week">Due this week</option>
|
||||||
|
<option value="today">Due today</option>
|
||||||
|
<option value="none">No date</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Priority filter */}
|
||||||
|
<select
|
||||||
|
value={filters.priority}
|
||||||
|
onChange={(e) => onChange({ ...filters, priority: e.target.value as FilterState["priority"] })}
|
||||||
|
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All priorities</option>
|
||||||
|
<option value="urgent">Urgent</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="none">No priority</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Spacer + clear + close */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{isFilterActive(filters) && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} className="text-xs text-pylon-text-secondary">
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon-xs" onClick={onClose} className="text-pylon-text-secondary hover:text-pylon-text">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,11 +25,13 @@ const WIDTH_MAP = {
|
|||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
column: Column;
|
column: Column;
|
||||||
|
filteredCardIds?: string[];
|
||||||
|
focusedCardId?: string | null;
|
||||||
onCardClick?: (cardId: string) => void;
|
onCardClick?: (cardId: string) => void;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) {
|
export function KanbanColumn({ column, filteredCardIds, focusedCardId, onCardClick, isNew }: KanbanColumnProps) {
|
||||||
const [showAddCard, setShowAddCard] = useState(false);
|
const [showAddCard, setShowAddCard] = useState(false);
|
||||||
const board = useBoardStore((s) => s.board);
|
const board = useBoardStore((s) => s.board);
|
||||||
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
|
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
|
||||||
@@ -62,6 +64,8 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
|||||||
? `3px solid ${board.color}30`
|
? `3px solid ${board.color}30`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const displayCardIds = filteredCardIds ?? column.cardIds;
|
||||||
|
const isFiltering = filteredCardIds != null;
|
||||||
const cardCount = column.cardIds.length;
|
const cardCount = column.cardIds.length;
|
||||||
|
|
||||||
const wipTint = column.wipLimit != null
|
const wipTint = column.wipLimit != null
|
||||||
@@ -117,7 +121,7 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
|||||||
>
|
>
|
||||||
{/* The column header is the drag handle for column reordering */}
|
{/* The column header is the drag handle for column reordering */}
|
||||||
<div {...listeners}>
|
<div {...listeners}>
|
||||||
<ColumnHeader column={column} cardCount={column.cardIds.length} />
|
<ColumnHeader column={column} cardCount={cardCount} filteredCount={isFiltering ? displayCardIds.length : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card list - wrapped in SortableContext for within-column sorting */}
|
{/* Card list - wrapped in SortableContext for within-column sorting */}
|
||||||
@@ -138,7 +142,7 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
>
|
>
|
||||||
{column.cardIds.map((cardId) => {
|
{displayCardIds.map((cardId) => {
|
||||||
const card = board?.cards[cardId];
|
const card = board?.cards[cardId];
|
||||||
if (!card) return null;
|
if (!card) return null;
|
||||||
return (
|
return (
|
||||||
@@ -148,13 +152,14 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
|||||||
boardLabels={board?.labels ?? []}
|
boardLabels={board?.labels ?? []}
|
||||||
columnId={column.id}
|
columnId={column.id}
|
||||||
onCardClick={onCardClick}
|
onCardClick={onCardClick}
|
||||||
|
isFocused={focusedCardId === cardId}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{column.cardIds.length === 0 && (
|
{displayCardIds.length === 0 && (
|
||||||
<li className="flex min-h-[60px] items-center justify-center rounded-md border border-dashed border-pylon-text-secondary/20 text-xs text-pylon-text-secondary/50">
|
<li className="flex min-h-[60px] items-center justify-center rounded-md border border-dashed border-pylon-text-secondary/20 text-xs text-pylon-text-secondary/50">
|
||||||
Drop or add a card
|
{isFiltering ? "No matching cards" : "Drop or add a card"}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</motion.ul>
|
</motion.ul>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { LabelPicker } from "@/components/card-detail/LabelPicker";
|
|||||||
import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
|
import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
|
||||||
import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
|
import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
|
||||||
import { PriorityPicker } from "@/components/card-detail/PriorityPicker";
|
import { PriorityPicker } from "@/components/card-detail/PriorityPicker";
|
||||||
|
import { CommentsSection } from "@/components/card-detail/CommentsSection";
|
||||||
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
|
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
|
||||||
|
|
||||||
interface CardDetailModalProps {
|
interface CardDetailModalProps {
|
||||||
@@ -165,6 +166,15 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
|||||||
attachments={card.attachments}
|
attachments={card.attachments}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Row 5: Comments (full width) */}
|
||||||
|
<motion.div
|
||||||
|
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
transition={springs.bouncy}
|
||||||
|
>
|
||||||
|
<CommentsSection cardId={cardId} comments={card.comments} />
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
97
src/components/card-detail/CommentsSection.tsx
Normal file
97
src/components/card-detail/CommentsSection.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useBoardStore } from "@/stores/board-store";
|
||||||
|
import type { Comment } from "@/types/board";
|
||||||
|
|
||||||
|
interface CommentsSectionProps {
|
||||||
|
cardId: string;
|
||||||
|
comments: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
|
||||||
|
const addComment = useBoardStore((s) => s.addComment);
|
||||||
|
const deleteComment = useBoardStore((s) => s.deleteComment);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
addComment(cardId, trimmed);
|
||||||
|
setDraft("");
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||||
|
Comments
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Add comment */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Add a comment... (Enter to send, Shift+Enter for newline)"
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 resize-none rounded-md bg-pylon-column px-2 py-1.5 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
className="self-end"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment list */}
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="max-h-[200px]"
|
||||||
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
|
||||||
|
defer
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className="group/comment flex gap-2 rounded px-2 py-1.5 hover:bg-pylon-column/60"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-pylon-text">
|
||||||
|
{comment.text}
|
||||||
|
</p>
|
||||||
|
<span className="font-mono text-[10px] text-pylon-text-secondary">
|
||||||
|
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteComment(cardId, comment.id)}
|
||||||
|
className="shrink-0 self-start rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/comment:opacity-100"
|
||||||
|
aria-label="Delete comment"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { springs } from "@/lib/motion";
|
import { springs } from "@/lib/motion";
|
||||||
import { ArrowLeft, Settings, Search, Undo2, Redo2, SlidersHorizontal } from "lucide-react";
|
import { ArrowLeft, Settings, Search, Undo2, Redo2, SlidersHorizontal, Filter } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
@@ -138,6 +139,21 @@ export function TopBar() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isBoardView && (
|
{isBoardView && (
|
||||||
<>
|
<>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{savingStatus && (
|
||||||
|
<motion.span
|
||||||
|
key={savingStatus}
|
||||||
|
className="font-mono text-xs text-pylon-text-secondary"
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 4 }}
|
||||||
|
transition={springs.snappy}
|
||||||
|
>
|
||||||
|
{savingStatus}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="mx-2 h-4 w-px bg-pylon-text-secondary/20" />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -168,6 +184,21 @@ export function TopBar() {
|
|||||||
Redo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Shift+Z</kbd>
|
Redo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Shift+Z</kbd>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||||
|
onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
|
||||||
|
>
|
||||||
|
<Filter className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Filter cards <kbd className="ml-1 font-mono text-[10px] opacity-60">/</kbd>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isBoardView && board && (
|
{isBoardView && board && (
|
||||||
@@ -185,37 +216,33 @@ export function TopBar() {
|
|||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Background</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>Background</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{(["none", "dots", "grid", "gradient"] as const).map((bg) => (
|
<DropdownMenuRadioGroup
|
||||||
<DropdownMenuItem
|
value={board.settings.background}
|
||||||
key={bg}
|
onValueChange={(v) => useBoardStore.getState().updateBoardSettings({ ...board.settings, background: v as typeof board.settings.background })}
|
||||||
onClick={() => useBoardStore.getState().updateBoardSettings({ ...board.settings, background: bg })}
|
>
|
||||||
>
|
{(["none", "dots", "grid", "gradient"] as const).map((bg) => (
|
||||||
{bg.charAt(0).toUpperCase() + bg.slice(1)}
|
<DropdownMenuRadioItem key={bg} value={bg}>
|
||||||
{board.settings.background === bg && (
|
{bg.charAt(0).toUpperCase() + bg.slice(1)}
|
||||||
<span className="ml-auto text-pylon-accent">*</span>
|
</DropdownMenuRadioItem>
|
||||||
)}
|
))}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuRadioGroup>
|
||||||
))}
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>Attachments</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={board.settings.attachmentMode}
|
||||||
|
onValueChange={(v) => useBoardStore.getState().updateBoardSettings({ ...board.settings, attachmentMode: v as typeof board.settings.attachmentMode })}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="link">Link to original</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="copy">Copy into board</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{savingStatus && (
|
|
||||||
<motion.span
|
|
||||||
key={savingStatus}
|
|
||||||
className="mr-2 font-mono text-xs text-pylon-text-secondary"
|
|
||||||
initial={{ opacity: 0, y: -4 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 4 }}
|
|
||||||
transition={springs.snappy}
|
|
||||||
>
|
|
||||||
{savingStatus}
|
|
||||||
</motion.span>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
90
src/hooks/useKeyboardNavigation.ts
Normal file
90
src/hooks/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { Board } from "@/types/board";
|
||||||
|
|
||||||
|
export function useKeyboardNavigation(
|
||||||
|
board: Board | null,
|
||||||
|
onOpenCard: (cardId: string) => void
|
||||||
|
) {
|
||||||
|
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (!board) return;
|
||||||
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const isNav = ["j", "k", "h", "l", "arrowdown", "arrowup", "arrowleft", "arrowright", "enter", "escape"].includes(key);
|
||||||
|
if (!isNav) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (key === "escape") {
|
||||||
|
setFocusedCardId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "enter" && focusedCardId) {
|
||||||
|
onOpenCard(focusedCardId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build navigation grid
|
||||||
|
const columns = board.columns.filter((c) => !c.collapsed && c.cardIds.length > 0);
|
||||||
|
if (columns.length === 0) return;
|
||||||
|
|
||||||
|
// Find current position
|
||||||
|
let colIdx = -1;
|
||||||
|
let cardIdx = -1;
|
||||||
|
if (focusedCardId) {
|
||||||
|
for (let ci = 0; ci < columns.length; ci++) {
|
||||||
|
const idx = columns[ci].cardIds.indexOf(focusedCardId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
colIdx = ci;
|
||||||
|
cardIdx = idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing focused, focus first card
|
||||||
|
if (colIdx === -1) {
|
||||||
|
setFocusedCardId(columns[0].cardIds[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "j" || key === "arrowdown") {
|
||||||
|
const col = columns[colIdx];
|
||||||
|
const next = Math.min(cardIdx + 1, col.cardIds.length - 1);
|
||||||
|
setFocusedCardId(col.cardIds[next]);
|
||||||
|
} else if (key === "k" || key === "arrowup") {
|
||||||
|
const col = columns[colIdx];
|
||||||
|
const next = Math.max(cardIdx - 1, 0);
|
||||||
|
setFocusedCardId(col.cardIds[next]);
|
||||||
|
} else if (key === "l" || key === "arrowright") {
|
||||||
|
const nextCol = Math.min(colIdx + 1, columns.length - 1);
|
||||||
|
const targetIdx = Math.min(cardIdx, columns[nextCol].cardIds.length - 1);
|
||||||
|
setFocusedCardId(columns[nextCol].cardIds[targetIdx]);
|
||||||
|
} else if (key === "h" || key === "arrowleft") {
|
||||||
|
const prevCol = Math.max(colIdx - 1, 0);
|
||||||
|
const targetIdx = Math.min(cardIdx, columns[prevCol].cardIds.length - 1);
|
||||||
|
setFocusedCardId(columns[prevCol].cardIds[targetIdx]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[board, focusedCardId, onOpenCard]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
// Clear focus when a card is removed
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedCardId && board && !board.cards[focusedCardId]) {
|
||||||
|
setFocusedCardId(null);
|
||||||
|
}
|
||||||
|
}, [board, focusedCardId]);
|
||||||
|
|
||||||
|
return { focusedCardId, setFocusedCardId };
|
||||||
|
}
|
||||||
@@ -86,4 +86,5 @@ export const appSettingsSchema = z.object({
|
|||||||
windowState: windowStateSchema.nullable().default(null),
|
windowState: windowStateSchema.nullable().default(null),
|
||||||
boardSortOrder: z.enum(["manual", "title", "created", "updated"]).default("updated"),
|
boardSortOrder: z.enum(["manual", "title", "created", "updated"]).default("updated"),
|
||||||
boardManualOrder: z.array(z.string()).default([]),
|
boardManualOrder: z.array(z.string()).default([]),
|
||||||
|
lastNotificationCheck: z.string().nullable().default(null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { AppSettings } from "@/types/settings";
|
import type { AppSettings, BoardSortOrder } from "@/types/settings";
|
||||||
import type { BoardMeta, ColumnWidth } from "@/types/board";
|
import type { BoardMeta, ColumnWidth } from "@/types/board";
|
||||||
import { loadSettings, saveSettings, listBoards, ensureDataDirs } from "@/lib/storage";
|
import { loadSettings, saveSettings, listBoards, ensureDataDirs, loadBoard } from "@/lib/storage";
|
||||||
|
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
|
||||||
|
|
||||||
export type View = { type: "board-list" } | { type: "board"; boardId: string };
|
export type View = { type: "board-list" } | { type: "board"; boardId: string };
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ interface AppState {
|
|||||||
setView: (view: View) => void;
|
setView: (view: View) => void;
|
||||||
refreshBoards: () => Promise<void>;
|
refreshBoards: () => Promise<void>;
|
||||||
addRecentBoard: (boardId: string) => void;
|
addRecentBoard: (boardId: string) => void;
|
||||||
|
setBoardSortOrder: (order: BoardSortOrder) => void;
|
||||||
|
setBoardManualOrder: (ids: string[]) => void;
|
||||||
|
getSortedBoards: () => BoardMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: AppSettings["theme"]): void {
|
function applyTheme(theme: AppSettings["theme"]): void {
|
||||||
@@ -63,6 +67,9 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
defaultColumnWidth: "standard",
|
defaultColumnWidth: "standard",
|
||||||
windowState: null,
|
windowState: null,
|
||||||
|
boardSortOrder: "updated",
|
||||||
|
boardManualOrder: [],
|
||||||
|
lastNotificationCheck: null,
|
||||||
},
|
},
|
||||||
boards: [],
|
boards: [],
|
||||||
view: { type: "board-list" },
|
view: { type: "board-list" },
|
||||||
@@ -75,6 +82,45 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
set({ settings, boards, initialized: true });
|
set({ settings, boards, initialized: true });
|
||||||
applyTheme(settings.theme);
|
applyTheme(settings.theme);
|
||||||
applyAppearance(settings);
|
applyAppearance(settings);
|
||||||
|
|
||||||
|
// Due date notifications (once per hour)
|
||||||
|
const lastCheck = settings.lastNotificationCheck;
|
||||||
|
const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
if (!lastCheck || lastCheck < hourAgo) {
|
||||||
|
try {
|
||||||
|
let granted = await isPermissionGranted();
|
||||||
|
if (!granted) {
|
||||||
|
const perm = await requestPermission();
|
||||||
|
granted = perm === "granted";
|
||||||
|
}
|
||||||
|
if (granted) {
|
||||||
|
let dueToday = 0;
|
||||||
|
let overdue = 0;
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.toDateString();
|
||||||
|
|
||||||
|
for (const meta of boards) {
|
||||||
|
try {
|
||||||
|
const board = await loadBoard(meta.id);
|
||||||
|
for (const card of Object.values(board.cards)) {
|
||||||
|
if (!card.dueDate) continue;
|
||||||
|
const due = new Date(card.dueDate);
|
||||||
|
if (due.toDateString() === todayStr) dueToday++;
|
||||||
|
else if (due < today) overdue++;
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dueToday > 0) {
|
||||||
|
sendNotification({ title: "OpenPylon", body: `You have ${dueToday} card${dueToday > 1 ? "s" : ""} due today` });
|
||||||
|
}
|
||||||
|
if (overdue > 0) {
|
||||||
|
sendNotification({ title: "OpenPylon", body: `You have ${overdue} overdue card${overdue > 1 ? "s" : ""}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAndSave(get, set, { lastNotificationCheck: new Date().toISOString() });
|
||||||
|
} catch { /* notification plugin not available */ }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
@@ -117,4 +163,44 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
].slice(0, 10);
|
].slice(0, 10);
|
||||||
updateAndSave(get, set, { recentBoardIds: recent });
|
updateAndSave(get, set, { recentBoardIds: recent });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setBoardSortOrder: (boardSortOrder) => {
|
||||||
|
// When switching to manual for the first time, snapshot current order
|
||||||
|
if (boardSortOrder === "manual" && get().settings.boardManualOrder.length === 0) {
|
||||||
|
const currentSorted = get().getSortedBoards();
|
||||||
|
updateAndSave(get, set, {
|
||||||
|
boardSortOrder,
|
||||||
|
boardManualOrder: currentSorted.map((b) => b.id),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateAndSave(get, set, { boardSortOrder });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setBoardManualOrder: (boardManualOrder) => {
|
||||||
|
updateAndSave(get, set, { boardManualOrder });
|
||||||
|
},
|
||||||
|
|
||||||
|
getSortedBoards: () => {
|
||||||
|
const { boards, settings } = get();
|
||||||
|
const order = settings.boardSortOrder;
|
||||||
|
|
||||||
|
if (order === "manual") {
|
||||||
|
const manualOrder = settings.boardManualOrder;
|
||||||
|
const orderMap = new Map(manualOrder.map((id, i) => [id, i]));
|
||||||
|
return [...boards].sort((a, b) => {
|
||||||
|
const ai = orderMap.get(a.id) ?? Infinity;
|
||||||
|
const bi = orderMap.get(b.id) ?? Infinity;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (order === "title") {
|
||||||
|
return [...boards].sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
}
|
||||||
|
if (order === "created") {
|
||||||
|
return [...boards].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
// "updated" — default, already sorted from listBoards
|
||||||
|
return boards;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface WindowState {
|
|||||||
maximized: boolean;
|
maximized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BoardSortOrder = "manual" | "title" | "created" | "updated";
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: "light" | "dark" | "system";
|
theme: "light" | "dark" | "system";
|
||||||
dataDirectory: string | null;
|
dataDirectory: string | null;
|
||||||
@@ -17,4 +19,7 @@ export interface AppSettings {
|
|||||||
density: "compact" | "comfortable" | "spacious";
|
density: "compact" | "comfortable" | "spacious";
|
||||||
defaultColumnWidth: ColumnWidth;
|
defaultColumnWidth: ColumnWidth;
|
||||||
windowState: WindowState | null;
|
windowState: WindowState | null;
|
||||||
|
boardSortOrder: BoardSortOrder;
|
||||||
|
boardManualOrder: string[];
|
||||||
|
lastNotificationCheck: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user