@flaredev/cli 0.3.0 → 0.4.0

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 (42) hide show
  1. package/dist/index.js +385 -209
  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 +35 -0
  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/components/account/account-nav.tsx +39 -0
  13. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  14. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  15. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  16. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  17. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  18. package/templates/app/components/dashboard/editable.ts +12 -0
  19. package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
  20. package/templates/app/components/dashboard/import-dialog.tsx +60 -7
  21. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  22. package/templates/app/components/dashboard/local-time.tsx +77 -0
  23. package/templates/app/components/dashboard/query.ts +2 -0
  24. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  25. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  26. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  27. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  28. package/templates/app/components/dashboard/resource-table.tsx +405 -331
  29. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  30. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  31. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  32. package/templates/app/components/ui/sonner.tsx +33 -13
  33. package/templates/app/db/flare-schema.ts +22 -0
  34. package/templates/app/lib/api.ts +12 -0
  35. package/templates/app/lib/dashboard.ts +165 -138
  36. package/templates/app/lib/views.ts +29 -0
  37. package/templates/app/migrations/0000_init.sql +11 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  39. package/templates/app/vite.config.ts +41 -31
  40. package/templates/locks/package-lock.json +3 -3
  41. package/templates/locks/pnpm-lock.yaml +4 -4
  42. package/templates/locks/yarn.lock +3548 -0
@@ -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__";
@@ -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 { Resource } 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: Resource;
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
+ }
@@ -0,0 +1,200 @@
1
+ import Link from "next/link";
2
+ import { notFound } from "next/navigation";
3
+ import { formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { recentAudit } from "@/lib/audit";
8
+ import { adminPermissions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
9
+ import { site } from "@/lib/site";
10
+ import { LocalTime } from "./local-time";
11
+ import { PageHeader } from "./page-header";
12
+ import { RecordActions } from "./record-actions";
13
+ import type { RelationMeta } from "./fields/field-widget";
14
+
15
+ /** How many of a record's children to show before pointing at the filtered list. */
16
+ const RELATED_LIMIT = 5;
17
+
18
+ /**
19
+ * One record: its fields, what belongs to it, and what's been done to it.
20
+ *
21
+ * A row in a table is a summary; this is the page you send someone when you want them
22
+ * to look at the thing itself.
23
+ */
24
+ export async function RecordDetail({ resource, id }: { resource: Resource; id: string }) {
25
+ await requireAccess(resource, "read");
26
+ const store = dashboardStore(resource.name);
27
+ const result = await store.get(id);
28
+ if (!result.ok) notFound();
29
+ const record = result.data;
30
+ const permissions = await adminPermissions(resource.name);
31
+
32
+ const all = allResources();
33
+ const byName = new Map(all.map((item) => [item.name, item]));
34
+ const fields = storedFields(resource);
35
+
36
+ // Relation titles, so a belongsTo reads as a name rather than an id.
37
+ const relationTitles: Record<string, string> = {};
38
+ const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
39
+ for (const [key, def] of fields) {
40
+ if (def.kind !== "belongsTo") continue;
41
+ const target = byName.get(def.target);
42
+ if (!target) continue;
43
+ relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
44
+ const value = record[key];
45
+ if (typeof value === "string") {
46
+ const titles = await dashboardStore(target.name).titles([value]);
47
+ const title = titles[value];
48
+ if (title) relationTitles[key] = title;
49
+ }
50
+ }
51
+
52
+ // Children: resources with a belongsTo pointing back here.
53
+ const graph = relationGraph(all);
54
+ const children = (graph.byResource[resource.name]?.hasMany ?? []).flatMap((relation) => {
55
+ const target = byName.get(relation.target);
56
+ return target ? [{ relation, target }] : [];
57
+ });
58
+ const related = await Promise.all(
59
+ children.map(async ({ relation, target }) => {
60
+ const params = new URLSearchParams({ perPage: String(RELATED_LIMIT), [`filter[${relation.foreignKey}]`]: id });
61
+ const list = await dashboardStore(target.name).list(params);
62
+ return { relation, target, rows: list.ok ? list.data.data : [], total: list.ok ? list.data.meta.total : 0, filterKey: relation.foreignKey };
63
+ }),
64
+ );
65
+
66
+ const history = await recentAudit(10, resource.name);
67
+ const forThisRecord = history.filter((entry) => entry.recordId === id);
68
+
69
+ const titleDef = resource.fields[resource.titleField];
70
+ const title = titleDef && titleDef.kind !== "hasMany" ? String(formatValue(titleDef, record[resource.titleField]) || id) : id;
71
+
72
+ return (
73
+ <>
74
+ <PageHeader
75
+ title={title}
76
+ description={<>Created <LocalTime value={record.createdAt as string | number | Date} /></>}
77
+ crumbs={[
78
+ { label: "Dashboard", href: "/dashboard" },
79
+ { label: resource.pluralLabel, href: resourcePath(resource) },
80
+ { label: title },
81
+ ]}
82
+ actions={
83
+ <RecordActions
84
+ resource={resource}
85
+ id={id}
86
+ record={record}
87
+ relations={relations}
88
+ listHref={resourcePath(resource)}
89
+ editHref={resourcePath(resource, id, "edit")}
90
+ canUpdate={permissions.update}
91
+ canDelete={permissions.delete}
92
+ modalForms={site.dashboard.forms === "modal"}
93
+ />
94
+ }
95
+ />
96
+
97
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
98
+ <Card>
99
+ <CardHeader>
100
+ <CardTitle className="text-base">Details</CardTitle>
101
+ </CardHeader>
102
+ <CardContent className="grid gap-x-8 gap-y-4 sm:grid-cols-2">
103
+ {fields.map(([key, def]) => {
104
+ const value = record[key];
105
+ let content: React.ReactNode = formatValue(def, value);
106
+ if ((def.kind === "datetime") && value != null) {
107
+ content = <LocalTime value={value as string | number | Date} />;
108
+ } else if (def.kind === "enum" && value != null) {
109
+ const tone = statusTone(value);
110
+ content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
111
+ } else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
112
+ content = (
113
+ <span className="flex flex-wrap gap-1">
114
+ {value.map((item) => (
115
+ <Badge key={String(item)} variant="secondary">
116
+ {optionLabel(def, String(item))}
117
+ </Badge>
118
+ ))}
119
+ </span>
120
+ );
121
+ } else if (def.kind === "belongsTo" && typeof value === "string") {
122
+ const target = byName.get(def.target);
123
+ content = target ? (
124
+ <Link href={resourcePath(target, value)} className="text-primary hover:underline">
125
+ {relationTitles[key] ?? value}
126
+ </Link>
127
+ ) : (
128
+ value
129
+ );
130
+ }
131
+ return (
132
+ <div key={key} className={def.kind === "text" ? "flex flex-col gap-1 sm:col-span-2" : "flex flex-col gap-1"}>
133
+ <dt className="text-xs text-muted-foreground">{def.label}</dt>
134
+ <dd className="text-sm break-words">{content}</dd>
135
+ </div>
136
+ );
137
+ })}
138
+ </CardContent>
139
+ </Card>
140
+
141
+ <div className="flex flex-col gap-6">
142
+ <Card>
143
+ <CardHeader>
144
+ <CardTitle className="text-base">History</CardTitle>
145
+ <CardDescription>What's been done to this {resource.label.toLowerCase()}.</CardDescription>
146
+ </CardHeader>
147
+ <CardContent className="flex flex-col">
148
+ {forThisRecord.length === 0 ? (
149
+ <p className="text-sm text-muted-foreground">Nothing since the audit log started.</p>
150
+ ) : (
151
+ forThisRecord.map((entry) => (
152
+ <div key={entry.id} className="flex flex-col gap-0.5 border-b py-2.5 text-sm last:border-b-0 last:pb-0">
153
+ <span>
154
+ <Badge variant={entry.action === "delete" ? "destructive" : "secondary"}>{entry.action}</Badge>
155
+ <span className="ml-2 text-muted-foreground">{entry.userEmail ?? "someone"}</span>
156
+ </span>
157
+ <LocalTime value={entry.createdAt} className="text-xs text-muted-foreground tabular-nums" />
158
+ </div>
159
+ ))
160
+ )}
161
+ </CardContent>
162
+ </Card>
163
+
164
+ {related.map(({ relation, target, rows, total, filterKey }) => (
165
+ <Card key={relation.key}>
166
+ <CardHeader>
167
+ <CardTitle className="text-base">{target.pluralLabel}</CardTitle>
168
+ <CardDescription>
169
+ {total === 0 ? `No ${target.pluralLabel.toLowerCase()} yet.` : `${total.toLocaleString()} in total.`}
170
+ </CardDescription>
171
+ </CardHeader>
172
+ {rows.length > 0 && (
173
+ <CardContent className="flex flex-col">
174
+ {rows.map((row) => {
175
+ const childTitle = target.fields[target.titleField];
176
+ const label = childTitle && childTitle.kind !== "hasMany" ? formatValue(childTitle, row[target.titleField]) : String(row.id);
177
+ return (
178
+ <Link
179
+ key={String(row.id)}
180
+ href={resourcePath(target, String(row.id))}
181
+ className="truncate border-b py-2 text-sm last:border-b-0 last:pb-0 hover:text-primary"
182
+ >
183
+ {label || String(row.id)}
184
+ </Link>
185
+ );
186
+ })}
187
+ {total > rows.length && (
188
+ <Button variant="link" className="h-auto justify-start p-0 pt-2" asChild>
189
+ <Link href={`${resourcePath(target)}?filter[${filterKey}]=${id}`}>See all {total.toLocaleString()}</Link>
190
+ </Button>
191
+ )}
192
+ </CardContent>
193
+ )}
194
+ </Card>
195
+ ))}
196
+ </div>
197
+ </div>
198
+ </>
199
+ );
200
+ }