SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
3.8 KB · 70 lines tsx
Raw Blame History
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