Frontend improvements

Reviewed-on: #1
This commit is contained in:
2025-12-23 10:28:03 +00:00
parent 3a19995008
commit 8c010e06e5
17 changed files with 896 additions and 167 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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 (
<SidebarMenuItem>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleRename}
onKeyDown={handleKeyDown}
autoFocus
className="h-7 text-xs"
/>
</SidebarMenuItem>
);
}
return (
<SidebarMenuItem className="group/tag">
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={tag.name}
className="pr-0"
>
<Link to={`/?tag=${encodeURIComponent(tag.name)}`} className="flex items-center justify-between w-full">
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{tag.name}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover/tag:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={(e) => { e.preventDefault(); setIsEditing(true); }}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.preventDefault(); handleDelete(); }} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
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() {
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={location.pathname === item.url} tooltip={item.title}>
<SidebarMenuButton asChild isActive={location.pathname === item.url && !activeTag} tooltip={item.title}>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
@@ -58,9 +181,46 @@ export function AppSidebar() {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Tag Browser Section */}
<SidebarGroup>
<Collapsible open={tagsOpen} onOpenChange={setTagsOpen}>
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="flex items-center justify-between w-full cursor-pointer group/collapsible">
<div className="flex items-center gap-1.5">
<Tag className="h-3.5 w-3.5" />
<span>Tags</span>
</div>
<ChevronRight className="h-3.5 w-3.5 transition-transform group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<ScrollArea className="max-h-48">
<SidebarMenu>
{tags && tags.length > 0 ? (
tags.map((tag: { id: string; name: string }) => (
<TagItem
key={tag.id}
tag={tag}
isActive={activeTag === tag.name}
/>
))
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No tags yet
</div>
)}
</SidebarMenu>
</ScrollArea>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} dataManagementEnabled />
</>
)
}

View File

@@ -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 (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-background border rounded-full px-4 py-2 shadow-lg animate-in slide-in-from-bottom-4 duration-200">
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<div className="h-4 w-px bg-border" />
<Button
variant="ghost"
size="sm"
onClick={handleArchiveAll}
className="gap-2"
>
<Archive className="h-4 w-4" />
Archive
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDeleteAll}
className="gap-2 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
<div className="h-4 w-px bg-border" />
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
interface BulkSelectionContextType {
selectedIds: Set<string>;
isBulkMode: boolean;
toggleSelection: (id: string) => void;
selectAll: (ids: string[]) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
}
const BulkSelectionContext = createContext<BulkSelectionContextType | null>(null);
export function BulkSelectionProvider({ children }: { children: ReactNode }) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
<BulkSelectionContext.Provider
value={{
selectedIds,
isBulkMode,
toggleSelection,
selectAll,
clearSelection,
isSelected,
}}
>
{children}
</BulkSelectionContext.Provider>
);
}
export function useBulkSelection() {
const context = useContext(BulkSelectionContext);
if (!context) {
throw new Error("useBulkSelection must be used within a BulkSelectionProvider");
}
return context;
}

View File

@@ -6,15 +6,26 @@ 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: () => {
@@ -27,14 +38,25 @@ export function CreateNoteDialog() {
});
};
const defaultTrigger = (
<Button>
<Plus className="mr-2 h-4 w-4" />
New Note
</Button>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Note
</Button>
</DialogTrigger>
{trigger !== undefined && (
<DialogTrigger asChild>
{trigger ?? defaultTrigger}
</DialogTrigger>
)}
{trigger === undefined && (
<DialogTrigger asChild>
{defaultTrigger}
</DialogTrigger>
)}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Note</DialogTitle>

View File

@@ -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 (
<SidebarProvider>
<AppSidebar />
<main className="w-full flex flex-col min-h-screen">
<BulkSelectionProvider>
<SidebarProvider>
<AppSidebar />
<main className="w-full flex flex-col min-h-screen">
<header className="border-b bg-background/95 backdrop-blur h-14 flex items-center justify-between px-4 sticky top-0 z-10">
<div className="flex items-center gap-2">
<SidebarTrigger />
<img src="/logo.png" alt="K-Notes Logo" className="h-8 w-8 object-contain" />
<div className="font-semibold">K-Notes</div>
</div>
<div className="flex items-center gap-2">
<SidebarTrigger />
<img src="/logo.png" alt="K-Notes Logo" className="h-8 w-8 object-contain" />
<div className="font-semibold">K-Notes</div>
</div>
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground hidden sm:block">
{user?.email}
</div>
<ModeToggle />
<Button variant="ghost" size="icon" onClick={() => logout()} title="Logout">
<LogOut className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground hidden sm:block">
{user?.email}
</div>
<ModeToggle />
<Button variant="ghost" size="icon" onClick={() => logout()} title="Logout">
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
<div className="flex-1 p-4 md:p-6 bg-muted/10">
<Outlet />
</div>
</main>
</SidebarProvider>
</main>
<BulkActionsBar />
</SidebarProvider>
</BulkSelectionProvider>
)
}

View File

@@ -0,0 +1,42 @@
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export function NoteCardSkeleton() {
return (
<Card className="relative">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-4 rounded-full" />
</div>
<Skeleton className="h-3 w-24 mt-1" />
</CardHeader>
<CardContent className="pb-2 space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</CardContent>
<CardFooter className="flex flex-col items-start gap-2 pt-2">
<div className="flex flex-wrap gap-1">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="flex justify-end w-full gap-1">
<Skeleton className="h-8 w-8 rounded" />
<Skeleton className="h-8 w-8 rounded" />
<Skeleton className="h-8 w-8 rounded" />
</div>
</CardFooter>
</Card>
);
}
export function NoteCardSkeletonGrid({ count = 8 }: { count?: number }) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</>
);
}

View File

@@ -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,46 +27,55 @@ 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
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)
: [];
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");
}
});
updateNote({
id: note.id,
...data,
tags,
}, {
onSuccess: () => {
setEditing(false);
toast.success("Note updated");
}
});
}
const colorClass = getNoteColor(note.color);
@@ -75,13 +86,28 @@ export function NoteCard({ note }: NoteCardProps) {
className={clsx(
"relative group transition-all hover:shadow-md cursor-pointer",
colorClass,
note.is_pinned ? 'border-primary shadow-sm' : ''
note.is_pinned ? 'border-primary shadow-sm' : '',
selected && 'ring-2 ring-primary ring-offset-2'
)}
onClick={() => setViewOpen(true)}
onClick={() => !isBulkMode && setViewOpen(true)}
>
{/* Bulk selection checkbox */}
<div
className={clsx(
"absolute top-2 left-2 z-10 transition-opacity",
isBulkMode ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={handleCheckboxClick}
>
<Checkbox
checked={selected}
className="h-5 w-5 bg-background/80 backdrop-blur-sm border-2"
/>
</div>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-lg font-semibold line-clamp-1">{note.title}</CardTitle>
<CardTitle className={clsx("text-lg font-semibold line-clamp-1", isBulkMode && "pl-6")}>{note.title}</CardTitle>
{note.is_pinned && <Pin className="h-4 w-4 text-primary rotate-45" />}
</div>
<CardDescription className="text-xs opacity-70">
@@ -103,19 +129,19 @@ export function NoteCard({ note }: NoteCardProps) {
</div>
<div className="flex justify-end w-full gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={(e) => { e.stopPropagation(); setHistoryOpen(true); }} title="History">
<History className="h-4 w-4" />
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={(e) => { e.stopPropagation(); setEditing(true); }}>
<Edit className="h-4 w-4" />
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={togglePin}>
<Pin className={`h-4 w-4 ${note.is_pinned ? 'fill-current' : ''}`} />
<Pin className={`h-4 w-4 ${note.is_pinned ? 'fill-current' : ''}`} />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={toggleArchive}>
<Archive className={`h-4 w-4 ${note.is_archived ? 'fill-current' : ''}`} />
<Archive className={`h-4 w-4 ${note.is_archived ? 'fill-current' : ''}`} />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardFooter>
@@ -123,20 +149,20 @@ export function NoteCard({ note }: NoteCardProps) {
<Dialog open={editing} onOpenChange={setEditing}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Note</DialogTitle>
</DialogHeader>
<NoteForm
defaultValues={{
title: note.title,
content: note.content,
is_pinned: note.is_pinned,
color: note.color,
tags: note.tags.map(t => t.name).join(", "),
}}
onSubmit={handleEdit}
submitLabel="Update"
/>
<DialogHeader>
<DialogTitle>Edit Note</DialogTitle>
</DialogHeader>
<NoteForm
defaultValues={{
title: note.title,
content: note.content,
is_pinned: note.is_pinned,
color: note.color,
tags: note.tags.map(t => t.name).join(", "),
}}
onSubmit={handleEdit}
submitLabel="Update"
/>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,46 @@
import { useEffect, useCallback } from "react";
interface KeyboardShortcutsConfig {
onNewNote?: () => 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]);
}

View File

@@ -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"] });
},
});
}

View File

@@ -1,25 +1,56 @@
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("");
const searchInputRef = useRef<HTMLInputElement>(null);
// Fetch normal notes only if not searching
const { data: notes, isLoading: notesLoading } = useNotes(searchQuery ? undefined : { archived: isArchive });
// 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);
@@ -27,13 +58,47 @@ export default function DashboardPage() {
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 (
<div className="flex flex-col gap-4 max-w-3xl mx-auto">
{notesList.map((note: Note) => (
<NoteCard key={note.id} note={note} />
))}
</div>
);
}
return (
<Masonry
breakpointCols={masonryBreakpoints}
className="flex -ml-4 w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{notesList.map((note: Note) => (
<div key={note.id} className="mb-4">
<NoteCard note={note} />
</div>
))}
</Masonry>
);
};
return (
<div className="max-w-7xl mx-auto">
<div className="max-w-7xl mx-auto pb-20 md:pb-0">
{/* Action Bar */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-center mb-6">
<div className="relative w-full md:w-96">
<div className="relative w-full md:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
id="search-input"
placeholder="Search your notes..."
className="pl-9 w-full bg-background"
value={searchQuery}
@@ -62,19 +127,35 @@ export default function DashboardPage() {
<List className="h-4 w-4" />
</Button>
</div>
{!isArchive && <CreateNoteDialog />}
{!isArchive && (
<div className="hidden md:block">
<CreateNoteDialog open={createNoteOpen} onOpenChange={setCreateNoteOpen} />
</div>
)}
</div>
</div>
{/* Title */}
<h1 className="text-2xl font-bold mb-4 hidden">
{isArchive ? "Archive" : "Notes"}
</h1>
{/* Active Tag Filter Badge */}
{activeTag && (
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-muted-foreground">Filtering by:</span>
<Badge variant="secondary" className="flex items-center gap-1">
{activeTag}
<Link to="/" className="ml-1 hover:text-destructive">
<X className="h-3 w-3" />
</Link>
</Badge>
</div>
)}
{/* Loading State */}
{/* Loading State - Skeleton */}
{isLoading && (
<div className="text-center py-12 text-muted-foreground animate-pulse">
Loading your ideas...
<div className={clsx(
viewMode === "grid"
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
: "flex flex-col gap-4 max-w-3xl mx-auto"
)}>
<NoteCardSkeletonGrid count={viewMode === "list" ? 4 : 8} />
</div>
)}
@@ -84,30 +165,53 @@ export default function DashboardPage() {
<div className="text-muted-foreground">
{searchQuery
? "No matching notes found"
: isArchive
? "No archived notes yet"
: "Your notes will appear here. Click + to create one."
: activeTag
? `No notes with tag "${activeTag}"`
: isArchive
? "No archived notes yet"
: "Your notes will appear here. Click + to create one."
}
</div>
</div>
)}
{/* Notes Grid/List */}
<div className={clsx(
viewMode === "grid"
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start"
: "flex flex-col gap-4 max-w-3xl mx-auto"
)}>
{/* Pinned Notes First (if not searching and not archive) */}
{!searchQuery && !isArchive && displayNotes?.filter((n: any) => n.is_pinned).map((note: any) => (
<NoteCard key={note.id} note={note} />
))}
{/* Pinned Notes Section */}
{!isLoading && pinnedNotes.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3 text-muted-foreground">
<Pin className="h-4 w-4 rotate-45" />
<span className="text-sm font-medium uppercase tracking-wide">Pinned</span>
</div>
{renderNotes(pinnedNotes)}
</div>
)}
{/* Other Notes */}
{displayNotes?.filter((n: any) => searchQuery || isArchive || !n.is_pinned).map((note: any) => (
<NoteCard key={note.id} note={note} />
))}
</div>
{/* Other Notes Section */}
{!isLoading && unpinnedNotes.length > 0 && (
<div>
{pinnedNotes.length > 0 && (
<div className="flex items-center gap-2 mb-3 text-muted-foreground border-t pt-4">
<span className="text-sm font-medium uppercase tracking-wide">Others</span>
</div>
)}
{renderNotes(unpinnedNotes)}
</div>
)}
{/* Floating Action Button (Mobile only) */}
{!isArchive && (
<CreateNoteDialog
trigger={
<Button
size="icon"
className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-lg md:hidden z-50 hover:scale-105 transition-transform"
>
<Plus className="h-6 w-6" />
</Button>
}
/>
)}
</div>
);
}

View File

@@ -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<bool>,
pub archived: Option<bool>,
pub tag: Option<Uuid>,
}
impl From<ListNotesQuery> 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<String>,
}
/// 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 {

View File

@@ -36,5 +36,8 @@ pub fn api_v1_router() -> Router<AppState> {
.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),
)
}

View File

@@ -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<NoteResponse> = notes.into_iter().map(NoteResponse::from).collect();
Ok(Json(response))

View File

@@ -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,7 +22,11 @@ pub async fn list_tags(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
) -> ApiResult<Json<Vec<TagResponse>>> {
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);
@@ -40,10 +44,16 @@ pub async fn create_tag(
auth: AuthSession<AuthBackend>,
Json(payload): Json<CreateTagRequest>,
) -> ApiResult<(StatusCode, Json<TagResponse>)> {
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);
@@ -52,6 +62,32 @@ pub async fn create_tag(
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
}
/// Rename a tag
/// PATCH /api/v1/tags/:id
pub async fn rename_tag(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
Json(payload): Json<RenameTagRequest>,
) -> ApiResult<Json<TagResponse>> {
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,7 +95,11 @@ pub async fn delete_tag(
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
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);

View File

@@ -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<Tag> {
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)