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%
6.1 KB · 124 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Check } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input, Textarea, Field } from "@/components/ui/input";7import { toast } from "@/components/ui/toast";8import { api } from "@/lib/client/api";9import { errorMessage } from "@/lib/client/humanize";10import { invalidateProjects } from "@/components/app/store";11import { ProjectIcon, PROJECT_COLORS, PROJECT_EMOJIS } from "./project-icon";12import type { PublicProject } from "@/lib/client/types";13import { cn } from "@/lib/utils";1415interface Draft {16  name: string;17  icon: string;18  color: string;19  description: string;20}2122/**23 * Create / edit a project (name, emoji icon, colour, description). Bottom sheet on phones, dialog on desktop.24 * Instructions, models, settings and notes live on the project page.25 */26export function ProjectFormSheet({ open, onOpenChange, project, onSaved }: { open: boolean; onOpenChange: (o: boolean) => void; project?: PublicProject | null; onSaved?: (p: PublicProject) => void }) {27  const [draft, setDraft] = React.useState<Draft>({ name: "", icon: "", color: "", description: "" });28  const [saving, setSaving] = React.useState(false);29  const nameRef = React.useRef<HTMLInputElement>(null);3031  React.useEffect(() => {32    if (!open) return;33    // eslint-disable-next-line react-hooks/set-state-in-effect34    setDraft({ name: project?.name ?? "", icon: project?.icon ?? "", color: project?.color ?? "", description: project?.description ?? "" });35    const t = setTimeout(() => nameRef.current?.focus(), 80);36    return () => clearTimeout(t);37  }, [open, project]);3839  const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));40  const valid = draft.name.trim().length > 0;4142  const save = async () => {43    if (!valid || saving) return;44    setSaving(true);45    const body = { name: draft.name.trim(), icon: draft.icon.trim() || null, color: draft.color || null, description: draft.description.trim() || null };46    try {47      const res = project ? await api<{ project: PublicProject }>(`/api/projects/${project.id}`, { method: "PATCH", json: body }) : await api<{ project: PublicProject }>("/api/projects", { method: "POST", json: body });48      await invalidateProjects();49      toast.success(project ? "Project updated" : "Project created");50      onOpenChange(false);51      onSaved?.(res.project);52    } catch (e) {53      toast.error(project ? "Could not update project" : "Could not create project", errorMessage(e));54    } finally {55      setSaving(false);56    }57  };5859  return (60    <ResponsiveDialog61      open={open}62      onOpenChange={onOpenChange}63      title={project ? "Edit project" : "New project"}64      description={project ? undefined : "Group chats, files, prompts and instructions."}65      size="md"66      footer={67        <div className="flex gap-2 sm:justify-end">68          <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => onOpenChange(false)} disabled={saving}>69            Cancel70          </Button>71          <Button className="flex-1 sm:flex-none" loading={saving} disabled={!valid} onClick={save}>72            {project ? "Save changes" : "Create project"}73          </Button>74        </div>75      }76    >77      <form78        className="space-y-5 pt-1"79        onSubmit={(e) => {80          e.preventDefault();81          void save();82        }}83      >84        <div className="flex items-center gap-3">85          <ProjectIcon icon={draft.icon} color={draft.color} size="xl" />86          <div className="min-w-0 flex-1">87            <Field label="Name" htmlFor="project-name">88              <Input ref={nameRef} id="project-name" value={draft.name} onChange={(e) => set({ name: e.target.value })} placeholder="Research, Client X, Side project…" maxLength={80} required className="h-11 sm:h-9" />89            </Field>90          </div>91        </div>9293        <Field label="Icon" hint="Pick one or paste any emoji.">94          <div className="flex flex-wrap gap-1.5">95            <Input value={draft.icon} onChange={(e) => set({ icon: e.target.value.slice(0, 4) })} placeholder="✨" maxLength={4} aria-label="Custom emoji" className="h-10 w-14 text-center text-lg sm:h-9" />96            {PROJECT_EMOJIS.map((e) => (97              <button key={e} type="button" onClick={() => set({ icon: draft.icon === e ? "" : e })} className={cn("flex size-10 items-center justify-center rounded-md text-[18px] transition-colors sm:size-9", draft.icon === e ? "bg-accent-soft ring-2 ring-accent/50" : "bg-bg-muted hover:bg-border")} aria-label={`Use ${e}`} aria-pressed={draft.icon === e}>98                {e}99              </button>100            ))}101          </div>102        </Field>103104        <Field label="Colour">105          <div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Project colour">106            <button type="button" role="radio" aria-checked={!draft.color} onClick={() => set({ color: "" })} className={cn("flex size-10 items-center justify-center rounded-full border border-dashed text-[11px] text-fg-subtle sm:size-8", !draft.color ? "border-fg" : "border-border-strong")} aria-label="No colour">107              {!draft.color ? <Check className="size-4" /> : "—"}108            </button>109            {PROJECT_COLORS.map((c) => (110              <button key={c} type="button" role="radio" aria-checked={draft.color === c} onClick={() => set({ color: c })} className={cn("flex size-10 items-center justify-center rounded-full ring-offset-2 ring-offset-bg-elevated transition-shadow sm:size-8", draft.color === c && "ring-2 ring-fg")} style={{ backgroundColor: c }} aria-label={`Colour ${c}`}>111                {draft.color === c ? <Check className="size-4 text-white" /> : null}112              </button>113            ))}114          </div>115        </Field>116117        <Field label="Description" htmlFor="project-desc" hint="Optional — shown on the project card.">118          <Textarea id="project-desc" value={draft.description} onChange={(e) => set({ description: e.target.value })} rows={2} maxLength={600} placeholder="What this project is about" className="min-h-[64px]" />119        </Field>120      </form>121    </ResponsiveDialog>122  );123}124