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:
@@ -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}
|
||||
/>
|
||||
</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>
|
||||
</OverlayScrollbarsComponent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user