create-nevela 0.5.1 → 0.5.3

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 (31) hide show
  1. package/index.mjs +32 -2
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +16 -0
  5. package/template/laravel/src/Concerns/Trashable.php +63 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +43 -0
  7. package/template/laravel/src/Console/TrashCommand.php +33 -0
  8. package/template/laravel/src/Generator/ResourceGenerator.php +84 -1
  9. package/template/laravel/src/Http/ResourceRequest.php +108 -0
  10. package/template/laravel/src/Http/TrashController.php +190 -0
  11. package/template/laravel/src/Nevela.php +162 -15
  12. package/template/laravel/src/NevelaServiceProvider.php +19 -0
  13. package/template/laravel/src/Support/Insights.php +78 -0
  14. package/template/laravel/src/Trash/Trash.php +151 -0
  15. package/template/laravel/src/Trash/TrashedScope.php +21 -0
  16. package/template/web/app/dashboard/actions.ts +79 -0
  17. package/template/web/app/dashboard/layout.tsx +4 -2
  18. package/template/web/app/dashboard/trash/actions.ts +78 -0
  19. package/template/web/app/dashboard/trash/page.tsx +167 -0
  20. package/template/web/components/dashboard/bulk-create-grid.tsx +326 -0
  21. package/template/web/components/dashboard/dashboard-sidebar.tsx +5 -1
  22. package/template/web/components/dashboard/record-actions.tsx +14 -5
  23. package/template/web/components/dashboard/record-detail.tsx +3 -0
  24. package/template/web/components/dashboard/resource-insights.tsx +219 -0
  25. package/template/web/components/dashboard/resource-table.tsx +10 -0
  26. package/template/web/components/dashboard/row-actions.tsx +12 -7
  27. package/template/web/components/dashboard/table-selection.tsx +27 -7
  28. package/template/web/components/dashboard/trash-actions.tsx +178 -0
  29. package/template/web/lib/trash-info.ts +13 -0
  30. package/template/web/lib/trash.ts +66 -0
  31. package/template/web/package.json +1 -1
@@ -0,0 +1,326 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useMemo, useRef, useState, useTransition } from "react";
5
+ import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
6
+ import { ListPlusIcon, PlusIcon, XIcon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import { createManyAction } from "@/app/dashboard/actions";
9
+ import { Alert, AlertDescription } from "@/components/ui/alert";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Checkbox } from "@/components/ui/checkbox";
12
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
13
+ import { Input } from "@/components/ui/input";
14
+ import { Spinner } from "@/components/ui/spinner";
15
+ import { cn } from "@/lib/utils";
16
+ import { FieldWidget, type RelationMeta } from "./fields/field-widget";
17
+
18
+ type FieldDef = StoredField & { label: string };
19
+ type Values = Record<string, string | boolean>;
20
+ type Relations = Record<string, RelationMeta & { initialTitle?: string }>;
21
+
22
+ interface Row {
23
+ /** Stays with the row as others are added and removed around it. */
24
+ key: number;
25
+ values: Values;
26
+ }
27
+
28
+ /**
29
+ * Enough to see that more can be added, and few enough not to look like a form to fill
30
+ * in. An empty grid gives nothing to type into; one row means clicking "Add row" before
31
+ * you have begun.
32
+ */
33
+ const STARTING_ROWS = 5;
34
+
35
+ /**
36
+ * Where the grid stops adding rows: what Laravel accepts in one request unless an app
37
+ * changes it (config/nevela.php, bulk_max). An app that sets it lower is still safe:
38
+ * Laravel refuses the larger request, and its message, which says the limit, is shown.
39
+ */
40
+ const MOST_ROWS = 500;
41
+
42
+ /** Kinds that fit in a cell: one control, one value. A file needs its own upload, so it is left to the form. */
43
+ const IN_A_CELL = new Set(["string", "text", "int", "float", "boolean", "date", "datetime", "enum", "belongsTo"]);
44
+
45
+ /**
46
+ * Add several records at once, in a grid.
47
+ *
48
+ * Between "New" and "Import". Import is for a file somebody already has, and New is for
49
+ * one record. This is for the twelve you have in your head or on a piece of paper, which
50
+ * until now meant opening the form twelve times.
51
+ *
52
+ * Rows left empty are ignored, so nobody has to delete the ones they didn't use. The rows
53
+ * that were filled in are saved together or not at all: Laravel checks every one first,
54
+ * and a mistake comes back against its row with nothing created.
55
+ */
56
+ export function BulkCreateButton({ resource, relations = {} }: { resource: ClientResource; relations?: Relations }) {
57
+ const [open, setOpen] = useState(false);
58
+ const plural = resource.pluralLabel.toLowerCase();
59
+
60
+ return (
61
+ <Dialog open={open} onOpenChange={setOpen}>
62
+ <DialogTrigger asChild>
63
+ <Button variant="outline">
64
+ <ListPlusIcon data-icon="inline-start" />
65
+ Add several
66
+ </Button>
67
+ </DialogTrigger>
68
+ <DialogContent className="flex max-h-[90vh] flex-col gap-4 sm:max-w-[min(96vw,84rem)]">
69
+ <DialogHeader>
70
+ <DialogTitle>Add several {plural}</DialogTitle>
71
+ <DialogDescription>One row for each. Rows you leave empty are ignored, and they are all saved together or not at all.</DialogDescription>
72
+ </DialogHeader>
73
+ {/* Mounted only while open, so what was typed and abandoned last time is gone. */}
74
+ {open && <Grid resource={resource} relations={relations} onDone={() => setOpen(false)} />}
75
+ </DialogContent>
76
+ </Dialog>
77
+ );
78
+ }
79
+
80
+ function Grid({ resource, relations, onDone }: { resource: ClientResource; relations: Relations; onDone: () => void }) {
81
+ const router = useRouter();
82
+ const [pending, startTransition] = useTransition();
83
+ const validators = useMemo(() => createValidators(resource), [resource]);
84
+ const all = useMemo(() => storedFields(resource) as [string, FieldDef][], [resource]);
85
+ const fields = useMemo(() => all.filter(([, def]) => IN_A_CELL.has(def.kind)), [all]);
86
+ // A field this grid can't show and Laravel requires would fail every row, with nothing
87
+ // on screen to fix. Say so, and leave those records to the form.
88
+ const missing = useMemo(() => all.filter(([, def]) => !IN_A_CELL.has(def.kind) && def.required !== false), [all]);
89
+
90
+ const blank = useMemo(() => initialFormValues(resource, null) as Values, [resource]);
91
+ const nextKey = useRef(STARTING_ROWS);
92
+ const [rows, setRows] = useState<Row[]>(() => Array.from({ length: STARTING_ROWS }, (_, key) => ({ key, values: { ...blank } })));
93
+ /** Problems by row key, then by field. "" holds what isn't about one field. */
94
+ const [problems, setProblems] = useState<Record<number, Record<string, string>>>({});
95
+ const [error, setError] = useState<string | null>(null);
96
+
97
+ // A row is empty when it is exactly as it started: a tick box left unticked is not an entry.
98
+ const isEmpty = (values: Values) => fields.every(([key]) => values[key] === blank[key]);
99
+ const filled = rows.filter((row) => !isEmpty(row.values));
100
+ const noun = filled.length === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase();
101
+
102
+ function change(rowKey: number, field: string, value: string | boolean) {
103
+ setRows((current) => current.map((row) => (row.key === rowKey ? { ...row, values: { ...row.values, [field]: value } } : row)));
104
+ if (problems[rowKey]?.[field]) {
105
+ setProblems(({ [rowKey]: row = {}, ...rest }) => {
106
+ const { [field]: _fixed, ...others } = row;
107
+ return Object.keys(others).length > 0 ? { ...rest, [rowKey]: others } : rest;
108
+ });
109
+ }
110
+ }
111
+
112
+ function addRows(count: number) {
113
+ setRows((current) => [...current, ...Array.from({ length: Math.min(count, MOST_ROWS - current.length) }, () => ({ key: nextKey.current++, values: { ...blank } }))]);
114
+ }
115
+
116
+ function removeRow(rowKey: number) {
117
+ setRows((current) => (current.length > 1 ? current.filter((row) => row.key !== rowKey) : [{ key: nextKey.current++, values: { ...blank } }]));
118
+ setProblems(({ [rowKey]: _gone, ...rest }) => rest);
119
+ }
120
+
121
+ function save() {
122
+ setError(null);
123
+ if (filled.length === 0) {
124
+ setError("Nothing to create yet: every row is empty.");
125
+ return;
126
+ }
127
+ // Checked here first with the descriptor's own rules, so most mistakes never leave the page.
128
+ const found: Record<number, Record<string, string>> = {};
129
+ const inputs = filled.map((row) => {
130
+ const input = formValuesToInput(resource, row.values, "create") as Record<string, unknown>;
131
+ const parsed = validators.create.safeParse(input);
132
+ if (!parsed.success) {
133
+ const visible = Object.fromEntries(Object.entries(issuesByField(parsed.error.issues)).filter(([key]) => fields.some(([field]) => field === key)));
134
+ if (Object.keys(visible).length > 0) found[row.key] = visible;
135
+ }
136
+ // Only what the grid shows is sent; a field it can't show is left for Laravel to judge.
137
+ return Object.fromEntries(Object.entries(input).filter(([key]) => fields.some(([field]) => field === key)));
138
+ });
139
+ if (Object.keys(found).length > 0) {
140
+ setProblems(found);
141
+ setError(Object.keys(found).length === 1 ? "One row needs fixing." : `${Object.keys(found).length} rows need fixing.`);
142
+ return;
143
+ }
144
+
145
+ startTransition(async () => {
146
+ const result = await createManyAction(resource.name, inputs);
147
+ if (result.ok) {
148
+ toast.success(`${result.data.created} ${result.data.created === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()} created.`);
149
+ onDone();
150
+ router.refresh();
151
+ return;
152
+ }
153
+ // Laravel numbers the rows it was sent, which are only the ones filled in. Here a
154
+ // row is called by its number on screen, so every number it mentions is translated.
155
+ const onScreen = (sent: number) => {
156
+ const row = filled[sent - 1];
157
+ return row ? rows.findIndex((candidate) => candidate.key === row.key) + 1 : sent;
158
+ };
159
+ const fromServer: Record<number, Record<string, string>> = {};
160
+ const numbers: number[] = [];
161
+ for (const problem of result.rows ?? []) {
162
+ const row = filled[problem.row - 1];
163
+ if (!row) continue;
164
+ numbers.push(onScreen(problem.row));
165
+ fromServer[row.key] = {};
166
+ for (const issue of problem.issues) {
167
+ const field = issue.path.split(".")[0] ?? "";
168
+ const known = fields.some(([key]) => key === field);
169
+ const label = all.find(([key]) => key === field)?.[1].label;
170
+ const message = issue.message.replace(/\brow (\d+)\b/g, (_, sent: string) => `row ${onScreen(Number(sent))}`);
171
+ // A problem with a field that has no column here is said beside the row instead.
172
+ fromServer[row.key]![known ? field : ""] ??= known || !label ? message : `${label}: ${message}`;
173
+ }
174
+ }
175
+ setProblems(fromServer);
176
+ setError(numbers.length === 0 ? result.error : numbers.length === 1 ? `Nothing was created: row ${numbers[0]} needs fixing.` : `Nothing was created: ${numbers.length} rows need fixing.`);
177
+ });
178
+ }
179
+
180
+ return (
181
+ <>
182
+ {missing.length > 0 && (
183
+ <Alert>
184
+ <AlertDescription>
185
+ {missing.map(([, def]) => def.label).join(", ")} {missing.length === 1 ? "is" : "are"} required and can&apos;t be filled in here, so these rows would be
186
+ refused. Use New {resource.label.toLowerCase()} for {resource.pluralLabel.toLowerCase()} instead.
187
+ </AlertDescription>
188
+ </Alert>
189
+ )}
190
+
191
+ <div className="min-h-0 flex-1 overflow-auto rounded-md border">
192
+ <table className="w-full border-collapse text-sm">
193
+ <thead className="sticky top-0 z-10 bg-muted text-left text-muted-foreground">
194
+ <tr>
195
+ <th scope="col" className="w-10 px-2 py-2 text-center font-medium">
196
+ #
197
+ </th>
198
+ {fields.map(([key, def]) => (
199
+ <th key={key} scope="col" className={cn("px-2 py-2 font-medium whitespace-nowrap", def.kind === "boolean" ? "w-24 text-center" : "min-w-44")}>
200
+ {def.label}
201
+ {def.required !== false && def.kind !== "boolean" && (
202
+ <span className="text-destructive" aria-label="required">
203
+ {" "}
204
+ *
205
+ </span>
206
+ )}
207
+ </th>
208
+ ))}
209
+ <th scope="col" className="w-10">
210
+ <span className="sr-only">Remove</span>
211
+ </th>
212
+ </tr>
213
+ </thead>
214
+ <tbody>
215
+ {rows.map((row, index) => {
216
+ const wrong = problems[row.key] ?? {};
217
+ const messages = Object.entries(wrong).map(([field, message]) => {
218
+ const label = fields.find(([key]) => key === field)?.[1].label;
219
+ return label ? `${label}: ${message}` : message;
220
+ });
221
+ return (
222
+ <tr key={row.key} className="border-t align-top">
223
+ <td className="px-2 py-3 text-center text-muted-foreground tabular-nums">{index + 1}</td>
224
+ {fields.map(([key, def]) => (
225
+ <td key={key} className={cn("px-1.5 py-1.5", def.kind === "boolean" && "text-center")}>
226
+ <Cell
227
+ id={`bulk-${row.key}-${key}`}
228
+ resourceName={resource.name}
229
+ fieldKey={key}
230
+ field={def}
231
+ label={`${def.label}, row ${index + 1}`}
232
+ value={row.values[key] ?? ""}
233
+ invalid={Boolean(wrong[key])}
234
+ disabled={pending}
235
+ relation={relations[key]}
236
+ onChange={(value) => change(row.key, key, value)}
237
+ />
238
+ {/* Said under the cell it is about. One with no cell of its own goes under the first. */}
239
+ {key === fields[0]?.[0] && messages.length > 0 && (
240
+ <p role="alert" className="mt-1 w-max max-w-[60vw] text-left text-xs text-destructive">
241
+ {messages.join(" · ")}
242
+ </p>
243
+ )}
244
+ </td>
245
+ ))}
246
+ <td className="px-1 py-1.5">
247
+ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove row ${index + 1}`} disabled={pending} onClick={() => removeRow(row.key)}>
248
+ <XIcon />
249
+ </Button>
250
+ </td>
251
+ </tr>
252
+ );
253
+ })}
254
+ </tbody>
255
+ </table>
256
+ </div>
257
+
258
+ <div className="flex flex-wrap items-center gap-2">
259
+ <Button type="button" variant="outline" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(1)}>
260
+ <PlusIcon data-icon="inline-start" />
261
+ Add row
262
+ </Button>
263
+ <Button type="button" variant="ghost" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(5)}>
264
+ Add 5 rows
265
+ </Button>
266
+ {error && (
267
+ <p role="alert" className="text-sm text-destructive">
268
+ {error}
269
+ </p>
270
+ )}
271
+ </div>
272
+
273
+ <DialogFooter>
274
+ <Button type="button" variant="ghost" disabled={pending} onClick={onDone}>
275
+ Cancel
276
+ </Button>
277
+ <Button type="button" disabled={pending || filled.length === 0 || missing.length > 0} onClick={save}>
278
+ {pending && <Spinner />}
279
+ {filled.length === 0 ? `Create ${resource.pluralLabel.toLowerCase()}` : `Create ${filled.length} ${noun}`}
280
+ </Button>
281
+ </DialogFooter>
282
+ </>
283
+ );
284
+ }
285
+
286
+ /**
287
+ * One cell. Long text is a single line here and a tick box is a tick box: a grid is for
288
+ * typing across, and a four-line text area or a labelled switch in every row is neither
289
+ * quick nor readable. Everything else is the form's own control, so a date, a price or a
290
+ * link to another record behaves exactly as it does there.
291
+ */
292
+ function Cell({
293
+ id,
294
+ resourceName,
295
+ fieldKey,
296
+ field,
297
+ label,
298
+ value,
299
+ invalid,
300
+ disabled,
301
+ relation,
302
+ onChange,
303
+ }: {
304
+ id: string;
305
+ resourceName: string;
306
+ fieldKey: string;
307
+ field: FieldDef;
308
+ label: string;
309
+ value: string | boolean;
310
+ invalid: boolean;
311
+ disabled: boolean;
312
+ relation?: RelationMeta & { initialTitle?: string };
313
+ onChange: (value: string | boolean) => void;
314
+ }) {
315
+ if (field.kind === "boolean") {
316
+ return <Checkbox id={id} aria-label={label} checked={value === true} disabled={disabled} onCheckedChange={(checked) => onChange(checked === true)} className="mt-2" />;
317
+ }
318
+ if (field.kind === "text") {
319
+ return <Input id={id} aria-label={label} value={typeof value === "string" ? value : ""} disabled={disabled} aria-invalid={invalid || undefined} onChange={(event) => onChange(event.target.value)} />;
320
+ }
321
+ return (
322
+ <div aria-label={label} role="group">
323
+ <FieldWidget id={id} name={id} resourceName={resourceName} fieldKey={fieldKey} field={field} value={value} onChange={onChange} invalid={invalid} disabled={disabled} relation={relation} />
324
+ </div>
325
+ );
326
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname, useSearchParams } from "next/navigation";
5
- import { ActivityIcon, LayoutDashboardIcon, ShieldCheckIcon, UserCircleIcon, UsersIcon } from "lucide-react";
5
+ import { ActivityIcon, LayoutDashboardIcon, ShieldCheckIcon, Trash2Icon, UserCircleIcon, UsersIcon } from "lucide-react";
6
6
  import { ChevronsUpDownIcon } from "lucide-react";
7
7
  import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
8
8
  import {
@@ -64,6 +64,7 @@ export function DashboardSidebar({
64
64
  links = [],
65
65
  isAdmin = false,
66
66
  access = { users: false, roles: false },
67
+ trash = false,
67
68
  user,
68
69
  }: {
69
70
  appName: string;
@@ -74,6 +75,8 @@ export function DashboardSidebar({
74
75
  isAdmin?: boolean;
75
76
  /** Which of the screens for managing people this person may open. */
76
77
  access?: { users: boolean; roles: boolean };
78
+ /** Whether this person may delete anything, and so has a trash to look in. */
79
+ trash?: boolean;
77
80
  user: DashboardUser;
78
81
  }) {
79
82
  const pathname = usePathname();
@@ -83,6 +86,7 @@ export function DashboardSidebar({
83
86
  ...ACCOUNT_LINKS,
84
87
  ...(access.users ? [{ href: "/dashboard/access/users", label: "Users", icon: UsersIcon }] : []),
85
88
  ...(access.roles ? [{ href: "/dashboard/access/roles", label: "Roles", icon: ShieldCheckIcon }] : []),
89
+ ...(trash ? [{ href: "/dashboard/trash", label: "Trash", icon: Trash2Icon }] : []),
86
90
  ...(isAdmin ? ADMIN_LINKS : []),
87
91
  ];
88
92
 
@@ -7,6 +7,7 @@ import { PencilIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
8
  import type { ClientResource } from "@flaredev/core";
9
9
  import { deleteRecordAction } from "@/app/dashboard/actions";
10
+ import { restoreRecordAction } from "@/app/dashboard/trash/actions";
10
11
  import {
11
12
  AlertDialog,
12
13
  AlertDialogCancel,
@@ -18,7 +19,9 @@ import {
18
19
  } from "@/components/ui/alert-dialog";
19
20
  import { Button } from "@/components/ui/button";
20
21
  import { Spinner } from "@/components/ui/spinner";
22
+ import { keptFor, type TrashInfo } from "@/lib/trash-info";
21
23
  import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
24
+ import { toastMovedToTrash } from "./trash-actions";
22
25
 
23
26
  interface Props {
24
27
  resource: ClientResource;
@@ -31,10 +34,12 @@ interface Props {
31
34
  canDelete: boolean;
32
35
  /** Whether editing opens a sheet (site.dashboard.forms) or goes to the form page. */
33
36
  overlayForms: boolean;
37
+ /** This resource's trash, when a deleted record goes there instead of going for good. */
38
+ trash?: TrashInfo;
34
39
  }
35
40
 
36
41
  /** 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, overlayForms }: Props) {
42
+ export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash }: Props) {
38
43
  const router = useRouter();
39
44
  const [editing, setEditing] = useState(false);
40
45
  const [confirming, setConfirming] = useState(false);
@@ -47,7 +52,9 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
47
52
  toast.error(result.error);
48
53
  return;
49
54
  }
50
- toast.success(`${resource.label} deleted.`);
55
+ // Undone, the record has a page again, and that is where to go back to.
56
+ if (trash) toastMovedToTrash(`${resource.label} moved to the trash.`, () => restoreRecordAction(resource.slug, id), () => router.push(`${listHref}/${id}`));
57
+ else toast.success(`${resource.label} deleted.`);
51
58
  // The record this page is about is gone, so the list is the only place left to be.
52
59
  router.push(listHref);
53
60
  router.refresh();
@@ -96,14 +103,16 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
96
103
  <AlertDialog open={confirming} onOpenChange={setConfirming}>
97
104
  <AlertDialogContent>
98
105
  <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>
106
+ <AlertDialogTitle>{trash ? `Move this ${resource.label.toLowerCase()} to the trash?` : `Delete this ${resource.label.toLowerCase()}?`}</AlertDialogTitle>
107
+ <AlertDialogDescription>
108
+ {trash ? `It can be restored from the trash ${keptFor(trash)}.` : "This can't be undone. Anything that belongs to it may be deleted too."}
109
+ </AlertDialogDescription>
101
110
  </AlertDialogHeader>
102
111
  <AlertDialogFooter>
103
112
  <AlertDialogCancel disabled={pending}>Keep it</AlertDialogCancel>
104
113
  <Button variant="destructive" onClick={remove} disabled={pending}>
105
114
  {pending && <Spinner data-icon="inline-start" />}
106
- Delete
115
+ {trash ? "Move to trash" : "Delete"}
107
116
  </Button>
108
117
  </AlertDialogFooter>
109
118
  </AlertDialogContent>
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
7
7
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8
8
  import { adminPermissions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
9
9
  import { site } from "@/lib/site";
10
+ import { trashFor } from "@/lib/trash";
10
11
  import { LocalTime } from "./local-time";
11
12
  import { PageHeader } from "./page-header";
12
13
  import { RecordActions } from "./record-actions";
@@ -31,6 +32,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
31
32
  if (!result.ok) notFound();
32
33
  const record = result.data;
33
34
  const permissions = await adminPermissions(resource.name);
35
+ const trash = permissions.delete ? await trashFor(resource.slug) : undefined;
34
36
 
35
37
  const all = allResources();
36
38
  const byName = new Map(all.map((item) => [item.name, item]));
@@ -90,6 +92,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
90
92
  canUpdate={permissions.update}
91
93
  canDelete={permissions.delete}
92
94
  overlayForms={site.dashboard.forms === "sheet"}
95
+ trash={trash}
93
96
  />
94
97
  }
95
98
  />
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import { useSearchParams } from "next/navigation";
4
+ import { useEffect, useId, useMemo, useState } from "react";
5
+ import { optionLabel, type ClientResource } from "@flaredev/core";
6
+ import { BarChart3Icon, ChevronDownIcon } from "lucide-react";
7
+ import { resourceInsightsAction, type InsightsData, type InsightsUnit } from "@/app/dashboard/actions";
8
+ import { Spinner } from "@/components/ui/spinner";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ const UNITS: { key: InsightsUnit; label: string; per: string; span: string }[] = [
12
+ { key: "day", label: "Daily", per: "day", span: "the last 30 days" },
13
+ { key: "week", label: "Weekly", per: "week", span: "the last 26 weeks" },
14
+ { key: "month", label: "Monthly", per: "month", span: "the last 12 months" },
15
+ ];
16
+
17
+ /** A period as someone would say it: "9 Oct", "the week of 5 Oct", "Oct 2026". */
18
+ function periodLabel(unit: InsightsUnit, bucket: string, long = false): string {
19
+ // UTC throughout: a bucket is a calendar day or month, not a moment, and must not slide a day in another timezone.
20
+ const date = new Date(`${unit === "month" ? `${bucket}-01` : bucket}T00:00:00Z`);
21
+ if (unit === "month") return new Intl.DateTimeFormat("en-GB", { month: "short", year: "numeric", timeZone: "UTC" }).format(date);
22
+ const day = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...(long ? { year: "numeric" } : {}), timeZone: "UTC" }).format(date);
23
+ return unit === "week" && long ? `the week of ${day}` : day;
24
+ }
25
+
26
+ /**
27
+ * Charts above a list, closed until somebody wants them.
28
+ *
29
+ * Closed is the default on purpose. The panel's numbers are a grouped query each over
30
+ * everything the list matches, which is work an ordinary visit to a list doesn't need.
31
+ * So nothing is asked for until it is opened, and whether it was left open is remembered
32
+ * for the next visit.
33
+ *
34
+ * What it charts is the rows the table is showing: the same search and filters go on the
35
+ * request, so narrowing the list to one category redraws the charts for that category.
36
+ *
37
+ * Drawn with plain elements and no chart library: bars are all these are, and a library
38
+ * for bars would be several hundred kilobytes to draw rectangles.
39
+ */
40
+ export function ResourceInsights({ resource, total }: { resource: ClientResource; /** The list's own count: when it changes, so have the charts. */ total: number }) {
41
+ const panel = useId();
42
+ const remembered = `nevela.insights.${resource.name}`;
43
+ const [open, setOpen] = useState(false);
44
+ const [unit, setUnit] = useState<InsightsUnit>("day");
45
+ const [data, setData] = useState<InsightsData | null>(null);
46
+ const [loading, setLoading] = useState(false);
47
+ const [error, setError] = useState<string | null>(null);
48
+
49
+ // Only what narrows the list. A page number or a sort order changes no count.
50
+ const params = useSearchParams();
51
+ const narrowed = useMemo(() => {
52
+ const kept = new URLSearchParams();
53
+ for (const [key, value] of params) if (key === "q" || key.startsWith("filter[")) kept.append(key, value);
54
+ kept.sort();
55
+ return kept.toString();
56
+ }, [params]);
57
+
58
+ // Read after the first paint: the server can't know what this browser remembers.
59
+ useEffect(() => {
60
+ try {
61
+ if (window.localStorage.getItem(remembered) === "open") setOpen(true);
62
+ } catch {
63
+ // No storage (a private window, or it is switched off): it just starts closed.
64
+ }
65
+ }, [remembered]);
66
+
67
+ function toggle() {
68
+ const next = !open;
69
+ setOpen(next);
70
+ try {
71
+ window.localStorage.setItem(remembered, next ? "open" : "closed");
72
+ } catch {
73
+ // Not remembered, and that is all.
74
+ }
75
+ }
76
+
77
+ useEffect(() => {
78
+ if (!open) return;
79
+ let current = true;
80
+ setLoading(true);
81
+ void resourceInsightsAction(resource.name, narrowed, unit).then((result) => {
82
+ if (!current) return;
83
+ setLoading(false);
84
+ if (result.ok) {
85
+ setData(result.data);
86
+ setError(null);
87
+ } else {
88
+ setError(result.error);
89
+ }
90
+ });
91
+ return () => {
92
+ current = false;
93
+ };
94
+ }, [open, unit, narrowed, total, resource.name]);
95
+
96
+ const chosen = UNITS.find((entry) => entry.key === unit)!;
97
+ const plural = resource.pluralLabel.toLowerCase();
98
+
99
+ return (
100
+ <section className="rounded-xl border bg-card">
101
+ <button type="button" onClick={toggle} aria-expanded={open} aria-controls={panel} className="flex w-full items-center gap-2 rounded-xl px-4 py-3 text-left text-sm font-medium hover:bg-muted/50">
102
+ <BarChart3Icon className="size-4 text-muted-foreground" />
103
+ Insights
104
+ <span className="truncate font-normal text-muted-foreground">{narrowed ? `for the ${plural} this list is showing` : `when ${plural} were created, and how they split`}</span>
105
+ {loading && <Spinner className="ml-1" />}
106
+ <ChevronDownIcon className={cn("ml-auto size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
107
+ </button>
108
+
109
+ {open && (
110
+ <div id={panel} className="border-t p-4">
111
+ {error && !data && (
112
+ <p role="alert" className="text-sm text-destructive">
113
+ {error}
114
+ </p>
115
+ )}
116
+ {!data && !error && <p className="text-sm text-muted-foreground">Counting…</p>}
117
+ {data && (
118
+ <div className={cn("grid gap-x-10 gap-y-8", data.breakdown.length > 0 && "lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]", loading && "opacity-60")}>
119
+ <div className="flex min-w-0 flex-col gap-3">
120
+ <div className="flex flex-wrap items-center justify-between gap-2">
121
+ <p className="text-sm">
122
+ <span className="font-medium">Created per {chosen.per}</span> <span className="text-muted-foreground">· {chosen.span}</span>
123
+ </p>
124
+ <div role="group" aria-label="Period" className="flex rounded-md border p-0.5 text-xs">
125
+ {UNITS.map((entry) => (
126
+ <button
127
+ key={entry.key}
128
+ type="button"
129
+ aria-pressed={entry.key === unit}
130
+ onClick={() => setUnit(entry.key)}
131
+ className={cn("rounded px-2 py-1", entry.key === unit ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
132
+ >
133
+ {entry.label}
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ <Series unit={data.unit} series={data.series} noun={plural} />
139
+ </div>
140
+
141
+ {data.breakdown.length > 0 && (
142
+ <div className="flex min-w-0 flex-col gap-6">
143
+ {data.breakdown.map((group) => (
144
+ <Breakdown key={group.field} resource={resource} group={group} />
145
+ ))}
146
+ </div>
147
+ )}
148
+ </div>
149
+ )}
150
+ </div>
151
+ )}
152
+ </section>
153
+ );
154
+ }
155
+
156
+ function Series({ unit, series, noun }: { unit: InsightsUnit; series: InsightsData["series"]; noun: string }) {
157
+ const most = Math.max(0, ...series.map((point) => point.count));
158
+ const sum = series.reduce((all, point) => all + point.count, 0);
159
+ const peak = series.find((point) => point.count === most);
160
+ const middle = series[Math.floor(series.length / 2)];
161
+ const summary =
162
+ sum === 0 || !peak
163
+ ? `No ${noun} were created in this time.`
164
+ : `${sum.toLocaleString()} created in this time. The most was ${most.toLocaleString()}, ${unit === "day" ? "on" : "in"} ${periodLabel(unit, peak.bucket, true)}.`;
165
+
166
+ return (
167
+ <figure className="flex flex-col gap-2">
168
+ <div role="img" aria-label={summary} className="flex h-40 items-end gap-0.5 border-b pb-px">
169
+ {series.map((point) => (
170
+ <div key={point.bucket} title={`${point.count.toLocaleString()} · ${periodLabel(unit, point.bucket, true)}`} className="group flex h-full min-w-0 flex-1 items-end">
171
+ <div
172
+ // A count of one in a tall chart still has to be seen: never thinner than a hairline.
173
+ style={{ height: most > 0 && point.count > 0 ? `max(${(point.count / most) * 100}%, 3px)` : "1px" }}
174
+ className={cn("w-full rounded-t-sm", point.count > 0 ? "bg-primary/75 group-hover:bg-primary" : "bg-border")}
175
+ />
176
+ </div>
177
+ ))}
178
+ </div>
179
+ <div className="flex justify-between text-xs text-muted-foreground tabular-nums" aria-hidden="true">
180
+ <span>{series[0] && periodLabel(unit, series[0].bucket)}</span>
181
+ <span>{middle && periodLabel(unit, middle.bucket)}</span>
182
+ <span>{series.at(-1) && periodLabel(unit, series.at(-1)!.bucket)}</span>
183
+ </div>
184
+ <figcaption className="text-xs text-muted-foreground">{summary}</figcaption>
185
+ </figure>
186
+ );
187
+ }
188
+
189
+ function Breakdown({ resource, group }: { resource: ClientResource; group: InsightsData["breakdown"][number] }) {
190
+ const def = resource.fields[group.field];
191
+ const sum = group.slices.reduce((all, slice) => all + slice.count, 0);
192
+ const name = (value: string) => {
193
+ if (value === "") return "Not set";
194
+ if (group.kind === "boolean") return value === "true" ? "Yes" : "No";
195
+ return def && def.kind === "enum" ? optionLabel(def, value) : value;
196
+ };
197
+
198
+ return (
199
+ <div className="flex flex-col gap-2">
200
+ <p className="text-sm font-medium">{def && "label" in def && def.label ? String(def.label) : group.field}</p>
201
+ <ul className="flex flex-col gap-1.5">
202
+ {group.slices.map((slice) => {
203
+ const share = sum > 0 ? (slice.count / sum) * 100 : 0;
204
+ return (
205
+ <li key={slice.value} className="grid grid-cols-[minmax(4rem,7rem)_1fr_auto] items-center gap-3 text-sm">
206
+ <span className="truncate text-muted-foreground">{name(slice.value)}</span>
207
+ <span className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden="true">
208
+ <span className="block h-full rounded-full bg-primary/75" style={{ width: `${share}%` }} />
209
+ </span>
210
+ <span className="tabular-nums">
211
+ {slice.count.toLocaleString()} <span className="text-muted-foreground">· {Math.round(share)}%</span>
212
+ </span>
213
+ </li>
214
+ );
215
+ })}
216
+ </ul>
217
+ </div>
218
+ );
219
+ }