From 6340beb5d054a87354f2f9ad47b9d11c648afda4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 16 Feb 2026 14:52:08 +0200 Subject: [PATCH] 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 --- package-lock.json | 57 +++- package.json | 4 + src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 5 +- src-tauri/src/lib.rs | 1 + src/components/board/BoardView.tsx | 299 ++++++++++++------ src/components/board/CardThumbnail.tsx | 22 +- src/components/board/ColumnHeader.tsx | 5 +- src/components/board/FilterBar.tsx | 146 +++++++++ src/components/board/KanbanColumn.tsx | 15 +- .../card-detail/CardDetailModal.tsx | 10 + .../card-detail/CommentsSection.tsx | 97 ++++++ src/components/layout/TopBar.tsx | 83 +++-- src/hooks/useKeyboardNavigation.ts | 90 ++++++ src/lib/schemas.ts | 1 + src/stores/app-store.ts | 90 +++++- src/types/settings.ts | 5 + 17 files changed, 791 insertions(+), 140 deletions(-) create mode 100644 src/components/board/FilterBar.tsx create mode 100644 src/components/card-detail/CommentsSection.tsx create mode 100644 src/hooks/useKeyboardNavigation.ts diff --git a/package-lock.json b/package-lock.json index d107194..6c3871a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@tailwindcss/typography": "^0.5.19", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.3.5", "class-variance-authority": "^0.7.1", @@ -22,6 +24,8 @@ "date-fns": "^4.1.0", "framer-motion": "^12.34.0", "lucide-react": "^0.564.0", + "overlayscrollbars": "^2.14.0", + "overlayscrollbars-react": "^0.5.6", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -3678,6 +3682,31 @@ "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": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", @@ -3938,6 +3967,15 @@ "@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": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", @@ -4865,7 +4903,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -8089,6 +8126,22 @@ "dev": true, "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": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -9322,7 +9375,6 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -9762,7 +9814,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-name": { diff --git a/package.json b/package.json index 2fd9674..565d380 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@tailwindcss/typography": "^0.5.19", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.3.5", "class-variance-authority": "^0.7.1", @@ -24,6 +26,8 @@ "date-fns": "^4.1.0", "framer-motion": "^12.34.0", "lucide-react": "^0.564.0", + "overlayscrollbars": "^2.14.0", + "overlayscrollbars-react": "^0.5.6", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fd9f377..942651a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-dialog = "2" tauri-plugin-shell = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index aeef755..6b7310a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -17,9 +17,12 @@ "dialog:default", "shell:default", "fs:default", + "fs:read-all", + "fs:write-all", "core:window:allow-set-size", "core:window:allow-set-position", "core:window:allow-outer-size", - "core:window:allow-outer-position" + "core:window:allow-outer-position", + "notification:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49e9e63..825188f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_notification::init()) .setup(|app| { // Get portable data directory next to the exe let exe_path = diff --git a/src/components/board/BoardView.tsx b/src/components/board/BoardView.tsx index 2af0c61..ea247f4 100644 --- a/src/components/board/BoardView.tsx +++ b/src/components/board/BoardView.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; import { Plus } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; +import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { staggerContainer } from "@/lib/motion"; import { DndContext, @@ -26,6 +27,8 @@ import { ColumnOverlay, } from "@/components/board/DragOverlayContent"; 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"; function findColumnByCardId(board: Board, cardId: string) { @@ -63,9 +66,77 @@ export function BoardView() { const moveColumn = useBoardStore((s) => s.moveColumn); const [selectedCardId, setSelectedCardId] = useState(null); + const [showFilterBar, setShowFilterBar] = useState(false); + const [filters, setFilters] = useState(EMPTY_FILTER); + const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, handleCardClick); const [addingColumn, setAddingColumn] = useState(false); const [newColumnTitle, setNewColumnTitle] = useState(""); const inputRef = useRef(null); + const osRef = useRef(null); + + // Track columns that existed on initial render (for stagger vs instant appearance) + const initialColumnIds = useRef | 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 useEffect(() => { @@ -105,6 +176,15 @@ export function BoardView() { addColumn(trimmed); setNewColumnTitle(""); 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 --- + // Debounce cross-column moves to prevent oscillation crashes + const lastCrossColumnMoveRef = useRef(0); + + const clearDragState = useCallback(() => { + setActiveId(null); + setActiveType(null); + lastCrossColumnMoveRef.current = 0; + }, []); + const handleDragStart = useCallback((event: DragStartEvent) => { const { active } = event; const type = active.data.current?.type as "card" | "column" | undefined; setActiveId(active.id as string); setActiveType(type ?? null); + lastCrossColumnMoveRef.current = 0; }, []); const handleDragOver = useCallback( (event: DragOverEvent) => { 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; - if (activeType !== "card") return; // Only handle card cross-column moves here + if (activeType !== "card") return; const activeCardId = active.id as string; const overId = over.id as string; + if (overId === activeCardId) return; - // Determine the source column - const activeColumn = findColumnByCardId(board, activeCardId); + const activeColumn = findColumnByCardId(currentBoard, activeCardId); if (!activeColumn) return; - // Determine the target column let overColumn: ReturnType; let overIndex: number; - // Check if we're hovering over a card const overType = over.data.current?.type; if (overType === "card") { - overColumn = findColumnByCardId(board, overId); + overColumn = findColumnByCardId(currentBoard, overId); if (!overColumn) return; overIndex = overColumn.cardIds.indexOf(overId); } else if (overType === "column") { - // Hovering over the droppable area of a column const columnId = over.data.current?.columnId as string | undefined; if (columnId) { - overColumn = board.columns.find((c) => c.id === columnId); + overColumn = currentBoard.columns.find((c) => c.id === columnId); } else { - overColumn = board.columns.find((c) => c.id === overId); + overColumn = currentBoard.columns.find((c) => c.id === overId); } if (!overColumn) return; - overIndex = overColumn.cardIds.length; // Append to end + overIndex = overColumn.cardIds.length; } else { 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; + // 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); }, - [board, moveCard] + [moveCard] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { - const { active, over } = event; + try { + const { active, over } = event; - if (!over || !board) { - setActiveId(null); - setActiveType(null); - return; - } + // Always read fresh state + const currentBoard = useBoardStore.getState().board; + if (!over || !currentBoard) return; - const type = active.data.current?.type; + const type = active.data.current?.type; - if (type === "column") { - // Column reordering - const activeColumnId = active.id as string; - const overColumnId = over.id as string; + if (type === "column") { + const activeColumnId = active.id as string; + const overColumnId = over.id as string; - if (activeColumnId !== overColumnId) { - const fromIndex = board.columns.findIndex( - (c) => c.id === activeColumnId - ); - const toIndex = board.columns.findIndex( - (c) => c.id === overColumnId - ); - if (fromIndex !== -1 && toIndex !== -1) { - 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); + if (activeColumnId !== overColumnId) { + const fromIndex = currentBoard.columns.findIndex( + (c) => c.id === activeColumnId + ); + const toIndex = currentBoard.columns.findIndex( + (c) => c.id === overColumnId + ); + if (fromIndex !== -1 && toIndex !== -1) { + moveColumn(fromIndex, toIndex); } } - } else if (overType === "column") { - // Dropped on an empty column droppable - const columnId = over.data.current?.columnId as string | undefined; - const targetColumnId = columnId ?? (over.id as string); - const targetColumn = board.columns.find( - (c) => c.id === targetColumnId - ); + } else if (type === "card") { + const activeCardId = active.id as string; + const overId = over.id as string; - if (targetColumn && activeColumn.id !== targetColumn.id) { - moveCard( - activeCardId, - activeColumn.id, - targetColumn.id, - targetColumn.cardIds.length + const activeColumn = findColumnByCardId(currentBoard, activeCardId); + if (!activeColumn) return; + + const overType = over.data.current?.type; + + 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 handleDragEndWithAnnouncement = useCallback( (event: DragEndEvent) => { + // Read board BEFORE handleDragEnd potentially modifies it + const currentBoard = useBoardStore.getState().board; handleDragEnd(event); + const { active, over } = event; - if (over && board) { + if (over && currentBoard) { const type = active.data.current?.type; 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" - ? board.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id)) - : findColumnByCardId(board, over.id as string); + ? currentBoard.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id)) + : findColumnByCardId(currentBoard, over.id as string); if (card && targetCol) { setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`); } } 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) { setAnnouncement(`Reordered column "${col.title}"`); } } } }, - [handleDragEnd, board] + [handleDragEnd] ); if (!board) { @@ -311,31 +397,53 @@ export function BoardView() { > {announcement} + + {showFilterBar && board && ( + { setShowFilterBar(false); setFilters(EMPTY_FILTER); }} + boardLabels={board.labels} + /> + )} + + - {board.columns.map((column) => ( - - ))} + + {board.columns.map((column) => ( + + ))} + {/* Add column button / inline input */}
@@ -384,6 +492,7 @@ export function BoardView() { )}
+
{/* Drag overlay - renders a styled copy of the dragged item */} @@ -400,7 +509,7 @@ export function BoardView() { setSelectedCardId(null)} + onClose={() => { setSelectedCardId(null); }} /> ); diff --git a/src/components/board/CardThumbnail.tsx b/src/components/board/CardThumbnail.tsx index 907409e..de3f33a 100644 --- a/src/components/board/CardThumbnail.tsx +++ b/src/components/board/CardThumbnail.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { format } from "date-fns"; import { motion, useReducedMotion, AnimatePresence } from "framer-motion"; @@ -65,9 +65,10 @@ interface CardThumbnailProps { boardLabels: Label[]; columnId: string; 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 { @@ -82,6 +83,14 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card data: { type: "card", columnId }, }); + const cardRef = useRef(null); + + useEffect(() => { + if (isFocused && cardRef.current) { + cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [isFocused]); + const hasDueDate = card.dueDate != null; const dueDateStatus = getDueDateStatus(card.dueDate); @@ -111,7 +120,10 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card { + setNodeRef(node); + (cardRef as React.MutableRefObject).current = node; + }} style={{ transform: CSS.Transform.toString(transform), transition, @@ -119,7 +131,9 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card opacity: getAgingOpacity(card.updatedAt), }} 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}`} variants={fadeSlideUp} initial={prefersReducedMotion ? false : "hidden"} diff --git a/src/components/board/ColumnHeader.tsx b/src/components/board/ColumnHeader.tsx index a81a8a3..a69327f 100644 --- a/src/components/board/ColumnHeader.tsx +++ b/src/components/board/ColumnHeader.tsx @@ -20,6 +20,7 @@ import type { Column, ColumnWidth } from "@/types/board"; interface ColumnHeaderProps { column: Column; cardCount: number; + filteredCount?: number; } const COLOR_PRESETS = [ @@ -35,7 +36,7 @@ const COLOR_PRESETS = [ { hue: "0", label: "Slate" }, ]; -export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) { +export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderProps) { const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(column.title); const inputRef = useRef(null); @@ -107,7 +108,7 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) { ? "text-[oklch(65%_0.15_70)]" : "text-pylon-text-secondary" }`}> - {cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""} + {filteredCount != null ? `${filteredCount} of ` : ""}{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""} diff --git a/src/components/board/FilterBar.tsx b/src/components/board/FilterBar.tsx new file mode 100644 index 0000000..a868b9c --- /dev/null +++ b/src/components/board/FilterBar.tsx @@ -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(null); + const debounceRef = useRef | 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 ( + +
+ {/* Text search */} +
+ + 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" + /> +
+ + {/* Label filter chips */} + {boardLabels.length > 0 && ( +
+ {boardLabels.map((label) => ( + + ))} +
+ )} + + {/* Due date filter */} + + + {/* Priority filter */} + + + {/* Spacer + clear + close */} +
+ {isFilterActive(filters) && ( + + )} + +
+ + ); +} diff --git a/src/components/board/KanbanColumn.tsx b/src/components/board/KanbanColumn.tsx index 7191e0e..35d9732 100644 --- a/src/components/board/KanbanColumn.tsx +++ b/src/components/board/KanbanColumn.tsx @@ -25,11 +25,13 @@ const WIDTH_MAP = { interface KanbanColumnProps { column: Column; + filteredCardIds?: string[]; + focusedCardId?: string | null; onCardClick?: (cardId: string) => void; isNew?: boolean; } -export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) { +export function KanbanColumn({ column, filteredCardIds, focusedCardId, onCardClick, isNew }: KanbanColumnProps) { const [showAddCard, setShowAddCard] = useState(false); const board = useBoardStore((s) => s.board); const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse); @@ -62,6 +64,8 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) ? `3px solid ${board.color}30` : undefined; + const displayCardIds = filteredCardIds ?? column.cardIds; + const isFiltering = filteredCardIds != null; const cardCount = column.cardIds.length; 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 */}
- +
{/* Card list - wrapped in SortableContext for within-column sorting */} @@ -138,7 +142,7 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) initial="hidden" animate="visible" > - {column.cardIds.map((cardId) => { + {displayCardIds.map((cardId) => { const card = board?.cards[cardId]; if (!card) return null; return ( @@ -148,13 +152,14 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) boardLabels={board?.labels ?? []} columnId={column.id} onCardClick={onCardClick} + isFocused={focusedCardId === cardId} /> ); })} - {column.cardIds.length === 0 && ( + {displayCardIds.length === 0 && (
  • - Drop or add a card + {isFiltering ? "No matching cards" : "Drop or add a card"}
  • )} diff --git a/src/components/card-detail/CardDetailModal.tsx b/src/components/card-detail/CardDetailModal.tsx index ca18d70..dce86db 100644 --- a/src/components/card-detail/CardDetailModal.tsx +++ b/src/components/card-detail/CardDetailModal.tsx @@ -9,6 +9,7 @@ import { LabelPicker } from "@/components/card-detail/LabelPicker"; import { DueDatePicker } from "@/components/card-detail/DueDatePicker"; import { AttachmentSection } from "@/components/card-detail/AttachmentSection"; import { PriorityPicker } from "@/components/card-detail/PriorityPicker"; +import { CommentsSection } from "@/components/card-detail/CommentsSection"; import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion"; interface CardDetailModalProps { @@ -165,6 +166,15 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) { attachments={card.attachments} /> + + {/* Row 5: Comments (full width) */} + + + diff --git a/src/components/card-detail/CommentsSection.tsx b/src/components/card-detail/CommentsSection.tsx new file mode 100644 index 0000000..ba7ffae --- /dev/null +++ b/src/components/card-detail/CommentsSection.tsx @@ -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(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 ( +
    +

    + Comments +

    + + {/* Add comment */} +
    +