diff --git a/k-notes-frontend/bun.lock b/k-notes-frontend/bun.lock
index 11443ca..33932ee 100644
--- a/k-notes-frontend/bun.lock
+++ b/k-notes-frontend/bun.lock
@@ -46,6 +46,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"react-markdown": "^10.1.0",
+ "react-masonry-css": "^1.0.16",
"react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.11.0",
"recharts": "2.15.4",
@@ -869,6 +870,8 @@
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+ "react-masonry-css": ["react-masonry-css@1.0.16", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ=="],
+
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
diff --git a/k-notes-frontend/package.json b/k-notes-frontend/package.json
index ca86eec..4e82842 100644
--- a/k-notes-frontend/package.json
+++ b/k-notes-frontend/package.json
@@ -52,6 +52,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"react-markdown": "^10.1.0",
+ "react-masonry-css": "^1.0.16",
"react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.11.0",
"recharts": "2.15.4",
diff --git a/k-notes-frontend/src/components/app-sidebar.tsx b/k-notes-frontend/src/components/app-sidebar.tsx
index 35da870..d8b164c 100644
--- a/k-notes-frontend/src/components/app-sidebar.tsx
+++ b/k-notes-frontend/src/components/app-sidebar.tsx
@@ -1,4 +1,4 @@
-import { Home, Archive, Settings } from "lucide-react"
+import { Home, Archive, Settings, Tag, ChevronRight, Pencil, Trash2, MoreHorizontal } from "lucide-react"
import {
Sidebar,
SidebarContent,
@@ -9,9 +9,22 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
-import { Link, useLocation } from "react-router-dom"
+import { Link, useLocation, useSearchParams, useNavigate } from "react-router-dom"
import { SettingsDialog } from "@/components/settings-dialog"
import { useState } from "react"
+import { useTags, useDeleteTag, useRenameTag } from "@/hooks/use-notes"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { toast } from "sonner"
const items = [
{
@@ -26,9 +39,119 @@ const items = [
},
]
+interface TagItemProps {
+ tag: { id: string; name: string };
+ isActive: boolean;
+}
+
+function TagItem({ tag, isActive }: TagItemProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState(tag.name);
+ const { mutate: deleteTag } = useDeleteTag();
+ const { mutate: renameTag } = useRenameTag();
+ const navigate = useNavigate();
+
+ const handleDelete = () => {
+ if (confirm(`Delete tag "${tag.name}"? Notes will keep their content.`)) {
+ deleteTag(tag.id, {
+ onSuccess: () => {
+ toast.success("Tag deleted");
+ navigate("/");
+ },
+ onError: (err: any) => toast.error(err.message)
+ });
+ }
+ };
+
+ const handleRename = () => {
+ if (editName.trim() && editName.trim() !== tag.name) {
+ renameTag({ id: tag.id, name: editName.trim() }, {
+ onSuccess: () => {
+ toast.success("Tag renamed");
+ setIsEditing(false);
+ },
+ onError: (err: any) => {
+ toast.error(err.message);
+ setEditName(tag.name);
+ }
+ });
+ } else {
+ setIsEditing(false);
+ setEditName(tag.name);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ handleRename();
+ } else if (e.key === "Escape") {
+ setIsEditing(false);
+ setEditName(tag.name);
+ }
+ };
+
+ if (isEditing) {
+ return (
+
+ setEditName(e.target.value)}
+ onBlur={handleRename}
+ onKeyDown={handleKeyDown}
+ autoFocus
+ className="h-7 text-xs"
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+ {tag.name}
+
+
+ e.preventDefault()}>
+
+
+
+ { e.preventDefault(); setIsEditing(true); }}>
+
+ Rename
+
+ { e.preventDefault(); handleDelete(); }} className="text-destructive focus:text-destructive">
+
+ Delete
+
+
+
+
+
+
+ );
+}
+
export function AppSidebar() {
const location = useLocation();
+ const [searchParams] = useSearchParams();
const [settingsOpen, setSettingsOpen] = useState(false);
+ const [tagsOpen, setTagsOpen] = useState(true);
+
+ const { data: tags } = useTags();
+ const activeTag = searchParams.get("tag");
return (
<>
@@ -40,7 +163,7 @@ export function AppSidebar() {
{items.map((item) => (
-
+
{item.title}
@@ -58,9 +181,46 @@ export function AppSidebar() {
+
+ {/* Tag Browser Section */}
+
+
+
+
+
+
+ Tags
+
+
+
+
+
+
+
+
+ {tags && tags.length > 0 ? (
+ tags.map((tag: { id: string; name: string }) => (
+
+ ))
+ ) : (
+
+ No tags yet
+
+ )}
+
+
+
+
+
+
>
)
}
+
diff --git a/k-notes-frontend/src/components/bulk-actions-bar.tsx b/k-notes-frontend/src/components/bulk-actions-bar.tsx
new file mode 100644
index 0000000..b7e0021
--- /dev/null
+++ b/k-notes-frontend/src/components/bulk-actions-bar.tsx
@@ -0,0 +1,74 @@
+import { useBulkSelection } from "@/components/bulk-selection-context";
+import { useDeleteNote, useUpdateNote } from "@/hooks/use-notes";
+import { Button } from "@/components/ui/button";
+import { Archive, Trash2, X } from "lucide-react";
+import { toast } from "sonner";
+
+export function BulkActionsBar() {
+ const { selectedIds, clearSelection, isBulkMode } = useBulkSelection();
+ const { mutate: deleteNote } = useDeleteNote();
+ const { mutate: updateNote } = useUpdateNote();
+
+ if (!isBulkMode) return null;
+
+ const handleArchiveAll = () => {
+ const ids = Array.from(selectedIds);
+ ids.forEach((id) => {
+ updateNote({ id, is_archived: true });
+ });
+ toast.success(`Archived ${ids.length} note${ids.length > 1 ? "s" : ""}`);
+ clearSelection();
+ };
+
+ const handleDeleteAll = () => {
+ if (!confirm(`Are you sure you want to delete ${selectedIds.size} note(s)?`)) return;
+
+ const ids = Array.from(selectedIds);
+ ids.forEach((id) => {
+ deleteNote(id);
+ });
+ toast.success(`Deleted ${ids.length} note${ids.length > 1 ? "s" : ""}`);
+ clearSelection();
+ };
+
+ return (
+
+
+ {selectedIds.size} selected
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/k-notes-frontend/src/components/bulk-selection-context.tsx b/k-notes-frontend/src/components/bulk-selection-context.tsx
new file mode 100644
index 0000000..3486a74
--- /dev/null
+++ b/k-notes-frontend/src/components/bulk-selection-context.tsx
@@ -0,0 +1,65 @@
+import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
+
+interface BulkSelectionContextType {
+ selectedIds: Set;
+ isBulkMode: boolean;
+ toggleSelection: (id: string) => void;
+ selectAll: (ids: string[]) => void;
+ clearSelection: () => void;
+ isSelected: (id: string) => boolean;
+}
+
+const BulkSelectionContext = createContext(null);
+
+export function BulkSelectionProvider({ children }: { children: ReactNode }) {
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+
+ const toggleSelection = useCallback((id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ }, []);
+
+ const selectAll = useCallback((ids: string[]) => {
+ setSelectedIds(new Set(ids));
+ }, []);
+
+ const clearSelection = useCallback(() => {
+ setSelectedIds(new Set());
+ }, []);
+
+ const isSelected = useCallback((id: string) => {
+ return selectedIds.has(id);
+ }, [selectedIds]);
+
+ const isBulkMode = selectedIds.size > 0;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useBulkSelection() {
+ const context = useContext(BulkSelectionContext);
+ if (!context) {
+ throw new Error("useBulkSelection must be used within a BulkSelectionProvider");
+ }
+ return context;
+}
diff --git a/k-notes-frontend/src/components/create-note-dialog.tsx b/k-notes-frontend/src/components/create-note-dialog.tsx
index 85819e0..cf66295 100644
--- a/k-notes-frontend/src/components/create-note-dialog.tsx
+++ b/k-notes-frontend/src/components/create-note-dialog.tsx
@@ -6,16 +6,27 @@ import { NoteForm } from "./note-form";
import { toast } from "sonner";
import { Plus } from "lucide-react";
-export function CreateNoteDialog() {
- const [open, setOpen] = useState(false);
+interface CreateNoteDialogProps {
+ trigger?: React.ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+export function CreateNoteDialog({ trigger, open: controlledOpen, onOpenChange }: CreateNoteDialogProps) {
+ const [internalOpen, setInternalOpen] = useState(false);
const { mutate: createNote, isPending } = useCreateNote();
+ // Support both controlled and uncontrolled modes
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : internalOpen;
+ const setOpen = isControlled ? (onOpenChange ?? (() => { })) : setInternalOpen;
+
const onSubmit = (data: any) => {
// Parse tags
const tags = data.tags
- ? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
- : [];
-
+ ? data.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
+ : [];
+
createNote({ ...data, tags }, {
onSuccess: () => {
toast.success("Note created");
@@ -27,14 +38,25 @@ export function CreateNoteDialog() {
});
};
+ const defaultTrigger = (
+
+ );
+
return (