@flaredev/cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/index.js +498 -220
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +73 -5
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/app/globals.css +0 -6
  13. package/templates/app/app/layout.tsx +37 -28
  14. package/templates/app/components/account/account-nav.tsx +39 -0
  15. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  16. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  19. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  20. package/templates/app/components/dashboard/editable.ts +12 -0
  21. package/templates/app/components/dashboard/fields/file-field.tsx +310 -302
  22. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  23. package/templates/app/components/dashboard/import-dialog.tsx +62 -9
  24. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  25. package/templates/app/components/dashboard/local-time.tsx +77 -0
  26. package/templates/app/components/dashboard/query.ts +2 -0
  27. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  28. package/templates/app/components/dashboard/record-detail.tsx +203 -0
  29. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  30. package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
  31. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  32. package/templates/app/components/dashboard/resource-form.tsx +165 -165
  33. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  34. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  35. package/templates/app/components/dashboard/resource-table.tsx +408 -331
  36. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  37. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  38. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  39. package/templates/app/components/ui/sonner.tsx +33 -13
  40. package/templates/app/db/flare-schema.ts +22 -0
  41. package/templates/app/lib/api.ts +12 -0
  42. package/templates/app/lib/dashboard.ts +165 -138
  43. package/templates/app/lib/views.ts +29 -0
  44. package/templates/app/migrations/0000_init.sql +11 -0
  45. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  46. package/templates/app/vite.config.ts +41 -31
  47. package/templates/locks/package-lock.json +9 -9
  48. package/templates/locks/pnpm-lock.yaml +12 -12
  49. package/templates/locks/yarn.lock +3548 -0
@@ -33,7 +33,29 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
33
33
  const [query, setQuery] = useState("");
34
34
  const [options, setOptions] = useState<Option[]>([]);
35
35
  const [loading, setLoading] = useState(false);
36
- const [title, setTitle] = useState(relation.initialTitle ?? "");
36
+ // The title is remembered against the id it belongs to, so a value that changes
37
+ // underneath the field doesn't keep the name of the record it used to hold.
38
+ const [known, setKnown] = useState({ id: value, title: relation.initialTitle ?? "" });
39
+ const title = known.id === value ? known.title : "";
40
+
41
+ // A form opened from a table row is given the id but not the name behind it. Looking it
42
+ // up here means the field reads correctly wherever the form was rendered.
43
+ useEffect(() => {
44
+ if (!value || title) return;
45
+ const controller = new AbortController();
46
+ void (async () => {
47
+ try {
48
+ const response = await fetch(`/api/${relation.slug}/${value}`, { signal: controller.signal, credentials: "same-origin" });
49
+ if (!response.ok) return;
50
+ const record = (await response.json()) as Record<string, unknown>;
51
+ const found = record[relation.titleField];
52
+ if (found) setKnown({ id: value, title: String(found) });
53
+ } catch {
54
+ // Leave the id showing; the field still works.
55
+ }
56
+ })();
57
+ return () => controller.abort();
58
+ }, [value, title, relation.slug, relation.titleField]);
37
59
 
38
60
  useEffect(() => {
39
61
  if (!open) return;
@@ -99,7 +121,7 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
99
121
  value={option.id}
100
122
  onSelect={() => {
101
123
  onChange(option.id);
102
- setTitle(option.title);
124
+ setKnown({ id: option.id, title: option.title });
103
125
  setOpen(false);
104
126
  }}
105
127
  >
@@ -121,7 +143,7 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
121
143
  disabled={disabled}
122
144
  onClick={() => {
123
145
  onChange("");
124
- setTitle("");
146
+ setKnown({ id: "", title: "" });
125
147
  }}
126
148
  >
127
149
  <XIcon />
@@ -10,7 +10,7 @@ import {
10
10
  storedFields,
11
11
  type EnumField,
12
12
  type MultiSelectField,
13
- type Resource,
13
+ type ClientResource,
14
14
  type StoredField,
15
15
  } from "@flaredev/core";
16
16
  import { importRecordsAction } from "@/app/dashboard/import-actions";
@@ -26,11 +26,48 @@ import {
26
26
  DialogTrigger,
27
27
  } from "@/components/ui/dialog";
28
28
  import { Field, FieldLabel } from "@/components/ui/field";
29
- import { Input } from "@/components/ui/input";
30
29
  import { Progress } from "@/components/ui/progress";
31
30
  import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
32
31
  import { Spinner } from "@/components/ui/spinner";
33
32
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
33
+ import { toCsv } from "@/lib/csv";
34
+ import { Dropzone } from "./dropzone";
35
+ import { downloadCsv } from "./table-selection";
36
+
37
+ /** What a column looks like in the downloadable template. */
38
+ function exampleFor(key: string, def: StoredField & { label: string }): string {
39
+ switch (def.kind) {
40
+ case "string":
41
+ if (def.format === "email") return "ada@example.com";
42
+ if (def.format === "tel") return "+256772123456";
43
+ if (def.format === "url") return "https://example.com";
44
+ if (def.format === "domain") return "example.com";
45
+ if (def.format === "country") return "UG";
46
+ if (def.format === "color") return "#f2541d";
47
+ if (def.format === "slug") return "my-first-" + key.toLowerCase();
48
+ return def.label;
49
+ case "text":
50
+ return "A longer note about this record.";
51
+ case "int":
52
+ return "42";
53
+ case "float":
54
+ return "9.99";
55
+ case "boolean":
56
+ return "true";
57
+ case "date":
58
+ return new Date().toISOString().slice(0, 10);
59
+ case "datetime":
60
+ return new Date().toISOString();
61
+ case "enum":
62
+ return String((def as EnumField).options[0] ?? "");
63
+ case "multiselect":
64
+ return ((def as MultiSelectField).options.slice(0, 2) as string[]).join("; ");
65
+ case "belongsTo":
66
+ return "id of an existing " + (def as { target: string }).target;
67
+ default:
68
+ return "";
69
+ }
70
+ }
34
71
 
35
72
  /** Sentinel for "don't import this field": a SelectItem can't carry an empty value. */
36
73
  const SKIP = "__skip__";
@@ -165,7 +202,7 @@ function guessMapping(fields: MappedField[], headers: string[]): Record<string,
165
202
  * schemas and again on the server, and sent in batches so a long file reports progress
166
203
  * and never depends on one very long request.
167
204
  */
168
- export function ImportDialog({ resource }: { resource: Resource }) {
205
+ export function ImportDialog({ resource }: { resource: ClientResource }) {
169
206
  const router = useRouter();
170
207
  const validators = useMemo(() => createValidators(resource), [resource]);
171
208
  // Uploads need a signed URL per file, which a CSV cell can't stand in for.
@@ -186,6 +223,13 @@ export function ImportDialog({ resource }: { resource: Resource }) {
186
223
  const unmapped = fields.filter(([key, def]) => needsColumn(def) && (mapping[key] === undefined || mapping[key] === SKIP));
187
224
  const ready = rows.length > 0 && mapped.length > 0 && unmapped.length === 0;
188
225
 
226
+ /** A starter file: the header this import wants, and one row of what each column holds. */
227
+ function downloadTemplate() {
228
+ const columns = fields.map(([key, def]) => ({ key, label: def.label }));
229
+ const example = Object.fromEntries(fields.map(([key, def]) => [key, exampleFor(key, def)]));
230
+ downloadCsv(`${resource.slug}-template.csv`, toCsv([example], columns));
231
+ }
232
+
189
233
  function reset() {
190
234
  setFileName("");
191
235
  setHeaders([]);
@@ -197,8 +241,8 @@ export function ImportDialog({ resource }: { resource: Resource }) {
197
241
  setSummary(null);
198
242
  }
199
243
 
200
- async function onFile(event: ChangeEvent<HTMLInputElement>) {
201
- const file = event.target.files?.[0];
244
+ async function onFile(files: File[]) {
245
+ const file = files[0];
202
246
  if (!file) return;
203
247
  reset();
204
248
 
@@ -319,10 +363,19 @@ export function ImportDialog({ resource }: { resource: Resource }) {
319
363
  </DialogHeader>
320
364
 
321
365
  <div className="grid gap-6">
322
- <Field data-invalid={fileError ? true : undefined}>
323
- <FieldLabel htmlFor="import-file">CSV file</FieldLabel>
324
- <Input id="import-file" type="file" accept=".csv,text/csv" disabled={running} onChange={onFile} />
325
- </Field>
366
+ <div className="flex flex-col gap-2">
367
+ <Dropzone
368
+ onFiles={onFile}
369
+ accept=".csv,text/csv"
370
+ disabled={running}
371
+ label={fileName ? fileName : "Drop a CSV here"}
372
+ hint={fileName ? `${rows.length.toLocaleString()} rows ready to map` : "One row per " + resource.label.toLowerCase() + ", with a header row on top"}
373
+ />
374
+ <Button type="button" variant="link" size="sm" className="h-auto w-fit p-0" onClick={downloadTemplate}>
375
+ <DownloadIcon data-icon="inline-start" />
376
+ Download a template with an example row
377
+ </Button>
378
+ </div>
326
379
 
327
380
  {fileError && (
328
381
  <Alert variant="destructive">
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, useTransition } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { CheckIcon, ChevronDownIcon, PencilIcon, XIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { optionLabel, type StoredField } from "@flaredev/core";
8
+ import { updateRecordAction } from "@/app/dashboard/actions";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
12
+ import { Spinner } from "@/components/ui/spinner";
13
+ import { Switch } from "@/components/ui/switch";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ interface Props {
17
+ resourceName: string;
18
+ id: string;
19
+ fieldKey: string;
20
+ def: StoredField & { label: string };
21
+ value: unknown;
22
+ /** What the cell shows when it isn't being edited. */
23
+ children: React.ReactNode;
24
+ }
25
+
26
+ const NONE = "__none__";
27
+
28
+ /**
29
+ * Edit one value without leaving the table.
30
+ *
31
+ * For the small changes — a status, a flag, a number — opening a form to change one
32
+ * field and closing it again is most of the work. Anything with a picker, an upload or
33
+ * a relation still goes through the form, where there's room to explain itself.
34
+ */
35
+ export function InlineCell({ resourceName, id, fieldKey, def, value, children }: Props) {
36
+ const router = useRouter();
37
+ const [editing, setEditing] = useState(false);
38
+ const [draft, setDraft] = useState("");
39
+ const [pending, startTransition] = useTransition();
40
+ const input = useRef<HTMLInputElement>(null);
41
+
42
+ useEffect(() => {
43
+ if (editing) input.current?.focus();
44
+ }, [editing]);
45
+
46
+ const save = (next: unknown) => {
47
+ setEditing(false);
48
+ if (next === value || (next === null && value === null)) return;
49
+ startTransition(async () => {
50
+ const result = await updateRecordAction(resourceName, id, { [fieldKey]: next });
51
+ if (!result.ok) {
52
+ toast.error(result.issues?.[0]?.message ?? result.error);
53
+ return;
54
+ }
55
+ toast.success(`${def.label} saved.`);
56
+ router.refresh();
57
+ });
58
+ };
59
+
60
+ if (pending) {
61
+ return (
62
+ <span className="inline-flex items-center gap-2 text-muted-foreground">
63
+ <Spinner className="size-3.5" />
64
+ {children}
65
+ </span>
66
+ );
67
+ }
68
+
69
+ // A switch and a select are one click either way, so they're always live.
70
+ if (def.kind === "boolean") {
71
+ return (
72
+ <Switch
73
+ checked={value === true}
74
+ onCheckedChange={(checked) => save(checked)}
75
+ aria-label={`${def.label} for this record`}
76
+ className="data-[state=unchecked]:bg-muted-foreground/30"
77
+ />
78
+ );
79
+ }
80
+
81
+ if (def.kind === "enum") {
82
+ // The badge stays until it's clicked: a row of grey dropdowns loses the colour that
83
+ // makes a status column readable at a glance.
84
+ if (!editing) {
85
+ return (
86
+ <button
87
+ type="button"
88
+ onClick={() => setEditing(true)}
89
+ className="group/cell inline-flex items-center gap-1 rounded px-0.5 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
90
+ aria-label={`Change ${def.label.toLowerCase()}`}
91
+ >
92
+ {children}
93
+ <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" aria-hidden="true" />
94
+ </button>
95
+ );
96
+ }
97
+ return (
98
+ <Select
99
+ defaultOpen
100
+ value={typeof value === "string" ? value : NONE}
101
+ onValueChange={(next) => save(next === NONE ? null : next)}
102
+ onOpenChange={(open) => {
103
+ if (!open) setEditing(false);
104
+ }}
105
+ >
106
+ <SelectTrigger size="sm" className="h-7 w-auto px-1.5" aria-label={def.label}>
107
+ <SelectValue placeholder="—" />
108
+ </SelectTrigger>
109
+ <SelectContent>
110
+ <SelectGroup>
111
+ {!def.required && <SelectItem value={NONE}>—</SelectItem>}
112
+ {def.options.map((option) => (
113
+ <SelectItem key={option} value={option}>
114
+ {optionLabel(def, option)}
115
+ </SelectItem>
116
+ ))}
117
+ </SelectGroup>
118
+ </SelectContent>
119
+ </Select>
120
+ );
121
+ }
122
+
123
+ if (editing) {
124
+ const numeric = def.kind === "int" || def.kind === "float";
125
+ return (
126
+ <span className="flex items-center gap-1">
127
+ <Input
128
+ ref={input}
129
+ value={draft}
130
+ onChange={(event) => setDraft(event.target.value)}
131
+ type={numeric ? "number" : "text"}
132
+ step={def.kind === "float" ? "any" : undefined}
133
+ className="h-7 w-full min-w-24 px-1.5"
134
+ aria-label={def.label}
135
+ onKeyDown={(event) => {
136
+ if (event.key === "Enter") save(numeric ? (draft === "" ? null : Number(draft)) : draft);
137
+ if (event.key === "Escape") setEditing(false);
138
+ }}
139
+ onBlur={() => save(numeric ? (draft === "" ? null : Number(draft)) : draft)}
140
+ />
141
+ <Button variant="ghost" size="icon" className="size-6" aria-label="Save" onMouseDown={(event) => event.preventDefault()}>
142
+ <CheckIcon />
143
+ </Button>
144
+ <Button variant="ghost" size="icon" className="size-6" aria-label="Cancel" onMouseDown={(event) => { event.preventDefault(); setEditing(false); }}>
145
+ <XIcon />
146
+ </Button>
147
+ </span>
148
+ );
149
+ }
150
+
151
+ return (
152
+ <button
153
+ type="button"
154
+ onClick={() => {
155
+ setDraft(value === null || value === undefined ? "" : String(value));
156
+ setEditing(true);
157
+ }}
158
+ className={cn("group/cell inline-flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-muted", "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary")}
159
+ aria-label={`Edit ${def.label.toLowerCase()}`}
160
+ >
161
+ <span className="truncate">{children}</span>
162
+ <PencilIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" aria-hidden="true" />
163
+ </button>
164
+ );
165
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /**
6
+ * A timestamp in the reader's own timezone.
7
+ *
8
+ * Pages render on a Worker, where the clock is UTC, so a date formatted on the server is
9
+ * in the wrong timezone for everyone. The server's version is rendered first (so the
10
+ * markup isn't empty and search engines see something), then the browser replaces it
11
+ * with its own once it's running.
12
+ */
13
+ export function LocalTime({
14
+ value,
15
+ dateStyle = "medium",
16
+ timeStyle = "short",
17
+ className,
18
+ }: {
19
+ /** Milliseconds, an ISO string, or a Date. */
20
+ value: number | string | Date | null | undefined;
21
+ dateStyle?: Intl.DateTimeFormatOptions["dateStyle"];
22
+ timeStyle?: Intl.DateTimeFormatOptions["timeStyle"];
23
+ className?: string;
24
+ }) {
25
+ const date = value === null || value === undefined ? null : value instanceof Date ? value : new Date(value);
26
+ const utc = date ? new Intl.DateTimeFormat("en-GB", { dateStyle, timeStyle, timeZone: "UTC" }).format(date) : "—";
27
+ const [text, setText] = useState(utc);
28
+
29
+ useEffect(() => {
30
+ if (!date) return;
31
+ setText(new Intl.DateTimeFormat(undefined, { dateStyle, timeStyle }).format(date));
32
+ }, [date?.getTime(), dateStyle, timeStyle]);
33
+
34
+ if (!date) return <span className={className}>—</span>;
35
+ return (
36
+ <time dateTime={date.toISOString()} title={date.toISOString()} className={className} suppressHydrationWarning>
37
+ {text}
38
+ </time>
39
+ );
40
+ }
41
+
42
+ /** "3 minutes ago", "yesterday", falling back to a date once it's a week old. */
43
+ export function RelativeTime({ value, className }: { value: number | string | Date | null | undefined; className?: string }) {
44
+ const date = value === null || value === undefined ? null : value instanceof Date ? value : new Date(value);
45
+ const [text, setText] = useState<string | null>(null);
46
+
47
+ useEffect(() => {
48
+ if (!date) return;
49
+ const format = () => {
50
+ const seconds = Math.round((date.getTime() - Date.now()) / 1000);
51
+ const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
52
+ const units: [Intl.RelativeTimeFormatUnit, number][] = [
53
+ ["second", 60],
54
+ ["minute", 60],
55
+ ["hour", 24],
56
+ ["day", 7],
57
+ ];
58
+ let value = seconds;
59
+ for (const [unit, size] of units) {
60
+ if (Math.abs(value) < size) return relative.format(Math.round(value), unit);
61
+ value /= size;
62
+ }
63
+ return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
64
+ };
65
+ setText(format());
66
+ // Re-read every half minute, so "just now" doesn't stay "just now" all afternoon.
67
+ const timer = setInterval(() => setText(format()), 30_000);
68
+ return () => clearInterval(timer);
69
+ }, [date?.getTime()]);
70
+
71
+ if (!date) return <span className={className}>—</span>;
72
+ return (
73
+ <time dateTime={date.toISOString()} title={date.toISOString()} className={className} suppressHydrationWarning>
74
+ {text ?? new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" }).format(date)}
75
+ </time>
76
+ );
77
+ }
@@ -19,7 +19,9 @@ export function hrefWith(path: string, current: URLSearchParams, changes: Record
19
19
  if (value === null || value === "") next.delete(key);
20
20
  else next.set(key, value);
21
21
  }
22
+ // A cursor belongs to the query that produced it, so changing anything else drops it.
22
23
  if (!("page" in changes)) next.delete("page");
24
+ if (!("cursor" in changes)) next.delete("cursor");
23
25
  const query = next.toString();
24
26
  return query ? `${path}?${query}` : path;
25
27
  }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState, useTransition } from "react";
6
+ import { PencilIcon, Trash2Icon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import type { ClientResource } from "@flaredev/core";
9
+ import { deleteRecordAction } from "@/app/dashboard/actions";
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogCancel,
13
+ AlertDialogContent,
14
+ AlertDialogDescription,
15
+ AlertDialogFooter,
16
+ AlertDialogHeader,
17
+ AlertDialogTitle,
18
+ } from "@/components/ui/alert-dialog";
19
+ import { Button } from "@/components/ui/button";
20
+ import { Spinner } from "@/components/ui/spinner";
21
+ import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
22
+
23
+ interface Props {
24
+ resource: ClientResource;
25
+ id: string;
26
+ record: Record<string, unknown>;
27
+ relations: FormRelations;
28
+ listHref: string;
29
+ editHref: string;
30
+ canUpdate: boolean;
31
+ canDelete: boolean;
32
+ /** Whether editing opens a dialog (site.dashboard.forms) or goes to the form page. */
33
+ modalForms: boolean;
34
+ }
35
+
36
+ /** Edit and delete, at the top of a record's own page. Deleting goes back to the list. */
37
+ export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, modalForms }: Props) {
38
+ const router = useRouter();
39
+ const [editing, setEditing] = useState(false);
40
+ const [confirming, setConfirming] = useState(false);
41
+ const [pending, startTransition] = useTransition();
42
+
43
+ const remove = () => {
44
+ startTransition(async () => {
45
+ const result = await deleteRecordAction(resource.name, id);
46
+ if (!result.ok) {
47
+ toast.error(result.error);
48
+ return;
49
+ }
50
+ toast.success(`${resource.label} deleted.`);
51
+ // The record this page is about is gone, so the list is the only place left to be.
52
+ router.push(listHref);
53
+ router.refresh();
54
+ });
55
+ };
56
+
57
+ return (
58
+ <>
59
+ {canUpdate &&
60
+ (modalForms ? (
61
+ <Button variant="outline" onClick={() => setEditing(true)}>
62
+ <PencilIcon data-icon="inline-start" />
63
+ Edit
64
+ </Button>
65
+ ) : (
66
+ <Button variant="outline" asChild>
67
+ <Link href={editHref}>
68
+ <PencilIcon data-icon="inline-start" />
69
+ Edit
70
+ </Link>
71
+ </Button>
72
+ ))}
73
+ {canDelete && (
74
+ <Button variant="outline" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
75
+ <Trash2Icon data-icon="inline-start" />
76
+ Delete
77
+ </Button>
78
+ )}
79
+
80
+ {canUpdate && modalForms && (
81
+ <ResourceFormDialog
82
+ resource={resource}
83
+ relations={relations}
84
+ listHref={listHref}
85
+ mode="edit"
86
+ id={id}
87
+ record={record}
88
+ open={editing}
89
+ onOpenChange={(open) => {
90
+ setEditing(open);
91
+ if (!open) router.refresh();
92
+ }}
93
+ />
94
+ )}
95
+
96
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
97
+ <AlertDialogContent>
98
+ <AlertDialogHeader>
99
+ <AlertDialogTitle>Delete this {resource.label.toLowerCase()}?</AlertDialogTitle>
100
+ <AlertDialogDescription>This can&apos;t be undone. Anything that belongs to it may be deleted too.</AlertDialogDescription>
101
+ </AlertDialogHeader>
102
+ <AlertDialogFooter>
103
+ <AlertDialogCancel disabled={pending}>Keep it</AlertDialogCancel>
104
+ <Button variant="destructive" onClick={remove} disabled={pending}>
105
+ {pending && <Spinner data-icon="inline-start" />}
106
+ Delete
107
+ </Button>
108
+ </AlertDialogFooter>
109
+ </AlertDialogContent>
110
+ </AlertDialog>
111
+ </>
112
+ );
113
+ }