@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 { Plus } from "lucide-react";
|
||||
|
||||
export function CreateNoteDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
interface CreateNoteDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }: CreateNoteDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const { mutate: createNote, isPending } = useCreateNote();
|
||||
|
||||
// Support both controlled and uncontrolled modes
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (onOpenChange ?? (() => { })) : setInternalOpen;
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
// Parse tags
|
||||
const tags = data.tags
|
||||
? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
createNote({ ...data, tags }, {
|
||||
onSuccess: () => {
|
||||
toast.success("Note created");
|
||||
@@ -27,14 +38,25 @@ export function CreateNoteDialog() {
|
||||
});
|
||||
};
|
||||
|
||||
const defaultTrigger = (
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<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>
|
||||
</header>
|
||||
<div className="flex-1 p-4 md:p-6 bg-muted/10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</main>
|
||||
<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 { VersionHistoryDialog } from "./version-history-dialog";
|
||||
import { NoteViewDialog } from "./note-view-dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useBulkSelection } from "@/components/bulk-selection-context";
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note;
|
||||
@@ -25,63 +27,87 @@ export function NoteCard({ note }: NoteCardProps) {
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
|
||||
// Bulk selection
|
||||
const { isSelected, toggleSelection, isBulkMode } = useBulkSelection();
|
||||
const selected = isSelected(note.id);
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection(note.id);
|
||||
};
|
||||
|
||||
// Archive toggle
|
||||
const toggleArchive = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
updateNote({
|
||||
id: note.id,
|
||||
is_archived: !note.is_archived
|
||||
updateNote({
|
||||
id: note.id,
|
||||
is_archived: !note.is_archived
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Pin toggle
|
||||
const togglePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
updateNote({
|
||||
id: note.id,
|
||||
is_pinned: !note.is_pinned
|
||||
});
|
||||
e.stopPropagation();
|
||||
updateNote({
|
||||
id: note.id,
|
||||
is_pinned: !note.is_pinned
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm("Are you sure?")) {
|
||||
deleteNote(note.id);
|
||||
}
|
||||
e.stopPropagation();
|
||||
if (confirm("Are you sure?")) {
|
||||
deleteNote(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleEdit = (data: any) => {
|
||||
const tags = data.tags
|
||||
? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
updateNote({
|
||||
id: note.id,
|
||||
...data,
|
||||
tags,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
toast.success("Note updated");
|
||||
}
|
||||
});
|
||||
const tags = data.tags
|
||||
? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
updateNote({
|
||||
id: note.id,
|
||||
...data,
|
||||
tags,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
toast.success("Note updated");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const colorClass = getNoteColor(note.color);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
<Card
|
||||
className={clsx(
|
||||
"relative group transition-all hover:shadow-md cursor-pointer",
|
||||
"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,24 +149,24 @@ 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>
|
||||
|
||||
<VersionHistoryDialog
|
||||
|
||||
<VersionHistoryDialog
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
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),
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useNotes, useSearchNotes } from "@/hooks/use-notes";
|
||||
import { useState, useRef } from "react";
|
||||
import { useNotes, useSearchNotes, type Note } from "@/hooks/use-notes";
|
||||
import { CreateNoteDialog } from "@/components/create-note-dialog";
|
||||
import { NoteCard } from "@/components/note-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, LayoutGrid, List } from "lucide-react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Search, LayoutGrid, List, Plus, Pin, X } from "lucide-react";
|
||||
import { useLocation, useSearchParams, Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import clsx from "clsx";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { NoteCardSkeletonGrid } from "@/components/note-card-skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
// Masonry breakpoint columns configuration
|
||||
const masonryBreakpoints = {
|
||||
default: 4,
|
||||
1280: 4,
|
||||
1024: 3,
|
||||
768: 2,
|
||||
640: 1,
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isArchive = location.pathname === "/archive";
|
||||
|
||||
const activeTag = searchParams.get("tag");
|
||||
|
||||
// View mode state
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Fetch normal notes only if not searching
|
||||
const { data: notes, isLoading: notesLoading } = useNotes(searchQuery ? undefined : { archived: isArchive });
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Create note dialog state (keyboard controlled)
|
||||
const [createNoteOpen, setCreateNoteOpen] = useState(false);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
onNewNote: () => !isArchive && setCreateNoteOpen(true),
|
||||
onFocusSearch: () => searchInputRef.current?.focus(),
|
||||
onEscape: () => {
|
||||
searchInputRef.current?.blur();
|
||||
setCreateNoteOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch notes with optional tag filter
|
||||
const { data: notes, isLoading: notesLoading } = useNotes(
|
||||
searchQuery ? undefined : { archived: isArchive, tag: activeTag ?? undefined }
|
||||
);
|
||||
|
||||
// Fetch search results if searching
|
||||
const { data: searchResults, isLoading: searchLoading } = useSearchNotes(searchQuery);
|
||||
|
||||
const displayNotes = searchQuery ? searchResults : notes;
|
||||
const isLoading = searchQuery ? searchLoading : notesLoading;
|
||||
|
||||
// Separate pinned and unpinned notes
|
||||
const pinnedNotes = !searchQuery && !isArchive
|
||||
? (displayNotes?.filter((n: Note) => n.is_pinned) ?? [])
|
||||
: [];
|
||||
const unpinnedNotes = displayNotes?.filter((n: Note) => searchQuery || isArchive || !n.is_pinned) ?? [];
|
||||
|
||||
const renderNotes = (notesList: Note[]) => {
|
||||
if (viewMode === "list") {
|
||||
return (
|
||||
<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
|
||||
placeholder="Search your notes..."
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
id="search-input"
|
||||
placeholder="Search your notes..."
|
||||
className="pl-9 w-full bg-background"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center bg-muted/50 p-1 rounded-lg border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={clsx("h-8 w-8", viewMode === "grid" && "bg-background shadow-sm")}
|
||||
onClick={() => setViewMode("grid")}
|
||||
title="Grid View"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={clsx("h-8 w-8", viewMode === "list" && "bg-background shadow-sm")}
|
||||
onClick={() => setViewMode("list")}
|
||||
title="List View"
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -82,32 +163,55 @@ export default function DashboardPage() {
|
||||
{!isLoading && displayNotes?.length === 0 && (
|
||||
<div className="text-center py-20 bg-background rounded-lg border border-dashed">
|
||||
<div className="text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No matching notes found"
|
||||
: isArchive
|
||||
? "No archived notes yet"
|
||||
: "Your notes will appear here. Click + to create one."
|
||||
{searchQuery
|
||||
? "No matching notes found"
|
||||
: activeTag
|
||||
? `No notes with tag "${activeTag}"`
|
||||
: isArchive
|
||||
? "No archived notes yet"
|
||||
: "Your notes will appear here. Click + to create one."
|
||||
}
|
||||
</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} />
|
||||
))}
|
||||
|
||||
{/* Other Notes */}
|
||||
{displayNotes?.filter((n: any) => searchQuery || isArchive || !n.is_pinned).map((note: any) => (
|
||||
<NoteCard key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
{/* 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user