@@ -46,6 +46,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
"react-resizable-panels": "^4.0.15",
|
"react-resizable-panels": "^4.0.15",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "2.15.4",
|
"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-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-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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
"react-resizable-panels": "^4.0.15",
|
"react-resizable-panels": "^4.0.15",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Home, Archive, Settings } from "lucide-react"
|
import { Home, Archive, Settings, Tag, ChevronRight, Pencil, Trash2, MoreHorizontal } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -9,9 +9,22 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} 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 { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useState } from "react"
|
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 = [
|
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() {
|
export function AppSidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [tagsOpen, setTagsOpen] = useState(true);
|
||||||
|
|
||||||
|
const { data: tags } = useTags();
|
||||||
|
const activeTag = searchParams.get("tag");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -40,7 +163,7 @@ export function AppSidebar() {
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<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}>
|
<Link to={item.url}>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
@@ -58,9 +181,46 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</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>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} dataManagementEnabled />
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} dataManagementEnabled />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
k-notes-frontend/src/components/bulk-actions-bar.tsx
Normal file
74
k-notes-frontend/src/components/bulk-actions-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
k-notes-frontend/src/components/bulk-selection-context.tsx
Normal file
65
k-notes-frontend/src/components/bulk-selection-context.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,16 +6,27 @@ import { NoteForm } from "./note-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
export function CreateNoteDialog() {
|
interface CreateNoteDialogProps {
|
||||||
const [open, setOpen] = useState(false);
|
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();
|
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) => {
|
const onSubmit = (data: any) => {
|
||||||
// Parse tags
|
// Parse tags
|
||||||
const tags = data.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 }, {
|
createNote({ ...data, tags }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Note created");
|
toast.success("Note created");
|
||||||
@@ -27,14 +38,25 @@ export function CreateNoteDialog() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultTrigger = (
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Note
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
{trigger !== undefined && (
|
||||||
<Button>
|
<DialogTrigger asChild>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
{trigger ?? defaultTrigger}
|
||||||
New Note
|
</DialogTrigger>
|
||||||
</Button>
|
)}
|
||||||
</DialogTrigger>
|
{trigger === undefined && (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{defaultTrigger}
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Note</DialogTitle>
|
<DialogTitle>Create Note</DialogTitle>
|
||||||
|
|||||||
@@ -5,36 +5,42 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import { useLogout, useUser } from "@/hooks/use-auth";
|
import { useLogout, useUser } from "@/hooks/use-auth";
|
||||||
import { ModeToggle } from "@/components/mode-toggle";
|
import { ModeToggle } from "@/components/mode-toggle";
|
||||||
|
import { BulkSelectionProvider } from "@/components/bulk-selection-context";
|
||||||
|
import { BulkActionsBar } from "@/components/bulk-actions-bar";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { mutate: logout } = useLogout();
|
const { mutate: logout } = useLogout();
|
||||||
const { data: user } = useUser();
|
const { data: user } = useUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<BulkSelectionProvider>
|
||||||
<AppSidebar />
|
<SidebarProvider>
|
||||||
<main className="w-full flex flex-col min-h-screen">
|
<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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<img src="/logo.png" alt="K-Notes Logo" className="h-8 w-8 object-contain" />
|
<img src="/logo.png" alt="K-Notes Logo" className="h-8 w-8 object-contain" />
|
||||||
<div className="font-semibold">K-Notes</div>
|
<div className="font-semibold">K-Notes</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm text-muted-foreground hidden sm:block">
|
<div className="text-sm text-muted-foreground hidden sm:block">
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</div>
|
</div>
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Button variant="ghost" size="icon" onClick={() => logout()} title="Logout">
|
<Button variant="ghost" size="icon" onClick={() => logout()} title="Logout">
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 p-4 md:p-6 bg-muted/10">
|
<div className="flex-1 p-4 md:p-6 bg-muted/10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SidebarProvider>
|
<BulkActionsBar />
|
||||||
|
</SidebarProvider>
|
||||||
|
</BulkSelectionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
k-notes-frontend/src/components/note-card-skeleton.tsx
Normal file
42
k-notes-frontend/src/components/note-card-skeleton.tsx
Normal 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} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { getNoteColor } from "@/lib/constants";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { VersionHistoryDialog } from "./version-history-dialog";
|
import { VersionHistoryDialog } from "./version-history-dialog";
|
||||||
import { NoteViewDialog } from "./note-view-dialog";
|
import { NoteViewDialog } from "./note-view-dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useBulkSelection } from "@/components/bulk-selection-context";
|
||||||
|
|
||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
note: Note;
|
note: Note;
|
||||||
@@ -25,63 +27,87 @@ export function NoteCard({ note }: NoteCardProps) {
|
|||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [viewOpen, setViewOpen] = 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
|
// Archive toggle
|
||||||
const toggleArchive = (e: React.MouseEvent) => {
|
const toggleArchive = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
updateNote({
|
updateNote({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
is_archived: !note.is_archived
|
is_archived: !note.is_archived
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pin toggle
|
// Pin toggle
|
||||||
const togglePin = (e: React.MouseEvent) => {
|
const togglePin = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
updateNote({
|
updateNote({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
is_pinned: !note.is_pinned
|
is_pinned: !note.is_pinned
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm("Are you sure?")) {
|
if (confirm("Are you sure?")) {
|
||||||
deleteNote(note.id);
|
deleteNote(note.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (data: any) => {
|
const handleEdit = (data: any) => {
|
||||||
const tags = data.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)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
updateNote({
|
updateNote({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
...data,
|
...data,
|
||||||
tags,
|
tags,
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
toast.success("Note updated");
|
toast.success("Note updated");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorClass = getNoteColor(note.color);
|
const colorClass = getNoteColor(note.color);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative group transition-all hover:shadow-md cursor-pointer",
|
"relative group transition-all hover:shadow-md cursor-pointer",
|
||||||
colorClass,
|
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">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start">
|
<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" />}
|
{note.is_pinned && <Pin className="h-4 w-4 text-primary rotate-45" />}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-xs opacity-70">
|
<CardDescription className="text-xs opacity-70">
|
||||||
@@ -103,19 +129,19 @@ export function NoteCard({ note }: NoteCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end w-full gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
<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">
|
<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>
|
||||||
<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); }}>
|
<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>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={togglePin}>
|
<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>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={toggleArchive}>
|
<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>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10" onClick={handleDelete}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
@@ -123,24 +149,24 @@ export function NoteCard({ note }: NoteCardProps) {
|
|||||||
|
|
||||||
<Dialog open={editing} onOpenChange={setEditing}>
|
<Dialog open={editing} onOpenChange={setEditing}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Note</DialogTitle>
|
<DialogTitle>Edit Note</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<NoteForm
|
<NoteForm
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
title: note.title,
|
title: note.title,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
is_pinned: note.is_pinned,
|
is_pinned: note.is_pinned,
|
||||||
color: note.color,
|
color: note.color,
|
||||||
tags: note.tags.map(t => t.name).join(", "),
|
tags: note.tags.map(t => t.name).join(", "),
|
||||||
}}
|
}}
|
||||||
onSubmit={handleEdit}
|
onSubmit={handleEdit}
|
||||||
submitLabel="Update"
|
submitLabel="Update"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<VersionHistoryDialog
|
<VersionHistoryDialog
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={setHistoryOpen}
|
onOpenChange={setHistoryOpen}
|
||||||
noteId={note.id}
|
noteId={note.id}
|
||||||
|
|||||||
46
k-notes-frontend/src/hooks/use-keyboard-shortcuts.ts
Normal file
46
k-notes-frontend/src/hooks/use-keyboard-shortcuts.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ export function useCreateNote() {
|
|||||||
mutationFn: (data: CreateNoteInput) => api.post("/notes", data),
|
mutationFn: (data: CreateNoteInput) => api.post("/notes", data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -73,8 +74,42 @@ export function useUpdateNote() {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, ...data }: UpdateNoteInput) => api.patch(`/notes/${id}`, data),
|
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: ["notes"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,8 +119,38 @@ export function useDeleteNote() {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/notes/${id}`),
|
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: ["notes"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -112,3 +177,29 @@ export function useTags() {
|
|||||||
queryFn: () => api.get("/tags"),
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,125 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useNotes, useSearchNotes } from "@/hooks/use-notes";
|
import { useNotes, useSearchNotes, type Note } from "@/hooks/use-notes";
|
||||||
import { CreateNoteDialog } from "@/components/create-note-dialog";
|
import { CreateNoteDialog } from "@/components/create-note-dialog";
|
||||||
import { NoteCard } from "@/components/note-card";
|
import { NoteCard } from "@/components/note-card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, LayoutGrid, List } from "lucide-react";
|
import { Search, LayoutGrid, List, Plus, Pin, X } from "lucide-react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation, useSearchParams, Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import clsx from "clsx";
|
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() {
|
export default function DashboardPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const isArchive = location.pathname === "/archive";
|
const isArchive = location.pathname === "/archive";
|
||||||
|
const activeTag = searchParams.get("tag");
|
||||||
|
|
||||||
// View mode state
|
// View mode state
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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
|
// Fetch search results if searching
|
||||||
const { data: searchResults, isLoading: searchLoading } = useSearchNotes(searchQuery);
|
const { data: searchResults, isLoading: searchLoading } = useSearchNotes(searchQuery);
|
||||||
|
|
||||||
const displayNotes = searchQuery ? searchResults : notes;
|
const displayNotes = searchQuery ? searchResults : notes;
|
||||||
const isLoading = searchQuery ? searchLoading : notesLoading;
|
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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto pb-20 md:pb-0">
|
||||||
{/* Action Bar */}
|
{/* Action Bar */}
|
||||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-center mb-6">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search your notes..."
|
ref={searchInputRef}
|
||||||
|
id="search-input"
|
||||||
|
placeholder="Search your notes..."
|
||||||
className="pl-9 w-full bg-background"
|
className="pl-9 w-full bg-background"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center bg-muted/50 p-1 rounded-lg border">
|
<div className="flex items-center bg-muted/50 p-1 rounded-lg border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={clsx("h-8 w-8", viewMode === "grid" && "bg-background shadow-sm")}
|
className={clsx("h-8 w-8", viewMode === "grid" && "bg-background shadow-sm")}
|
||||||
onClick={() => setViewMode("grid")}
|
onClick={() => setViewMode("grid")}
|
||||||
title="Grid View"
|
title="Grid View"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={clsx("h-8 w-8", viewMode === "list" && "bg-background shadow-sm")}
|
className={clsx("h-8 w-8", viewMode === "list" && "bg-background shadow-sm")}
|
||||||
onClick={() => setViewMode("list")}
|
onClick={() => setViewMode("list")}
|
||||||
title="List View"
|
title="List View"
|
||||||
@@ -62,19 +127,35 @@ export default function DashboardPage() {
|
|||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{!isArchive && <CreateNoteDialog />}
|
{!isArchive && (
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<CreateNoteDialog open={createNoteOpen} onOpenChange={setCreateNoteOpen} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Active Tag Filter Badge */}
|
||||||
<h1 className="text-2xl font-bold mb-4 hidden">
|
{activeTag && (
|
||||||
{isArchive ? "Archive" : "Notes"}
|
<div className="flex items-center gap-2 mb-4">
|
||||||
</h1>
|
<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 && (
|
{isLoading && (
|
||||||
<div className="text-center py-12 text-muted-foreground animate-pulse">
|
<div className={clsx(
|
||||||
Loading your ideas...
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -82,32 +163,55 @@ export default function DashboardPage() {
|
|||||||
{!isLoading && displayNotes?.length === 0 && (
|
{!isLoading && displayNotes?.length === 0 && (
|
||||||
<div className="text-center py-20 bg-background rounded-lg border border-dashed">
|
<div className="text-center py-20 bg-background rounded-lg border border-dashed">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? "No matching notes found"
|
? "No matching notes found"
|
||||||
: isArchive
|
: activeTag
|
||||||
? "No archived notes yet"
|
? `No notes with tag "${activeTag}"`
|
||||||
: "Your notes will appear here. Click + to create one."
|
: isArchive
|
||||||
|
? "No archived notes yet"
|
||||||
|
: "Your notes will appear here. Click + to create one."
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes Grid/List */}
|
{/* Pinned Notes Section */}
|
||||||
<div className={clsx(
|
{!isLoading && pinnedNotes.length > 0 && (
|
||||||
viewMode === "grid"
|
<div className="mb-6">
|
||||||
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start"
|
<div className="flex items-center gap-2 mb-3 text-muted-foreground">
|
||||||
: "flex flex-col gap-4 max-w-3xl mx-auto"
|
<Pin className="h-4 w-4 rotate-45" />
|
||||||
)}>
|
<span className="text-sm font-medium uppercase tracking-wide">Pinned</span>
|
||||||
{/* Pinned Notes First (if not searching and not archive) */}
|
</div>
|
||||||
{!searchQuery && !isArchive && displayNotes?.filter((n: any) => n.is_pinned).map((note: any) => (
|
{renderNotes(pinnedNotes)}
|
||||||
<NoteCard key={note.id} note={note} />
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{/* Other Notes */}
|
{/* Other Notes Section */}
|
||||||
{displayNotes?.filter((n: any) => searchQuery || isArchive || !n.is_pinned).map((note: any) => (
|
{!isLoading && unpinnedNotes.length > 0 && (
|
||||||
<NoteCard key={note.id} note={note} />
|
<div>
|
||||||
))}
|
{pinnedNotes.length > 0 && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use notes_domain::{Note, NoteFilter, Tag};
|
use notes_domain::{Note, Tag};
|
||||||
|
|
||||||
/// Request to create a new note
|
/// Request to create a new note
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
@@ -47,17 +47,8 @@ pub struct UpdateNoteRequest {
|
|||||||
pub struct ListNotesQuery {
|
pub struct ListNotesQuery {
|
||||||
pub pinned: Option<bool>,
|
pub pinned: Option<bool>,
|
||||||
pub archived: Option<bool>,
|
pub archived: Option<bool>,
|
||||||
pub tag: Option<Uuid>,
|
/// Tag name to filter by (will be looked up by route handler)
|
||||||
}
|
pub tag: Option<String>,
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query parameters for search
|
/// Query parameters for search
|
||||||
@@ -119,6 +110,13 @@ pub struct CreateTagRequest {
|
|||||||
pub name: String,
|
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
|
/// Login request
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
|
|||||||
@@ -36,5 +36,8 @@ pub fn api_v1_router() -> Router<AppState> {
|
|||||||
.route("/import", post(import_export::import_data))
|
.route("/import", post(import_export::import_data))
|
||||||
// Tag routes
|
// Tag routes
|
||||||
.route("/tags", get(tags::list_tags).post(tags::create_tag))
|
.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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,23 @@ pub async fn list_notes(
|
|||||||
)))?;
|
)))?;
|
||||||
let user_id = user.id();
|
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();
|
let response: Vec<NoteResponse> = notes.into_iter().map(NoteResponse::from).collect();
|
||||||
|
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Tag route handlers
|
//! Tag route handlers
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use axum_login::{AuthSession, AuthUser};
|
use axum_login::{AuthSession, AuthUser};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -12,7 +12,7 @@ use validator::Validate;
|
|||||||
use notes_domain::TagService;
|
use notes_domain::TagService;
|
||||||
|
|
||||||
use crate::auth::AuthBackend;
|
use crate::auth::AuthBackend;
|
||||||
use crate::dto::{CreateTagRequest, TagResponse};
|
use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -22,14 +22,18 @@ pub async fn list_tags(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthSession<AuthBackend>,
|
auth: AuthSession<AuthBackend>,
|
||||||
) -> ApiResult<Json<Vec<TagResponse>>> {
|
) -> 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 user_id = user.id();
|
||||||
|
|
||||||
let service = TagService::new(state.tag_repo);
|
let service = TagService::new(state.tag_repo);
|
||||||
|
|
||||||
let tags = service.list_tags(user_id).await?;
|
let tags = service.list_tags(user_id).await?;
|
||||||
let response: Vec<TagResponse> = tags.into_iter().map(TagResponse::from).collect();
|
let response: Vec<TagResponse> = tags.into_iter().map(TagResponse::from).collect();
|
||||||
|
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,18 +44,50 @@ pub async fn create_tag(
|
|||||||
auth: AuthSession<AuthBackend>,
|
auth: AuthSession<AuthBackend>,
|
||||||
Json(payload): Json<CreateTagRequest>,
|
Json(payload): Json<CreateTagRequest>,
|
||||||
) -> ApiResult<(StatusCode, Json<TagResponse>)> {
|
) -> 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();
|
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 service = TagService::new(state.tag_repo);
|
||||||
|
|
||||||
let tag = service.create_tag(user_id, &payload.name).await?;
|
let tag = service.create_tag(user_id, &payload.name).await?;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(TagResponse::from(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 a tag
|
||||||
/// DELETE /api/v1/tags/:id
|
/// DELETE /api/v1/tags/:id
|
||||||
pub async fn delete_tag(
|
pub async fn delete_tag(
|
||||||
@@ -59,12 +95,16 @@ pub async fn delete_tag(
|
|||||||
auth: AuthSession<AuthBackend>,
|
auth: AuthSession<AuthBackend>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> 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 user_id = user.id();
|
||||||
|
|
||||||
let service = TagService::new(state.tag_repo);
|
let service = TagService::new(state.tag_repo);
|
||||||
|
|
||||||
service.delete_tag(id, user_id).await?;
|
service.delete_tag(id, user_id).await?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,40 @@ impl TagService {
|
|||||||
|
|
||||||
self.tag_repo.delete(id).await
|
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)
|
/// Service for User operations (OIDC-ready)
|
||||||
|
|||||||
Reference in New Issue
Block a user