"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Feather, Plus, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { DEFAULT_FOLDERS, DEFAULT_NOTES, SEED_NOW, extractTags, snippet } from "./data";
import { NoteEditor, type EditorMode } from "./editor";
import { NoteList } from "./note-list";
import { NotesSidebar } from "./sidebar";
import type { Folder, Note, SaveState, Scope } from "./types";
export type { Folder, Note, Scope } from "./types";
export interface NotesAppProps {
/** Starting notes. Defaults to a seeded set whose timestamps are shifted to "now" after mount. */
initialNotes?: Note[];
folders?: Folder[];
appName?: string;
/** Fires on every change to the note collection. */
onChange?: (notes: Note[]) => void;
/** Fires after the autosave debounce with the note that was saved. */
onSave?: (note: Note) => void;
onDelete?: (note: Note) => void;
/** Autosave debounce in ms. */
saveDelay?: number;
className?: string;
}
let uid = 0;
const newId = () => `note-${Date.now().toString(36)}-${(uid++).toString(36)}`;
export function NotesApp({ initialNotes, folders = DEFAULT_FOLDERS, appName = "Quill", onChange, onSave, onDelete, saveDelay = 700, className }: NotesAppProps) {
const seeded = !initialNotes;
const [notes, setNotes] = React.useState<Note[]>(initialNotes ?? DEFAULT_NOTES);
const [now, setNow] = React.useState(SEED_NOW);
const [scope, setScope] = React.useState<Scope>({ kind: "all" });
const [query, setQuery] = React.useState("");
const [selectedId, setSelectedId] = React.useState<string | null>((initialNotes ?? DEFAULT_NOTES)[0]?.id ?? null);
const [mode, setMode] = React.useState<EditorMode>("preview");
const [pane, setPane] = React.useState<"list" | "editor">("list");
const [drawer, setDrawer] = React.useState(false);
const [save, setSave] = React.useState<{ id: string | null; state: SaveState }>({ id: null, state: "idle" });
const [undo, setUndo] = React.useState<{ note: Note; index: number } | null>(null);
const [focusTitle, setFocusTitle] = React.useState(false);
const saveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const fadeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const undoTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const folderMap = React.useMemo(() => Object.fromEntries(folders.map((f) => [f.id, f])), [folders]);
// Clock for relative times. Seed notes are re-based onto the real clock after mount (hydration safe).
React.useEffect(() => {
const start = setTimeout(() => {
const real = Date.now();
if (seeded) {
const shift = real - SEED_NOW;
setNotes((ns) => ns.map((n) => (n.updatedAt <= SEED_NOW ? { ...n, updatedAt: n.updatedAt + shift, createdAt: n.createdAt + shift } : n)));
}
setNow(real);
}, 0);
const tick = setInterval(() => setNow(Date.now()), 30_000);
return () => {
clearTimeout(start);
clearInterval(tick);
[saveTimer, fadeTimer, undoTimer].forEach((t) => t.current && clearTimeout(t.current));
};
}, [seeded]);
const onChangeRef = React.useRef(onChange);
React.useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(notes);
}, [notes]);
const tagList = React.useMemo(() => {
const m = new Map<string, number>();
for (const n of notes) for (const t of extractTags(n.body)) m.set(t, (m.get(t) ?? 0) + 1);
return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([tag, count]) => ({ tag, count }));
}, [notes]);
const counts = React.useMemo(() => {
const f: Record<string, number> = {};
for (const n of notes) f[n.folderId] = (f[n.folderId] ?? 0) + 1;
return { all: notes.length, pinned: notes.filter((n) => n.pinned).length, folders: f };
}, [notes]);
const q = query.trim().toLowerCase();
const visible = React.useMemo(() => {
return notes
.filter((n) => {
// Search spans every note; otherwise the list follows the sidebar scope.
if (q) return n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q);
if (scope.kind === "pinned" && !n.pinned) return false;
if (scope.kind === "folder" && n.folderId !== scope.id) return false;
if (scope.kind === "tag" && !extractTags(n.body).includes(scope.tag)) return false;
return true;
})
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt);
}, [notes, scope, q]);
const selected = notes.find((n) => n.id === selectedId) ?? null;
const scopeTitle = scope.kind === "all" ? "All notes" : scope.kind === "pinned" ? "Pinned" : scope.kind === "folder" ? (folderMap[scope.id]?.name ?? "Folder") : `#${scope.tag}`;
const markSaving = (note: Note) => {
setSave({ id: note.id, state: "saving" });
if (saveTimer.current) clearTimeout(saveTimer.current);
if (fadeTimer.current) clearTimeout(fadeTimer.current);
saveTimer.current = setTimeout(() => {
setSave({ id: note.id, state: "saved" });
onSave?.(note);
fadeTimer.current = setTimeout(() => setSave((v) => (v.id === note.id ? { ...v, state: "idle" } : v)), 2200);
}, saveDelay);
};
const update = (id: string, patch: Partial<Note>, touch = true) => {
const base = notes.find((n) => n.id === id);
if (!base) return;
const next: Note = { ...base, ...patch, updatedAt: touch ? Date.now() : base.updatedAt };
setNotes((ns) => ns.map((n) => (n.id === id ? { ...n, ...patch, updatedAt: next.updatedAt } : n)));
markSaving(next);
};
const select = (id: string) => {
setSelectedId(id);
setPane("editor");
setFocusTitle(false);
};
const create = () => {
const folderId = scope.kind === "folder" ? scope.id : (folders[0]?.id ?? "personal");
const t = Date.now();
const body = scope.kind === "tag" ? `\n\n#${scope.tag}` : "";
const note: Note = { id: newId(), title: "", body, folderId, pinned: scope.kind === "pinned", createdAt: t, updatedAt: t };
setNotes((ns) => [note, ...ns]);
setQuery("");
setSelectedId(note.id);
setMode("edit");
setPane("editor");
setDrawer(false);
setFocusTitle(true);
setNow(t);
};
const duplicate = () => {
if (!selected) return;
const t = Date.now();
const copy: Note = { ...selected, id: newId(), title: `${selected.title || "Untitled"} copy`, pinned: false, createdAt: t, updatedAt: t };
setNotes((ns) => {
const i = ns.findIndex((n) => n.id === selected.id);
return [...ns.slice(0, i + 1), copy, ...ns.slice(i + 1)];
});
setSelectedId(copy.id);
setNow(t);
markSaving(copy);
};
const remove = () => {
if (!selected) return;
const index = notes.findIndex((n) => n.id === selected.id);
const pos = visible.findIndex((n) => n.id === selected.id);
const nextSel = visible[pos + 1] ?? visible[pos - 1] ?? null;
setNotes((ns) => ns.filter((n) => n.id !== selected.id));
setSelectedId(nextSel?.id ?? null);
setUndo({ note: selected, index });
onDelete?.(selected);
if (!nextSel) setPane("list");
if (undoTimer.current) clearTimeout(undoTimer.current);
undoTimer.current = setTimeout(() => setUndo(null), 5000);
};
const restore = () => {
if (!undo) return;
setNotes((ns) => [...ns.slice(0, undo.index), undo.note, ...ns.slice(undo.index)]);
setSelectedId(undo.note.id);
setUndo(null);
};
const pickScope = (s: Scope) => {
setScope(s);
setDrawer(false);
setPane("list");
};
React.useEffect(() => {
if (!drawer) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setDrawer(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawer]);
const sidebar = (inDrawer: boolean) => (
<NotesSidebar
appName={appName}
folders={folders}
tags={tagList}
counts={counts}
scope={scope}
onScope={pickScope}
query={query}
onQuery={(v) => {
setQuery(v);
setPane("list");
}}
onCreate={create}
onClose={inDrawer ? () => setDrawer(false) : undefined}
layoutId={inDrawer ? "notes-drawer" : "notes-side"}
/>
);
return (
<div className={cn("relative flex h-[760px] w-full overflow-hidden bg-background text-foreground", className)}>
<aside className="hidden w-60 shrink-0 border-r bg-muted/30 lg:block">{sidebar(false)}</aside>
<AnimatePresence>
{drawer && (
<>
<motion.button type="button" aria-label="Close sidebar" className="absolute inset-0 z-30 bg-black/40 lg:hidden" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setDrawer(false)} />
<motion.aside
role="dialog"
aria-modal="true"
aria-label="Folders and tags"
className="absolute inset-y-0 left-0 z-40 w-64 border-r bg-background shadow-2xl lg:hidden"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 380, damping: 38 }}
>
{sidebar(true)}
</motion.aside>
</>
)}
</AnimatePresence>
<section aria-label="Note list" className={cn("min-w-0 shrink-0 border-r md:block md:w-72 xl:w-80", pane === "list" ? "block w-full" : "hidden")}>
<NoteList title={scopeTitle} notes={visible} folders={folderMap} selectedId={selectedId} now={now} query={query.trim()} onSelect={select} onCreate={create} onOpenSidebar={() => setDrawer(true)} />
</section>
<section aria-label="Editor" className={cn("min-w-0 flex-1 md:block", pane === "editor" ? "block" : "hidden")}>
<AnimatePresence mode="wait" initial={false}>
{selected ? (
<motion.div key={selected.id} className="h-full" initial={{ opacity: 0, x: 8 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.16 }}>
<NoteEditor
note={selected}
folders={folders}
mode={mode}
onMode={setMode}
onChange={(patch) => update(selected.id, patch)}
onPin={() => update(selected.id, { pinned: !selected.pinned }, false)}
onDuplicate={duplicate}
onDelete={remove}
onBack={() => setPane("list")}
onTagClick={(tag) => pickScope({ kind: "tag", tag })}
saveState={save.id === selected.id ? save.state : "idle"}
now={now}
autoFocusTitle={focusTitle}
/>
</motion.div>
) : (
<motion.div key="empty" className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<div className="relative">
<div className="absolute inset-0 -z-10 scale-150 rounded-full bg-primary/10 blur-2xl" />
<span className="grid size-16 place-items-center rounded-2xl border bg-card shadow-sm">
<Feather className="size-7 text-primary" aria-hidden />
</span>
</div>
<div>
<p className="font-semibold">No note selected</p>
<p className="mt-1 max-w-xs text-sm text-muted-foreground">Pick a note from the list, or start a fresh one. Everything saves automatically.</p>
</div>
<button type="button" onClick={create} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<Plus className="size-4" /> New note
</button>
</motion.div>
)}
</AnimatePresence>
</section>
<AnimatePresence>
{undo && (
<motion.div
role="status"
className="absolute bottom-12 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 whitespace-nowrap rounded-full border bg-popover py-1.5 pl-4 pr-1.5 text-sm text-popover-foreground shadow-xl"
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
>
<span className="max-w-44 truncate">Deleted “{undo.note.title || snippet(undo.note.body, 20) || "Untitled"}”</span>
<button type="button" onClick={restore} className="inline-flex h-7 items-center gap-1 rounded-full bg-foreground px-3 text-xs font-semibold text-background hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Undo2 className="size-3.5" /> Undo
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default NotesApp;