From 8c010e06e5821c703ff88104e47a507b5b65a9fe Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 23 Dec 2025 10:28:03 +0000 Subject: [PATCH] Frontend improvements Reviewed-on: https://git.gabrielkaszewski.dev/GKaszewski/k-notes/pulls/1 --- k-notes-frontend/bun.lock | 3 + k-notes-frontend/package.json | 1 + .../src/components/app-sidebar.tsx | 166 +++++++++++++- .../src/components/bulk-actions-bar.tsx | 74 +++++++ .../src/components/bulk-selection-context.tsx | 65 ++++++ .../src/components/create-note-dialog.tsx | 44 +++- k-notes-frontend/src/components/layout.tsx | 46 ++-- .../src/components/note-card-skeleton.tsx | 42 ++++ k-notes-frontend/src/components/note-card.tsx | 134 +++++++----- .../src/hooks/use-keyboard-shortcuts.ts | 46 ++++ k-notes-frontend/src/hooks/use-notes.ts | 95 +++++++- k-notes-frontend/src/pages/dashboard.tsx | 202 +++++++++++++----- notes-api/src/dto.rs | 22 +- notes-api/src/routes/mod.rs | 5 +- notes-api/src/routes/notes.rs | 18 +- notes-api/src/routes/tags.rs | 66 ++++-- notes-domain/src/services.rs | 34 +++ 17 files changed, 896 insertions(+), 167 deletions(-) create mode 100644 k-notes-frontend/src/components/bulk-actions-bar.tsx create mode 100644 k-notes-frontend/src/components/bulk-selection-context.tsx create mode 100644 k-notes-frontend/src/components/note-card-skeleton.tsx create mode 100644 k-notes-frontend/src/hooks/use-keyboard-shortcuts.ts diff --git a/k-notes-frontend/bun.lock b/k-notes-frontend/bun.lock index 11443ca..33932ee 100644 --- a/k-notes-frontend/bun.lock +++ b/k-notes-frontend/bun.lock @@ -46,6 +46,7 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.69.0", "react-markdown": "^10.1.0", + "react-masonry-css": "^1.0.16", "react-resizable-panels": "^4.0.15", "react-router-dom": "^7.11.0", "recharts": "2.15.4", @@ -869,6 +870,8 @@ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-masonry-css": ["react-masonry-css@1.0.16", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], diff --git a/k-notes-frontend/package.json b/k-notes-frontend/package.json index ca86eec..4e82842 100644 --- a/k-notes-frontend/package.json +++ b/k-notes-frontend/package.json @@ -52,6 +52,7 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.69.0", "react-markdown": "^10.1.0", + "react-masonry-css": "^1.0.16", "react-resizable-panels": "^4.0.15", "react-router-dom": "^7.11.0", "recharts": "2.15.4", diff --git a/k-notes-frontend/src/components/app-sidebar.tsx b/k-notes-frontend/src/components/app-sidebar.tsx index 35da870..d8b164c 100644 --- a/k-notes-frontend/src/components/app-sidebar.tsx +++ b/k-notes-frontend/src/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, Archive, Settings } from "lucide-react" +import { Home, Archive, Settings, Tag, ChevronRight, Pencil, Trash2, MoreHorizontal } from "lucide-react" import { Sidebar, SidebarContent, @@ -9,9 +9,22 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" -import { Link, useLocation } from "react-router-dom" +import { Link, useLocation, useSearchParams, useNavigate } from "react-router-dom" import { SettingsDialog } from "@/components/settings-dialog" import { useState } from "react" +import { useTags, useDeleteTag, useRenameTag } from "@/hooks/use-notes" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" const items = [ { @@ -26,9 +39,119 @@ const items = [ }, ] +interface TagItemProps { + tag: { id: string; name: string }; + isActive: boolean; +} + +function TagItem({ tag, isActive }: TagItemProps) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(tag.name); + const { mutate: deleteTag } = useDeleteTag(); + const { mutate: renameTag } = useRenameTag(); + const navigate = useNavigate(); + + const handleDelete = () => { + if (confirm(`Delete tag "${tag.name}"? Notes will keep their content.`)) { + deleteTag(tag.id, { + onSuccess: () => { + toast.success("Tag deleted"); + navigate("/"); + }, + onError: (err: any) => toast.error(err.message) + }); + } + }; + + const handleRename = () => { + if (editName.trim() && editName.trim() !== tag.name) { + renameTag({ id: tag.id, name: editName.trim() }, { + onSuccess: () => { + toast.success("Tag renamed"); + setIsEditing(false); + }, + onError: (err: any) => { + toast.error(err.message); + setEditName(tag.name); + } + }); + } else { + setIsEditing(false); + setEditName(tag.name); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleRename(); + } else if (e.key === "Escape") { + setIsEditing(false); + setEditName(tag.name); + } + }; + + if (isEditing) { + return ( + + setEditName(e.target.value)} + onBlur={handleRename} + onKeyDown={handleKeyDown} + autoFocus + className="h-7 text-xs" + /> + + ); + } + + return ( + + + + + {tag.name} + + + e.preventDefault()}> + + + + { e.preventDefault(); setIsEditing(true); }}> + + Rename + + { e.preventDefault(); handleDelete(); }} className="text-destructive focus:text-destructive"> + + Delete + + + + + + + ); +} + export function AppSidebar() { const location = useLocation(); + const [searchParams] = useSearchParams(); const [settingsOpen, setSettingsOpen] = useState(false); + const [tagsOpen, setTagsOpen] = useState(true); + + const { data: tags } = useTags(); + const activeTag = searchParams.get("tag"); return ( <> @@ -40,7 +163,7 @@ export function AppSidebar() { {items.map((item) => ( - + {item.title} @@ -58,9 +181,46 @@ export function AppSidebar() { + + {/* Tag Browser Section */} + + + + +
+ + Tags +
+ +
+
+ + + + + {tags && tags.length > 0 ? ( + tags.map((tag: { id: string; name: string }) => ( + + )) + ) : ( +
+ No tags yet +
+ )} +
+
+
+
+
+
) } + diff --git a/k-notes-frontend/src/components/bulk-actions-bar.tsx b/k-notes-frontend/src/components/bulk-actions-bar.tsx new file mode 100644 index 0000000..b7e0021 --- /dev/null +++ b/k-notes-frontend/src/components/bulk-actions-bar.tsx @@ -0,0 +1,74 @@ +import { useBulkSelection } from "@/components/bulk-selection-context"; +import { useDeleteNote, useUpdateNote } from "@/hooks/use-notes"; +import { Button } from "@/components/ui/button"; +import { Archive, Trash2, X } from "lucide-react"; +import { toast } from "sonner"; + +export function BulkActionsBar() { + const { selectedIds, clearSelection, isBulkMode } = useBulkSelection(); + const { mutate: deleteNote } = useDeleteNote(); + const { mutate: updateNote } = useUpdateNote(); + + if (!isBulkMode) return null; + + const handleArchiveAll = () => { + const ids = Array.from(selectedIds); + ids.forEach((id) => { + updateNote({ id, is_archived: true }); + }); + toast.success(`Archived ${ids.length} note${ids.length > 1 ? "s" : ""}`); + clearSelection(); + }; + + const handleDeleteAll = () => { + if (!confirm(`Are you sure you want to delete ${selectedIds.size} note(s)?`)) return; + + const ids = Array.from(selectedIds); + ids.forEach((id) => { + deleteNote(id); + }); + toast.success(`Deleted ${ids.length} note${ids.length > 1 ? "s" : ""}`); + clearSelection(); + }; + + return ( +
+ + {selectedIds.size} selected + + +
+ + + + + +
+ + +
+ ); +} diff --git a/k-notes-frontend/src/components/bulk-selection-context.tsx b/k-notes-frontend/src/components/bulk-selection-context.tsx new file mode 100644 index 0000000..3486a74 --- /dev/null +++ b/k-notes-frontend/src/components/bulk-selection-context.tsx @@ -0,0 +1,65 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; + +interface BulkSelectionContextType { + selectedIds: Set; + isBulkMode: boolean; + toggleSelection: (id: string) => void; + selectAll: (ids: string[]) => void; + clearSelection: () => void; + isSelected: (id: string) => boolean; +} + +const BulkSelectionContext = createContext(null); + +export function BulkSelectionProvider({ children }: { children: ReactNode }) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggleSelection = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const selectAll = useCallback((ids: string[]) => { + setSelectedIds(new Set(ids)); + }, []); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const isSelected = useCallback((id: string) => { + return selectedIds.has(id); + }, [selectedIds]); + + const isBulkMode = selectedIds.size > 0; + + return ( + + {children} + + ); +} + +export function useBulkSelection() { + const context = useContext(BulkSelectionContext); + if (!context) { + throw new Error("useBulkSelection must be used within a BulkSelectionProvider"); + } + return context; +} diff --git a/k-notes-frontend/src/components/create-note-dialog.tsx b/k-notes-frontend/src/components/create-note-dialog.tsx index 85819e0..cf66295 100644 --- a/k-notes-frontend/src/components/create-note-dialog.tsx +++ b/k-notes-frontend/src/components/create-note-dialog.tsx @@ -6,16 +6,27 @@ import { NoteForm } from "./note-form"; import { toast } from "sonner"; import { Plus } from "lucide-react"; -export function CreateNoteDialog() { - const [open, setOpen] = useState(false); +interface CreateNoteDialogProps { + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }: CreateNoteDialogProps) { + const [internalOpen, setInternalOpen] = useState(false); const { mutate: createNote, isPending } = useCreateNote(); + // Support both controlled and uncontrolled modes + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled ? (onOpenChange ?? (() => { })) : setInternalOpen; + const onSubmit = (data: any) => { // Parse tags const tags = data.tags - ? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean) - : []; - + ? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean) + : []; + createNote({ ...data, tags }, { onSuccess: () => { toast.success("Note created"); @@ -27,14 +38,25 @@ export function CreateNoteDialog() { }); }; + const defaultTrigger = ( + + ); + return ( - - - + {trigger !== undefined && ( + + {trigger ?? defaultTrigger} + + )} + {trigger === undefined && ( + + {defaultTrigger} + + )} Create Note diff --git a/k-notes-frontend/src/components/layout.tsx b/k-notes-frontend/src/components/layout.tsx index 461b261..f856b36 100644 --- a/k-notes-frontend/src/components/layout.tsx +++ b/k-notes-frontend/src/components/layout.tsx @@ -5,36 +5,42 @@ import { Button } from "@/components/ui/button"; import { LogOut } from "lucide-react"; import { useLogout, useUser } from "@/hooks/use-auth"; import { ModeToggle } from "@/components/mode-toggle"; +import { BulkSelectionProvider } from "@/components/bulk-selection-context"; +import { BulkActionsBar } from "@/components/bulk-actions-bar"; export default function Layout() { const { mutate: logout } = useLogout(); const { data: user } = useUser(); return ( - - -
+ + + +
-
- - K-Notes Logo -
K-Notes
-
- -
-
- {user?.email} -
- - -
+
+ + K-Notes Logo +
K-Notes
+
+ +
+
+ {user?.email} +
+ + +
-
-
+
+ +
+ ) } + diff --git a/k-notes-frontend/src/components/note-card-skeleton.tsx b/k-notes-frontend/src/components/note-card-skeleton.tsx new file mode 100644 index 0000000..033ca47 --- /dev/null +++ b/k-notes-frontend/src/components/note-card-skeleton.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function NoteCardSkeleton() { + return ( + + +
+ + +
+ +
+ + + + + + +
+ + +
+
+ + + +
+
+
+ ); +} + +export function NoteCardSkeletonGrid({ count = 8 }: { count?: number }) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( + + ))} + + ); +} diff --git a/k-notes-frontend/src/components/note-card.tsx b/k-notes-frontend/src/components/note-card.tsx index 58a9dea..dae28a4 100644 --- a/k-notes-frontend/src/components/note-card.tsx +++ b/k-notes-frontend/src/components/note-card.tsx @@ -13,6 +13,8 @@ import { getNoteColor } from "@/lib/constants"; import clsx from "clsx"; import { VersionHistoryDialog } from "./version-history-dialog"; import { NoteViewDialog } from "./note-view-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useBulkSelection } from "@/components/bulk-selection-context"; interface NoteCardProps { note: Note; @@ -25,63 +27,87 @@ export function NoteCard({ note }: NoteCardProps) { const [historyOpen, setHistoryOpen] = useState(false); const [viewOpen, setViewOpen] = useState(false); + // Bulk selection + const { isSelected, toggleSelection, isBulkMode } = useBulkSelection(); + const selected = isSelected(note.id); + + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleSelection(note.id); + }; + // Archive toggle const toggleArchive = (e: React.MouseEvent) => { e.stopPropagation(); - updateNote({ - id: note.id, - is_archived: !note.is_archived + updateNote({ + id: note.id, + is_archived: !note.is_archived }); }; - + // Pin toggle const togglePin = (e: React.MouseEvent) => { - e.stopPropagation(); - updateNote({ - id: note.id, - is_pinned: !note.is_pinned - }); + e.stopPropagation(); + updateNote({ + id: note.id, + is_pinned: !note.is_pinned + }); }; const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation(); - if (confirm("Are you sure?")) { - deleteNote(note.id); - } + e.stopPropagation(); + if (confirm("Are you sure?")) { + deleteNote(note.id); + } } - + const handleEdit = (data: any) => { - const tags = data.tags - ? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean) - : []; - - updateNote({ - id: note.id, - ...data, - tags, - }, { - onSuccess: () => { - setEditing(false); - toast.success("Note updated"); - } - }); + const tags = data.tags + ? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean) + : []; + + updateNote({ + id: note.id, + ...data, + tags, + }, { + onSuccess: () => { + setEditing(false); + toast.success("Note updated"); + } + }); } const colorClass = getNoteColor(note.color); return ( <> - setViewOpen(true)} + onClick={() => !isBulkMode && setViewOpen(true)} > + {/* Bulk selection checkbox */} +
+ +
+
- {note.title} + {note.title} {note.is_pinned && }
@@ -103,19 +129,19 @@ export function NoteCard({ note }: NoteCardProps) {
@@ -123,24 +149,24 @@ export function NoteCard({ note }: NoteCardProps) { - - Edit Note - - t.name).join(", "), - }} - onSubmit={handleEdit} - submitLabel="Update" - /> + + Edit Note + + t.name).join(", "), + }} + onSubmit={handleEdit} + submitLabel="Update" + /> - - void; + onFocusSearch?: () => void; + onEscape?: () => void; +} + +export function useKeyboardShortcuts(config: KeyboardShortcutsConfig) { + const { onNewNote, onFocusSearch, onEscape } = config; + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + // Don't trigger shortcuts when typing in inputs/textareas + const target = event.target as HTMLElement; + const isInputField = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable; + + // Escape should always work (to close dialogs) + if (event.key === "Escape") { + onEscape?.(); + return; + } + + // Other shortcuts only work when not in an input field + if (isInputField) return; + + // 'n' for new note + if (event.key === "n" && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); + onNewNote?.(); + } + + // '/' to focus search + if (event.key === "/") { + event.preventDefault(); + onFocusSearch?.(); + } + }, [onNewNote, onFocusSearch, onEscape]); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); +} diff --git a/k-notes-frontend/src/hooks/use-notes.ts b/k-notes-frontend/src/hooks/use-notes.ts index b5568d5..06a51eb 100644 --- a/k-notes-frontend/src/hooks/use-notes.ts +++ b/k-notes-frontend/src/hooks/use-notes.ts @@ -64,6 +64,7 @@ export function useCreateNote() { mutationFn: (data: CreateNoteInput) => api.post("/notes", data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); }, }); } @@ -73,8 +74,42 @@ export function useUpdateNote() { return useMutation({ mutationFn: ({ id, ...data }: UpdateNoteInput) => api.patch(`/notes/${id}`, data), - onSuccess: () => { + + // Optimistic update + onMutate: async (updatedNote) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: ["notes"] }); + + // Snapshot the previous value + const previousNotes = queryClient.getQueriesData({ queryKey: ["notes"] }); + + // Optimistically update all matching queries + queryClient.setQueriesData({ queryKey: ["notes"] }, (old: Note[] | undefined) => { + if (!old) return old; + return old.map((note) => + note.id === updatedNote.id + ? { ...note, ...updatedNote } + : note + ); + }); + + // Return a context object with the snapshotted value + return { previousNotes }; + }, + + // If the mutation fails, use the context returned from onMutate to roll back + onError: (_err, _updatedNote, context) => { + if (context?.previousNotes) { + context.previousNotes.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + } + }, + + // Always refetch after error or success + onSettled: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); }, }); } @@ -84,8 +119,38 @@ export function useDeleteNote() { return useMutation({ mutationFn: (id: string) => api.delete(`/notes/${id}`), - onSuccess: () => { + + // Optimistic delete + onMutate: async (deletedId) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: ["notes"] }); + + // Snapshot the previous value + const previousNotes = queryClient.getQueriesData({ queryKey: ["notes"] }); + + // Optimistically remove from all matching queries + queryClient.setQueriesData({ queryKey: ["notes"] }, (old: Note[] | undefined) => { + if (!old) return old; + return old.filter((note) => note.id !== deletedId); + }); + + // Return a context object with the snapshotted value + return { previousNotes }; + }, + + // If the mutation fails, use the context returned from onMutate to roll back + onError: (_err, _deletedId, context) => { + if (context?.previousNotes) { + context.previousNotes.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + } + }, + + // Always refetch after error or success + onSettled: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); }, }); } @@ -112,3 +177,29 @@ export function useTags() { queryFn: () => api.get("/tags"), }); } + +export function useDeleteTag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => api.delete(`/tags/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useRenameTag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + api.patch(`/tags/${id}`, { name }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + diff --git a/k-notes-frontend/src/pages/dashboard.tsx b/k-notes-frontend/src/pages/dashboard.tsx index 3e6b99b..607b064 100644 --- a/k-notes-frontend/src/pages/dashboard.tsx +++ b/k-notes-frontend/src/pages/dashboard.tsx @@ -1,60 +1,125 @@ -import { useState } from "react"; -import { useNotes, useSearchNotes } from "@/hooks/use-notes"; +import { useState, useRef } from "react"; +import { useNotes, useSearchNotes, type Note } from "@/hooks/use-notes"; import { CreateNoteDialog } from "@/components/create-note-dialog"; import { NoteCard } from "@/components/note-card"; import { Input } from "@/components/ui/input"; -import { Search, LayoutGrid, List } from "lucide-react"; -import { useLocation } from "react-router-dom"; +import { Search, LayoutGrid, List, Plus, Pin, X } from "lucide-react"; +import { useLocation, useSearchParams, Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; import clsx from "clsx"; +import Masonry from "react-masonry-css"; +import { NoteCardSkeletonGrid } from "@/components/note-card-skeleton"; +import { Badge } from "@/components/ui/badge"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; + +// Masonry breakpoint columns configuration +const masonryBreakpoints = { + default: 4, + 1280: 4, + 1024: 3, + 768: 2, + 640: 1, +}; export default function DashboardPage() { const location = useLocation(); + const [searchParams] = useSearchParams(); const isArchive = location.pathname === "/archive"; - + const activeTag = searchParams.get("tag"); + // View mode state const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); // Search state const [searchQuery, setSearchQuery] = useState(""); - - // Fetch normal notes only if not searching - const { data: notes, isLoading: notesLoading } = useNotes(searchQuery ? undefined : { archived: isArchive }); - + const searchInputRef = useRef(null); + + // Create note dialog state (keyboard controlled) + const [createNoteOpen, setCreateNoteOpen] = useState(false); + + // Keyboard shortcuts + useKeyboardShortcuts({ + onNewNote: () => !isArchive && setCreateNoteOpen(true), + onFocusSearch: () => searchInputRef.current?.focus(), + onEscape: () => { + searchInputRef.current?.blur(); + setCreateNoteOpen(false); + }, + }); + + // Fetch notes with optional tag filter + const { data: notes, isLoading: notesLoading } = useNotes( + searchQuery ? undefined : { archived: isArchive, tag: activeTag ?? undefined } + ); + // Fetch search results if searching const { data: searchResults, isLoading: searchLoading } = useSearchNotes(searchQuery); const displayNotes = searchQuery ? searchResults : notes; const isLoading = searchQuery ? searchLoading : notesLoading; + // Separate pinned and unpinned notes + const pinnedNotes = !searchQuery && !isArchive + ? (displayNotes?.filter((n: Note) => n.is_pinned) ?? []) + : []; + const unpinnedNotes = displayNotes?.filter((n: Note) => searchQuery || isArchive || !n.is_pinned) ?? []; + + const renderNotes = (notesList: Note[]) => { + if (viewMode === "list") { + return ( +
+ {notesList.map((note: Note) => ( + + ))} +
+ ); + } + + return ( + + {notesList.map((note: Note) => ( +
+ +
+ ))} +
+ ); + }; + return ( -
+
{/* Action Bar */}
-
+
- setSearchQuery(e.target.value)} />
- +
- -
- {!isArchive && } + {!isArchive && ( +
+ +
+ )}
- {/* Title */} -

- {isArchive ? "Archive" : "Notes"} -

+ {/* Active Tag Filter Badge */} + {activeTag && ( +
+ Filtering by: + + {activeTag} + + + + +
+ )} - {/* Loading State */} + {/* Loading State - Skeleton */} {isLoading && ( -
- Loading your ideas... +
+
)} @@ -82,32 +163,55 @@ export default function DashboardPage() { {!isLoading && displayNotes?.length === 0 && (
- {searchQuery - ? "No matching notes found" - : isArchive - ? "No archived notes yet" - : "Your notes will appear here. Click + to create one." + {searchQuery + ? "No matching notes found" + : activeTag + ? `No notes with tag "${activeTag}"` + : isArchive + ? "No archived notes yet" + : "Your notes will appear here. Click + to create one." }
)} - {/* Notes Grid/List */} -
- {/* Pinned Notes First (if not searching and not archive) */} - {!searchQuery && !isArchive && displayNotes?.filter((n: any) => n.is_pinned).map((note: any) => ( - - ))} - - {/* Other Notes */} - {displayNotes?.filter((n: any) => searchQuery || isArchive || !n.is_pinned).map((note: any) => ( - - ))} -
+ {/* Pinned Notes Section */} + {!isLoading && pinnedNotes.length > 0 && ( +
+
+ + Pinned +
+ {renderNotes(pinnedNotes)} +
+ )} + + {/* Other Notes Section */} + {!isLoading && unpinnedNotes.length > 0 && ( +
+ {pinnedNotes.length > 0 && ( +
+ Others +
+ )} + {renderNotes(unpinnedNotes)} +
+ )} + + {/* Floating Action Button (Mobile only) */} + {!isArchive && ( + + + + } + /> + )}
); } + diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs index e617a5b..c47efde 100644 --- a/notes-api/src/dto.rs +++ b/notes-api/src/dto.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; -use notes_domain::{Note, NoteFilter, Tag}; +use notes_domain::{Note, Tag}; /// Request to create a new note #[derive(Debug, Deserialize, Validate)] @@ -47,17 +47,8 @@ pub struct UpdateNoteRequest { pub struct ListNotesQuery { pub pinned: Option, pub archived: Option, - pub tag: Option, -} - -impl From for NoteFilter { - fn from(query: ListNotesQuery) -> Self { - let mut filter = NoteFilter::new(); - filter.is_pinned = query.pinned; - filter.is_archived = query.archived; - filter.tag_id = query.tag; - filter - } + /// Tag name to filter by (will be looked up by route handler) + pub tag: Option, } /// Query parameters for search @@ -119,6 +110,13 @@ pub struct CreateTagRequest { pub name: String, } +/// Request to rename a tag +#[derive(Debug, Deserialize, Validate)] +pub struct RenameTagRequest { + #[validate(length(min = 1, max = 50, message = "Tag name must be 1-50 characters"))] + pub name: String, +} + /// Login request #[derive(Debug, Deserialize, Validate)] pub struct LoginRequest { diff --git a/notes-api/src/routes/mod.rs b/notes-api/src/routes/mod.rs index 7da7636..770d18f 100644 --- a/notes-api/src/routes/mod.rs +++ b/notes-api/src/routes/mod.rs @@ -36,5 +36,8 @@ pub fn api_v1_router() -> Router { .route("/import", post(import_export::import_data)) // Tag routes .route("/tags", get(tags::list_tags).post(tags::create_tag)) - .route("/tags/{id}", delete(tags::delete_tag)) + .route( + "/tags/{id}", + delete(tags::delete_tag).patch(tags::rename_tag), + ) } diff --git a/notes-api/src/routes/notes.rs b/notes-api/src/routes/notes.rs index c427679..b4da472 100644 --- a/notes-api/src/routes/notes.rs +++ b/notes-api/src/routes/notes.rs @@ -33,9 +33,23 @@ pub async fn list_notes( )))?; let user_id = user.id(); - let service = NoteService::new(state.note_repo, state.tag_repo); + // Build the filter, looking up tag_id by name if needed + let mut filter = notes_domain::NoteFilter::new(); + filter.is_pinned = query.pinned; + filter.is_archived = query.archived; - let notes = service.list_notes(user_id, query.into()).await?; + // Look up tag by name if provided + if let Some(ref tag_name) = query.tag { + if let Ok(Some(tag)) = state.tag_repo.find_by_name(user_id, tag_name).await { + filter.tag_id = Some(tag.id); + } else { + // Tag not found, return empty results + return Ok(Json(vec![])); + } + } + + let service = NoteService::new(state.note_repo, state.tag_repo); + let notes = service.list_notes(user_id, filter).await?; let response: Vec = notes.into_iter().map(NoteResponse::from).collect(); Ok(Json(response)) diff --git a/notes-api/src/routes/tags.rs b/notes-api/src/routes/tags.rs index fc8ab32..0e959bf 100644 --- a/notes-api/src/routes/tags.rs +++ b/notes-api/src/routes/tags.rs @@ -1,9 +1,9 @@ //! Tag route handlers use axum::{ + Json, extract::{Path, State}, http::StatusCode, - Json, }; use axum_login::{AuthSession, AuthUser}; use uuid::Uuid; @@ -12,7 +12,7 @@ use validator::Validate; use notes_domain::TagService; use crate::auth::AuthBackend; -use crate::dto::{CreateTagRequest, TagResponse}; +use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse}; use crate::error::{ApiError, ApiResult}; use crate::state::AppState; @@ -22,14 +22,18 @@ pub async fn list_tags( State(state): State, auth: AuthSession, ) -> ApiResult>> { - let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; let user_id = user.id(); let service = TagService::new(state.tag_repo); - + let tags = service.list_tags(user_id).await?; let response: Vec = tags.into_iter().map(TagResponse::from).collect(); - + Ok(Json(response)) } @@ -40,18 +44,50 @@ pub async fn create_tag( auth: AuthSession, Json(payload): Json, ) -> ApiResult<(StatusCode, Json)> { - let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; let user_id = user.id(); - payload.validate().map_err(|e| ApiError::validation(e.to_string()))?; - + payload + .validate() + .map_err(|e| ApiError::validation(e.to_string()))?; + let service = TagService::new(state.tag_repo); - + let tag = service.create_tag(user_id, &payload.name).await?; - + Ok((StatusCode::CREATED, Json(TagResponse::from(tag)))) } +/// Rename a tag +/// PATCH /api/v1/tags/:id +pub async fn rename_tag( + State(state): State, + auth: AuthSession, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; + let user_id = user.id(); + + payload + .validate() + .map_err(|e| ApiError::validation(e.to_string()))?; + + let service = TagService::new(state.tag_repo); + + let tag = service.rename_tag(id, user_id, &payload.name).await?; + + Ok(Json(TagResponse::from(tag))) +} + /// Delete a tag /// DELETE /api/v1/tags/:id pub async fn delete_tag( @@ -59,12 +95,16 @@ pub async fn delete_tag( auth: AuthSession, Path(id): Path, ) -> ApiResult { - let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; let user_id = user.id(); let service = TagService::new(state.tag_repo); - + service.delete_tag(id, user_id).await?; - + Ok(StatusCode::NO_CONTENT) } diff --git a/notes-domain/src/services.rs b/notes-domain/src/services.rs index 40daab3..c8f240a 100644 --- a/notes-domain/src/services.rs +++ b/notes-domain/src/services.rs @@ -277,6 +277,40 @@ impl TagService { self.tag_repo.delete(id).await } + + /// Rename a tag + pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult { + let new_name = new_name.trim().to_lowercase(); + if new_name.is_empty() { + return Err(DomainError::validation("Tag name cannot be empty")); + } + + // Find the existing tag + let mut tag = self + .tag_repo + .find_by_id(id) + .await? + .ok_or(DomainError::TagNotFound(id))?; + + // Authorization check + if tag.user_id != user_id { + return Err(DomainError::unauthorized( + "Cannot rename another user's tag", + )); + } + + // Check if new name already exists (and it's not the same tag) + if let Some(existing) = self.tag_repo.find_by_name(user_id, &new_name).await? { + if existing.id != id { + return Err(DomainError::TagAlreadyExists(new_name)); + } + } + + // Update the name + tag.name = new_name; + self.tag_repo.save(&tag).await?; + Ok(tag) + } } /// Service for User operations (OIDC-ready)