Fazekit

Code

"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;

More in Productivity

View all →