1"use client";
2
3
20import { useState } from "react";
21import { useRouter } from "next/navigation";
22import { ArrowDown, ArrowUp, Loader2, Play, Plus, Save, Trash2, X, Zap } from "lucide-react";
23import { cn } from "@/lib/utils";
24import { toast } from "@/stores/ui-store";
25
26
27const notify = (t: { title: string; description?: string; variant?: string }) =>
28 toast(t.variant === "destructive" ? "error" : "success", t.title, t.description);
29import {
30 DatabaseButtonDef,
31 DatabaseProperty,
32 DatabaseView,
33 RecordDto,
34 RecordValue,
35 useCreateRecord,
36 useUpdateRecord,
37 useUpdateView
38} from "@/hooks/use-database";
39
40
41
42
43
44export interface DatabaseButtonsProps {
45 databaseId: string;
46 view: DatabaseView;
47 properties: DatabaseProperty[];
48
49 record?: RecordDto | null;
50
51 selected?: RecordDto[];
52 onOpenEditor: () => void;
53}
54
55export function DatabaseButtons({
56 databaseId,
57 view,
58 properties,
59 record,
60 selected = [],
61 onOpenEditor
62}: DatabaseButtonsProps) {
63 const router = useRouter();
64 const updateRecord = useUpdateRecord(databaseId);
65 const createRecord = useCreateRecord(databaseId);
66
67 const buttons: DatabaseButtonDef[] = (view.config.buttons ?? []) as DatabaseButtonDef[];
68
69 const execute = (button: DatabaseButtonDef) => {
70 if (button.prompt) {
71 const ok = window.confirm(button.prompt);
72 if (!ok) return;
73 }
74 switch (button.action.type) {
75 case "navigate":
76 if (button.action.pageId) router.push(`/p/${button.action.pageId}`);
77 else if (button.action.url) window.open(button.action.url, "_blank", "noopener,noreferrer");
78 return;
79 case "create": {
80 createRecord.mutate(
81 { properties: { ...(button.action.template ?? {}) } },
82 {
83 onSuccess: () => notify({ title: "Record created" }),
84 onError: (e) => notify({ title: "Create failed", description: (e as Error).message, variant: "destructive" })
85 }
86 );
87 return;
88 }
89 case "update": {
90 const propId = button.action.propertyId;
91 const value: RecordValue = button.action.value;
92 const targets = selected.length ? selected : record ? [record] : [];
93 if (targets.length === 0) {
94 notify({ title: "No record selected", description: "Select a row or open a record first.", variant: "destructive" });
95 return;
96 }
97 targets.forEach((r) =>
98 updateRecord.mutate({ recordId: r.id, version: r.version, properties: { [propId]: value } })
99 );
100 notify({ title: `Updated ${targets.length} record(s)` });
101 return;
102 }
103 case "formula":
104 notify({ title: "Formula buttons arrive in a later phase" });
105 return;
106 }
107 };
108
109 if (buttons.length === 0) return null;
110
111 return (
112 <div className="flex flex-wrap items-center gap-1.5">
113 {buttons.map((b, i) => (
114 <button
115 key={b.id ?? `${b.name}-${i}`}
116 type="button"
117 className="inline-flex items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary hover:bg-primary/20 disabled:opacity-50"
118 onClick={() => execute(b)}
119 disabled={updateRecord.isPending}
120 title={b.prompt ? `${b.name} (asks before running)` : b.name}
121 >
122 {b.icon ?? <Zap className="size-3.5" aria-hidden />}
123 {b.name}
124 </button>
125 ))}
126 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted" onClick={onOpenEditor} title="Edit buttons" aria-label="Edit buttons">
127 <Play className="size-3.5" />
128 </button>
129 </div>
130 );
131}
132
133
134
135
136
137export interface ButtonsEditorProps {
138 databaseId: string;
139 view: DatabaseView;
140 properties: DatabaseProperty[];
141 onClose: () => void;
142}
143
144interface DraftButton {
145 name: string;
146 icon?: string;
147 prompt?: string;
148 actionType: DatabaseButtonDef["action"]["type"];
149 propertyId?: string;
150 valueText?: string;
151}
152
153export function ButtonsEditor({ databaseId, view, properties, onClose }: ButtonsEditorProps) {
154 const updateView = useUpdateView(databaseId);
155 const [drafts, setDrafts] = useState<DraftButton[]>(() =>
156 ((view.config.buttons ?? []) as DatabaseButtonDef[]).map((b) => ({
157 name: b.name,
158 icon: b.icon,
159 prompt: b.prompt,
160 actionType: b.action.type,
161 propertyId: b.action.type === "update" ? b.action.propertyId : undefined
162 }))
163 );
164 const [error, setError] = useState<string | null>(null);
165
166 const setDraft = (i: number, patch: Partial<DraftButton>) =>
167 setDrafts((d) => d.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
168 const remove = (i: number) => setDrafts((d) => d.filter((_, idx) => idx !== i));
169 const move = (i: number, dir: -1 | 1) =>
170 setDrafts((d) => {
171 const j = i + dir;
172 if (j < 0 || j >= d.length) return d;
173 const copy = [...d];
174 [copy[i], copy[j]] = [copy[j], copy[i]];
175 return copy;
176 });
177
178 const settable = properties.filter((p) => p.type !== "button" && p.type !== "created_time" && p.type !== "created_by" && p.type !== "last_edited_time" && p.type !== "last_edited_by" && p.type !== "unique_id");
179
180 const save = () => {
181 const buttons: DatabaseButtonDef[] = drafts.map((d, i) => {
182 const base: DatabaseButtonDef = { name: d.name || `Button ${i + 1}`, icon: d.icon, prompt: d.prompt || undefined, action: { type: "formula" } };
183 switch (d.actionType) {
184 case "update":
185 base.action = { type: "update", propertyId: d.propertyId ?? settable[0]?.id ?? "", value: parseValue(settable.find((p) => p.id === d.propertyId), d.valueText ?? "") };
186 break;
187 case "create":
188 base.action = { type: "create", template: {} };
189 break;
190 case "navigate":
191 base.action = { type: "navigate" };
192 break;
193 case "formula":
194 default:
195 base.action = { type: "formula" };
196 break;
197 }
198 return base;
199 });
200 updateView.mutate(
201 { viewId: view.id, config: { ...view.config, buttons } },
202 {
203 onSuccess: () => {
204 notify({ title: "Buttons saved" });
205 onClose();
206 },
207 onError: (e) => setError((e as Error).message)
208 }
209 );
210 };
211
212 return (
213 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-label="Button editor">
214 <div className="flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-background shadow-xl">
215 <div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
216 <span className="text-sm font-medium">Buttons</span>
217 <button type="button" className="ml-auto rounded p-1 text-muted-foreground hover:bg-muted" onClick={onClose} aria-label="Close">
218 <X className="size-4" />
219 </button>
220 </div>
221
222 <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-4">
223 {drafts.length === 0 && (
224 <p className="text-xs text-muted-foreground">No buttons yet. Add one below.</p>
225 )}
226 {drafts.map((d, i) => (
227 <div key={i} className="space-y-1.5 rounded-md border border-border p-2.5">
228 <div className="flex items-center gap-1.5">
229 <input
230 className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
231 placeholder="Button label"
232 value={d.name}
233 onChange={(e) => setDraft(i, { name: e.target.value })}
234 />
235 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted disabled:opacity-30" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">
236 <ArrowUp className="size-3.5" />
237 </button>
238 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted disabled:opacity-30" disabled={i === drafts.length - 1} onClick={() => move(i, 1)} aria-label="Move down">
239 <ArrowDown className="size-3.5" />
240 </button>
241 <button type="button" className="rounded p-1 text-destructive hover:bg-destructive/10" onClick={() => remove(i)} aria-label="Remove button">
242 <Trash2 className="size-3.5" />
243 </button>
244 </div>
245 <div className="flex flex-wrap items-center gap-1.5">
246 <select
247 className="rounded border border-input bg-background px-1.5 py-1 text-xs outline-none"
248 value={d.actionType}
249 onChange={(e) => setDraft(i, { actionType: e.target.value as DraftButton["actionType"] })}
250 >
251 <option value="update">Update property</option>
252 <option value="create">Create record</option>
253 <option value="navigate">Navigate</option>
254 <option value="formula">Formula</option>
255 </select>
256 {d.actionType === "update" && (
257 <>
258 <select
259 className="max-w-32 rounded border border-input bg-background px-1.5 py-1 text-xs outline-none"
260 value={d.propertyId ?? ""}
261 onChange={(e) => setDraft(i, { propertyId: e.target.value })}
262 >
263 <option value="">Property…</option>
264 {settable.map((p) => (
265 <option key={p.id} value={p.id}>
266 {p.name}
267 </option>
268 ))}
269 </select>
270 <input
271 className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-xs outline-none"
272 placeholder="Value to set"
273 value={d.valueText ?? ""}
274 onChange={(e) => setDraft(i, { valueText: e.target.value })}
275 />
276 </>
277 )}
278 </div>
279 <input
280 className="w-full rounded border border-input bg-background px-2 py-1 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
281 placeholder="Optional confirmation prompt (shown before running)"
282 value={d.prompt ?? ""}
283 onChange={(e) => setDraft(i, { prompt: e.target.value })}
284 />
285 </div>
286 ))}
287 <button
288 type="button"
289 className="inline-flex items-center gap-1 rounded-md border border-dashed px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted"
290 onClick={() => setDrafts((d) => [...d, { name: "", actionType: "update" }])}
291 >
292 <Plus className="size-3.5" /> Add button
293 </button>
294 {error && <p className="text-xs text-destructive">{error}</p>}
295 </div>
296
297 <div className="flex items-center justify-end gap-2 border-t border-border px-4 py-2.5">
298 <button type="button" className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted" onClick={onClose}>
299 Cancel
300 </button>
301 <button
302 type="button"
303 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
304 onClick={save}
305 disabled={updateView.isPending}
306 >
307 {updateView.isPending && <Loader2 className="size-3.5 animate-spin" />}
308 <Save className="size-3.5" /> Save
309 </button>
310 </div>
311 </div>
312 </div>
313 );
314}
315
316
317
318function parseValue(prop: DatabaseProperty | undefined, text: string): RecordValue {
319 if (!prop) return { text };
320 switch (prop.type) {
321 case "checkbox":
322 return { value: text.toLowerCase() === "true" || text === "1" || text === "yes" || text === "on" };
323 case "number":
324 return { value: Number(text) || 0 };
325 case "select":
326 case "status":
327 return { optionId: text || undefined };
328 case "multi_select":
329 return { optionIds: text ? [text] : [] };
330 case "date":
331 return { start: text ? new Date(text).toISOString() : null, end: null };
332 default:
333 return { text };
334 }
335}
336