TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Eye, Pencil } from "lucide-react";4import { invalidateProjects } from "@/components/app/store";5import { api } from "@/lib/client/api";6import { errorMessage } from "@/lib/client/humanize";7import { Button } from "@/components/ui/button";8import { Textarea } from "@/components/ui/input";9import { Segmented } from "@/components/ui/segmented";10import { toast } from "@/components/ui/toast";11import { Markdown } from "@/components/markdown/markdown";12import { useIsMobile } from "@/lib/client/hooks";13import type { PublicProject } from "@/lib/client/types";14import { cn } from "@/lib/utils";1516type Mode = "write" | "preview";1718/** Free-form markdown notes: write / preview (side by side on desktop), explicit save, word count. */19export function ProjectNotes({ project, onSaved }: { project: PublicProject; onSaved: () => Promise<unknown> | void }) {20 const isMobile = useIsMobile();21 const [notes, setNotes] = React.useState(project.notes ?? "");22 const [mode, setMode] = React.useState<Mode>("write");23 const [saving, setSaving] = React.useState(false);24 const last = React.useRef(project.updatedAt);25 React.useEffect(() => {26 if (last.current !== project.updatedAt) {27 last.current = project.updatedAt;28 setNotes(project.notes ?? "");29 }30 }, [project]);31 const dirty = notes !== (project.notes ?? "");32 const words = React.useMemo(() => (notes.trim() ? notes.trim().split(/\s+/).length : 0), [notes]);3334 const save = async () => {35 setSaving(true);36 try {37 await api(`/api/projects/${project.id}`, { method: "PATCH", json: { notes: notes.trim() ? notes : null } });38 await Promise.all([onSaved(), invalidateProjects()]);39 toast.success("Notes saved");40 } catch (e) {41 toast.error("Could not save notes", errorMessage(e));42 } finally {43 setSaving(false);44 }45 };4647 const editor = <Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={14} maxLength={200_000} className="min-h-[50dvh] font-mono text-[13px] leading-5 sm:min-h-[420px]" placeholder={"# Goals\n- Ship the redesign by Q4\n\n## Links\n- Figma: …\n\nMarkdown is supported."} aria-label="Project notes" onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); if (dirty) void save(); } }} />;48 const preview = <div className={cn("min-h-[50dvh] rounded-xl border border-border bg-bg-elevated p-4 sm:min-h-[420px]", !notes.trim() && "flex items-center justify-center")}>{notes.trim() ? <Markdown content={notes} /> : <p className="text-[13px] text-fg-subtle">Nothing to preview yet.</p>}</div>;4950 return (51 <div className="space-y-3">52 <div className="flex items-center gap-2">53 {isMobile ? <Segmented ariaLabel="Notes mode" value={mode} onChange={setMode} options={[{ value: "write", label: "Write", icon: <Pencil /> }, { value: "preview", label: "Preview", icon: <Eye /> }]} size="sm" /> : <span className="text-[13px] text-fg-muted">Markdown · live preview</span>}54 <span className="ml-auto text-[12px] text-fg-subtle tabular-nums">{words} word{words === 1 ? "" : "s"}</span>55 </div>56 {isMobile ? (mode === "write" ? editor : preview) : <div className="grid gap-4 lg:grid-cols-2">{editor}{preview}</div>}57 <div className={cn("sticky bottom-0 -mx-4 flex items-center gap-2 border-t border-border bg-bg/90 px-4 py-3 backdrop-blur sm:mx-0 sm:rounded-xl sm:border sm:px-3", !dirty && "pointer-events-none opacity-0")} aria-hidden={!dirty}>58 <span className="text-[13px] text-fg-muted">Unsaved changes</span>59 <div className="flex-1" />60 <Button variant="ghost" onClick={() => setNotes(project.notes ?? "")} disabled={saving}>61 Discard62 </Button>63 <Button loading={saving} onClick={save}>64 Save notes65 </Button>66 </div>67 </div>68 );69}70