SPB Git
13.3 KB · 121 lines tsx
1"use client";2import { useMemo, useState } from "react";3import { Plus, Trash2, X, ChevronLeft } from "lucide-react";4import { Button } from "@/components/ui/button";5import { Switch } from "@/components/ui/primitives";6import { cn } from "@/lib/utils";7import type { Property, PropertyType, PropertyConfig, SelectOption, OptionColor } from "@/lib/databases/types";8import { PROPERTY_TYPES, AGGREGATES, OPTION_COLORS } from "@/lib/databases/types";9import { evaluate, FORMULA_HELP } from "@/lib/databases/formula";10import { formulaValue, newId, pickColor, displayValue } from "@/lib/databases/values";11import { useDb, usePropertyMutations, useAllDatabases, useRemoteDatabase } from "./hooks";12import { TYPE_META, TypeIcon, Sel, Field, inputCls } from "./meta";13import { DOT_CLASS } from "./colors";14import { OptionChip } from "./cells/SelectCell";1516const CREATABLE = PROPERTY_TYPES.filter((t) => t !== "title"); const OPT_TYPES: PropertyType[] = ["select", "multi_select", "status"];17/** Create (prop undefined) or edit a property. Renders inside a popover/dialog. */18export function PropertyEditor({ prop, onClose, onBack }: { prop?: Property; onClose: () => void; onBack?: () => void }) {19  const ctx = useDb(); const m = usePropertyMutations(ctx.db, ctx.db.pageId);20  const [name, setName] = useState(prop?.name ?? ""); const [type, setType] = useState<PropertyType>(prop?.type ?? "text"); const [config, setConfig] = useState<PropertyConfig>(prop?.config ?? {});21  const [pickType, setPickType] = useState(!prop); const [confirm, setConfirm] = useState(false);22  const set = (c: Partial<PropertyConfig>) => setConfig((x) => ({ ...x, ...c }));23  const changeType = (t: PropertyType) => { setType(t); setPickType(false); if (!prop || prop.type !== t) setConfig(OPT_TYPES.includes(type) && OPT_TYPES.includes(t) && config.options?.length ? { options: config.options } : {}); if (!name) setName(TYPE_META[t].label); };24  const save = async () => {25    const n = name.trim() || TYPE_META[type].label;26    if (type === "relation" && !config.targetDatabaseId) return;27    if (prop) await m.update.mutateAsync({ id: prop.id, ...(n !== prop.name ? { name: n } : {}), ...(type !== prop.type ? { type } : {}), config });28    else await m.create.mutateAsync({ name: n, type, config });29    onClose();30  };31  if (pickType) return (32    <div className="w-64">33      <div className="mb-1.5 flex items-center gap-1 text-[12px] font-medium text-fg-muted">{prop && <button type="button" onClick={() => setPickType(false)} className="rounded p-0.5 hover:bg-bg-sunk"><ChevronLeft className="size-4" /></button>}{prop ? "Change type" : "Property type"}</div>34      <div className="max-h-72 overflow-y-auto">{CREATABLE.map((t) => <button key={t} type="button" onClick={() => changeType(t)} className={cn("flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[13px] hover:bg-bg-sunk", t === type && "bg-bg-sunk")}><TypeIcon type={t} />{TYPE_META[t].label}</button>)}</div>35      {prop && prop.type !== type && <div className="mt-1 text-[11px] text-warning">Changing the type converts existing values when possible.</div>}36    </div>37  );38  return (39    <div className="w-72 space-y-2.5">40      <div className="flex items-center gap-1 text-[12px] font-medium text-fg-muted">{onBack && <button type="button" onClick={onBack} className="rounded p-0.5 hover:bg-bg-sunk"><ChevronLeft className="size-4" /></button>}{prop ? "Edit property" : "New property"}</div>41      <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Property name" autoFocus className={inputCls} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); save(); } if (e.key === "Escape") onClose(); }} />42      <button type="button" disabled={prop?.type === "title"} onClick={() => setPickType(true)} className="flex h-8 w-full items-center gap-2 rounded-md border border-line px-2 text-[13px] hover:bg-bg-sunk disabled:opacity-60"><TypeIcon type={type} />{TYPE_META[type].label}<span className="ml-auto text-[11px] text-fg-faint">{prop?.type === "title" ? "fixed" : "change"}</span></button>43      <ConfigEditor type={type} config={config} set={set} prop={prop} />44      <div className="flex items-center gap-1.5 pt-1">45        <Button size="sm" onClick={save} disabled={m.create.isPending || m.update.isPending || (type === "relation" && !config.targetDatabaseId)}>{prop ? "Save" : "Add property"}</Button>46        <Button size="sm" variant="ghost" onClick={onClose}>Cancel</Button>47        {prop && prop.type !== "title" && (confirm ? <Button size="sm" variant="danger" className="ml-auto" onClick={async () => { await m.remove.mutateAsync(prop.id); onClose(); }}>Confirm delete</Button> : <Button size="sm" variant="ghost" className="ml-auto text-danger" onClick={() => setConfirm(true)}><Trash2 /></Button>)}48      </div>49    </div>50  );51}52function ConfigEditor({ type, config, set, prop }: { type: PropertyType; config: PropertyConfig; set: (c: Partial<PropertyConfig>) => void; prop?: Property }) {53  const ctx = useDb();54  switch (type) {55    case "select": case "multi_select": case "status": return <OptionsEditor options={config.options ?? []} status={type === "status"} onChange={(options) => set({ options })} />;56    case "number": return <Field label="Format"><Sel value={config.format ?? "number"} onChange={(e) => set({ format: e.target.value as any })} className="w-full"><option value="number">Number</option><option value="integer">Integer</option><option value="percent">Percent</option><option value="currency">Currency (CAD)</option></Sel></Field>;57    case "date": return <label className="flex items-center justify-between text-[13px]">Include time<Switch checked={!!config.includeTime} onCheckedChange={(v) => set({ includeTime: v })} /></label>;58    case "relation": return <RelationConfig config={config} set={set} isNew={!prop} />;59    case "rollup": return <RollupConfig config={config} set={set} />;60    case "formula": return <FormulaConfig config={config} set={set} />;61    case "ai": return <Field label="Prompt" hint="Describe what to generate. Use {{Property}} to insert a value; all other properties and the page text are provided as context."><textarea value={config.prompt ?? ""} onChange={(e) => set({ prompt: e.target.value })} rows={4} placeholder="Summarize this paper in 3 sentences…" className={cn(inputCls, "h-auto py-1.5")} /></Field>;62    default: return null;63  }64}65function OptionsEditor({ options, status, onChange }: { options: SelectOption[]; status: boolean; onChange: (o: SelectOption[]) => void }) {66  const [draft, setDraft] = useState("");67  const add = () => { const n = draft.trim(); if (!n || options.some((o) => o.name.toLowerCase() === n.toLowerCase())) return; onChange([...options, { id: newId(), name: n, color: pickColor(options.length), ...(status ? { group: "todo" as const } : {}) }]); setDraft(""); };68  const upd = (id: string, patch: Partial<SelectOption>) => onChange(options.map((o) => (o.id === id ? { ...o, ...patch } : o)));69  return (70    <div className="space-y-1">71      <div className="text-[12px] font-medium text-fg-muted">Options</div>72      <div className="max-h-48 space-y-1 overflow-y-auto">{options.map((o) => (73        <div key={o.id} className="flex items-center gap-1">74          <ColorDot color={o.color} onChange={(color) => upd(o.id, { color })} />75          <input value={o.name} onChange={(e) => upd(o.id, { name: e.target.value })} className={cn(inputCls, "h-7 min-w-0 flex-1")} />76          {status && <Sel value={o.group ?? "todo"} onChange={(e) => upd(o.id, { group: e.target.value as any })} className="h-7 w-24 px-1 text-[12px]"><option value="todo">To-do</option><option value="in_progress">In progress</option><option value="done">Done</option></Sel>}77          <button type="button" onClick={() => onChange(options.filter((x) => x.id !== o.id))} className="grid size-7 shrink-0 place-items-center rounded text-fg-faint hover:bg-bg-sunk hover:text-danger"><X className="size-3.5" /></button>78        </div>))}</div>79      <div className="flex items-center gap-1"><input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="New option" className={cn(inputCls, "h-7")} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} /><Button size="iconXs" variant="outline" onClick={add}><Plus /></Button></div>80    </div>81  );82}83function ColorDot({ color, onChange }: { color: OptionColor; onChange: (c: OptionColor) => void }) {84  const [open, setOpen] = useState(false);85  return <span className="relative"><button type="button" onClick={() => setOpen((o) => !o)} className={cn("block size-4 rounded-full ring-2 ring-transparent hover:ring-line-strong", DOT_CLASS[color])} />{open && <span className="absolute left-0 top-5 z-10 grid grid-cols-5 gap-1 rounded-md border border-line bg-bg-elev p-1.5 shadow-pop">{OPTION_COLORS.map((c) => <button key={c} type="button" onClick={() => { onChange(c); setOpen(false); }} className={cn("size-4 rounded-full", DOT_CLASS[c], c === color && "ring-2 ring-fg")} />)}</span>}</span>;86}87function RelationConfig({ config, set, isNew }: { config: PropertyConfig; set: (c: Partial<PropertyConfig>) => void; isNew: boolean }) {88  const ctx = useDb(); const { data } = useAllDatabases();89  return (90    <div className="space-y-2">91      <Field label="Related to"><Sel value={config.targetDatabaseId ?? ""} onChange={(e) => set({ targetDatabaseId: e.target.value || undefined })} className="w-full"><option value="">Choose a database…</option>{(data ?? []).map((d) => <option key={d.id} value={d.id}>{d.icon ? `${d.icon} ` : ""}{d.name}{d.id === ctx.db.id ? " (this database)" : ""}</option>)}</Sel></Field>92      {isNew && <label className="flex items-center justify-between text-[13px]">Show on the other database<Switch checked={!!config.twoWay} onCheckedChange={(v) => set({ twoWay: v })} /></label>}93      {!!config.syncedPropertyId && <div className="text-[11px] text-fg-faint">Synced relation (edits propagate to the source property).</div>}94      <label className="flex items-center justify-between text-[13px]">Limit to one row<Switch checked={!!config.single} onCheckedChange={(v) => set({ single: v })} /></label>95    </div>96  );97}98function RollupConfig({ config, set }: { config: PropertyConfig; set: (c: Partial<PropertyConfig>) => void }) {99  const ctx = useDb(); const rels = ctx.props.filter((p) => p.type === "relation"); const rel = rels.find((p) => p.id === config.relationPropertyId);100  const { data: target } = useRemoteDatabase(rel?.config.targetDatabaseId as string | undefined);101  return (102    <div className="space-y-2">103      <Field label="Relation"><Sel value={config.relationPropertyId ?? ""} onChange={(e) => set({ relationPropertyId: e.target.value || undefined, targetPropertyId: undefined })} className="w-full"><option value="">Choose…</option>{rels.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Sel>{!rels.length && <span className="text-[11px] text-warning">Add a relation property first.</span>}</Field>104      <Field label="Property of the related rows"><Sel value={config.targetPropertyId ?? ""} onChange={(e) => set({ targetPropertyId: e.target.value || undefined })} className="w-full" disabled={!target}><option value="">Choose…</option>{(target?.properties ?? []).filter((p) => !["relation", "rollup", "formula", "file", "ai"].includes(p.type)).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Sel></Field>105      <Field label="Calculate"><Sel value={config.aggregate ?? "count"} onChange={(e) => set({ aggregate: e.target.value as any })} className="w-full">{AGGREGATES.map((a) => <option key={a} value={a}>{({ count: "Count rows", count_values: "Count values", sum: "Sum", avg: "Average", min: "Min", max: "Max", unique: "Unique values", show: "Show values" } as any)[a]}</option>)}</Sel></Field>106    </div>107  );108}109function FormulaConfig({ config, set }: { config: PropertyConfig; set: (c: Partial<PropertyConfig>) => void }) {110  const ctx = useDb(); const expr = config.expression ?? ""; const row = ctx.rows[0];111  const preview = useMemo(() => { if (!expr.trim()) return null; const byName = new Map(ctx.props.map((p) => [p.name, p])); return evaluate(expr, { prop: (n) => { const p = byName.get(n); if (!p) throw new Error(`Unknown property "${n}"`); return row ? formulaValue(p, row.values[p.id]) : null; } }); }, [expr, ctx.props, row]);112  const err = preview && typeof preview === "object" && "error" in preview ? preview.error : null;113  return (114    <div className="space-y-1.5">115      <Field label="Expression"><textarea value={expr} onChange={(e) => set({ expression: e.target.value })} rows={3} spellCheck={false} placeholder='if(prop("Done"), "✓", dateDiff(prop("Due"), today(), "days"))' className={cn(inputCls, "h-auto py-1.5 font-mono text-[12px]")} /></Field>116      {expr.trim() && <div className={cn("rounded-md px-2 py-1.5 text-[12px]", err ? "bg-danger/10 text-danger" : "bg-bg-sunk text-fg-muted")}>{err ? err : <>Preview{row ? ` (${row.title || "first row"})` : ""}: <span className="font-medium text-fg">{preview == null ? "—" : displayValue({ id: "", databaseId: "", name: "", type: "formula", config: {}, position: 0, width: null }, preview)}</span></>}</div>}117      <details className="text-[11px] text-fg-faint"><summary className="cursor-pointer select-none">Functions</summary><pre className="mt-1 whitespace-pre-wrap font-mono text-[10.5px] leading-4">{FORMULA_HELP}</pre><div className="mt-1">Properties: {ctx.props.filter((p) => p.type !== "formula" || p.config.expression !== expr).map((p) => `prop("${p.name}")`).join(" · ")}</div></details>118    </div>119  );120}121
Files · main
Menu

Theme