improvements #1
This commit is contained in:
@@ -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 } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -9,9 +9,13 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { Link, useLocation } from "react-router-dom"
|
import { Link, useLocation, useSearchParams } 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 } 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"
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -28,7 +32,12 @@ const items = [
|
|||||||
|
|
||||||
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 +49,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,6 +67,50 @@ 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 }) => (
|
||||||
|
<SidebarMenuItem key={tag.id}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={activeTag === tag.name}
|
||||||
|
tooltip={tag.name}
|
||||||
|
>
|
||||||
|
<Link to={`/?tag=${encodeURIComponent(tag.name)}`}>
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user