@flaredev/cli 0.2.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 (88) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +991 -344
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  6. package/templates/app/app/dashboard/account/page.tsx +11 -31
  7. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  8. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  9. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  10. package/templates/app/app/dashboard/actions.ts +246 -0
  11. package/templates/app/app/dashboard/import-actions.ts +91 -0
  12. package/templates/app/app/dashboard/layout.tsx +66 -39
  13. package/templates/app/app/dashboard/observability/page.tsx +175 -0
  14. package/templates/app/app/dashboard/page.tsx +108 -27
  15. package/templates/app/app/verify-email/page.tsx +33 -0
  16. package/templates/app/components/account/account-nav.tsx +39 -0
  17. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
  18. package/templates/app/components/auth/otp-input.tsx +95 -0
  19. package/templates/app/components/auth/password-field.tsx +116 -0
  20. package/templates/app/components/auth/password-forms.tsx +76 -79
  21. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  22. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  23. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  24. package/templates/app/components/auth/use-resend.ts +44 -0
  25. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  26. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  27. package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
  28. package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
  29. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  30. package/templates/app/components/dashboard/editable.ts +12 -0
  31. package/templates/app/components/dashboard/export-button.tsx +41 -0
  32. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
  33. package/templates/app/components/dashboard/import-dialog.tsx +540 -0
  34. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  35. package/templates/app/components/dashboard/local-time.tsx +77 -0
  36. package/templates/app/components/dashboard/page-header.tsx +57 -0
  37. package/templates/app/components/{admin → dashboard}/query.ts +2 -0
  38. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  39. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  40. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  41. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  42. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  43. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  44. package/templates/app/components/dashboard/resource-stats.tsx +48 -0
  45. package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
  46. package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
  47. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  48. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  49. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  50. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  51. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  52. package/templates/app/components/ui/sonner.tsx +33 -13
  53. package/templates/app/db/flare-schema.ts +55 -1
  54. package/templates/app/lib/analytics.ts +121 -0
  55. package/templates/app/lib/api.ts +12 -0
  56. package/templates/app/lib/audit.ts +80 -0
  57. package/templates/app/lib/auth-config.ts +38 -33
  58. package/templates/app/lib/auth.ts +34 -0
  59. package/templates/app/lib/cache.ts +83 -83
  60. package/templates/app/lib/csv.ts +24 -0
  61. package/templates/app/lib/dashboard-nav.ts +15 -0
  62. package/templates/app/lib/dashboard.ts +165 -0
  63. package/templates/app/lib/password-rules.ts +159 -0
  64. package/templates/app/lib/site.ts +56 -47
  65. package/templates/app/lib/views.ts +29 -0
  66. package/templates/app/migrations/0000_init.sql +25 -2
  67. package/templates/app/migrations/meta/0000_snapshot.json +159 -1
  68. package/templates/app/migrations/meta/_journal.json +1 -1
  69. package/templates/app/vite.config.ts +41 -31
  70. package/templates/app/worker-configuration.d.ts +15764 -0
  71. package/templates/locks/package-lock.json +9189 -0
  72. package/templates/locks/pnpm-lock.yaml +6347 -0
  73. package/templates/locks/yarn.lock +3548 -0
  74. package/templates/app/app/admin/actions.ts +0 -113
  75. package/templates/app/app/admin/layout.tsx +0 -32
  76. package/templates/app/components/admin/admin-header.tsx +0 -78
  77. package/templates/app/components/admin/resource-nav.tsx +0 -120
  78. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  79. package/templates/app/lib/admin-nav.ts +0 -15
  80. package/templates/app/lib/admin.ts +0 -103
  81. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  82. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  83. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  84. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  85. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ import Link from "next/link";
2
+ import { ChevronRightIcon } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export interface Crumb {
6
+ label: string;
7
+ href?: string;
8
+ }
9
+
10
+ /**
11
+ * The top of every dashboard page: where you are, what this page is, and what you can
12
+ * do here. Every page uses it, so they all line up.
13
+ */
14
+ export function PageHeader({
15
+ title,
16
+ description,
17
+ crumbs = [],
18
+ actions,
19
+ className,
20
+ }: {
21
+ title: React.ReactNode;
22
+ description?: React.ReactNode;
23
+ crumbs?: Crumb[];
24
+ /** Buttons for this page, right-aligned on a wide screen and below the title on a phone. */
25
+ actions?: React.ReactNode;
26
+ className?: string;
27
+ }) {
28
+ return (
29
+ <div className={cn("flex flex-col gap-3", className)}>
30
+ {crumbs.length > 0 && (
31
+ <nav aria-label="Breadcrumb">
32
+ <ol className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
33
+ {crumbs.map((crumb, index) => (
34
+ <li key={`${crumb.label}-${index}`} className="flex items-center gap-1">
35
+ {index > 0 && <ChevronRightIcon className="size-3.5 opacity-60" aria-hidden="true" />}
36
+ {crumb.href ? (
37
+ <Link href={crumb.href} className="hover:text-foreground">
38
+ {crumb.label}
39
+ </Link>
40
+ ) : (
41
+ <span aria-current="page">{crumb.label}</span>
42
+ )}
43
+ </li>
44
+ ))}
45
+ </ol>
46
+ </nav>
47
+ )}
48
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
49
+ <div className="flex flex-col gap-1">
50
+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
51
+ {description && <p className="text-sm text-muted-foreground">{description}</p>}
52
+ </div>
53
+ {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -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
+ }