@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
@@ -1,5 +1,5 @@
1
1
  import Link from "next/link";
2
- import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
2
+ import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
3
3
  import { formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
4
  import { isSortable } from "@flaredev/core/server";
5
5
  import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -15,11 +15,22 @@ import {
15
15
  PaginationPrevious,
16
16
  } from "@/components/ui/pagination";
17
17
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
18
- import { adminPath, adminPermissions, adminResources, adminStore, requireAccess } from "@/lib/admin";
18
+ import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
19
+ import { site } from "@/lib/site";
19
20
  import { cn } from "@/lib/utils";
21
+ import { ColumnMenu } from "./column-menu";
22
+ import { ExportButton } from "./export-button";
23
+ import { isInlineEditable } from "./editable";
24
+ import { InlineCell } from "./inline-cell";
25
+ import type { RelationMeta } from "./fields/field-widget";
26
+ import { ImportDialog } from "./import-dialog";
27
+ import { LocalTime } from "./local-time";
20
28
  import { hrefWith, toSearchParams, type SearchParams } from "./query";
29
+ import { NewRecordButton } from "./resource-form-dialog";
21
30
  import { ResourceTableToolbar } from "./resource-table-toolbar";
31
+ import { SaveViewButton } from "./save-view-button";
22
32
  import { RowActions } from "./row-actions";
33
+ import { RowCheckbox, SelectAllCheckbox, SelectionBar, TableSelection } from "./table-selection";
23
34
 
24
35
  type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
25
36
 
@@ -33,10 +44,10 @@ const NUMERIC = new Set(["int", "float"]);
33
44
  */
34
45
  export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
35
46
  await requireAccess(resource, "read");
36
- const basePath = adminPath(resource);
47
+ const basePath = resourcePath(resource);
37
48
  const permissions = await adminPermissions(resource.name);
38
49
  const params = toSearchParams(searchParams);
39
- const store = adminStore(resource.name);
50
+ const store = dashboardStore(resource.name);
40
51
 
41
52
  let result = await store.list(params);
42
53
  let ignoredQuery = false;
@@ -48,22 +59,38 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
48
59
  const { data: rows, meta } = result.data;
49
60
 
50
61
  const fields = storedFields(resource);
51
- const columns: Column[] = [
62
+ const allColumns: Column[] = [
52
63
  ...fields.filter(([, def]) => def.list !== false).map(([key, def]) => ({ key, label: def.label, def })),
53
- { key: "createdAt", label: "Created", def: { kind: "timestamp" } },
64
+ { key: "createdAt", label: "Created", def: { kind: "timestamp" as const } },
54
65
  ];
66
+ // ?columns=name,email narrows the table; anything unknown in it is ignored.
67
+ const chosen = (params.get("columns") ?? "").split(",").filter(Boolean);
68
+ const columns = chosen.length > 0 ? allColumns.filter((column) => chosen.includes(column.key)) : allColumns;
55
69
 
56
70
  // Resolve belongsTo ids to their related record titles, one query per relation.
57
- const byName = new Map(adminResources().map((r) => [r.name, r]));
71
+ const byName = new Map(allResources().map((r) => [r.name, r]));
58
72
  const titles: Record<string, Record<string, string>> = {};
59
73
  await Promise.all(
60
74
  columns.map(async ({ key, def }) => {
61
75
  if (def.kind !== "belongsTo") return;
62
76
  const ids = rows.map((row) => row[key]).filter((id): id is string => typeof id === "string");
63
- titles[key] = byName.has(def.target) ? await adminStore(def.target).titles(ids) : {};
77
+ titles[key] = byName.has(def.target) ? await dashboardStore(def.target).titles(ids) : {};
64
78
  }),
65
79
  );
66
80
 
81
+ // The dialog form needs the same relation metadata the form page builds.
82
+ const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
83
+ for (const [key, def] of fields) {
84
+ if (def.kind !== "belongsTo") continue;
85
+ const target = byName.get(def.target);
86
+ if (!target) continue;
87
+ relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
88
+ }
89
+
90
+ const modalForms = site.dashboard.forms === "modal";
91
+ const rowIds = rows.map((row) => String(row.id));
92
+ const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
93
+
67
94
  const sort = params.get("sort") ?? `${resource.defaultSort.direction === "desc" ? "-" : ""}${resource.defaultSort.field}`;
68
95
  const sortField = sort.replace(/^-/, "");
69
96
  const sortDescending = sort.startsWith("-");
@@ -72,17 +99,27 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
72
99
  const last = Math.min(meta.page * meta.perPage, meta.total);
73
100
 
74
101
  return (
75
- <div className="flex flex-col gap-4">
102
+ <TableSelection ids={rowIds}>
103
+ <div className="flex flex-col gap-4">
76
104
  <div className="flex flex-wrap items-center justify-between gap-2">
77
105
  <ResourceTableToolbar resource={resource} />
78
- {permissions.create && (
79
- <Button asChild>
80
- <Link href={adminPath(resource, "new")}>
81
- <PlusIcon data-icon="inline-start" />
82
- New {resource.label.toLowerCase()}
83
- </Link>
84
- </Button>
85
- )}
106
+ <div className="flex flex-wrap items-center gap-2">
107
+ <SaveViewButton resourceName={resource.name} label={resource.pluralLabel} />
108
+ <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
109
+ <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
110
+ {permissions.create && <ImportDialog resource={resource} />}
111
+ {permissions.create &&
112
+ (modalForms ? (
113
+ <NewRecordButton resource={resource} relations={relations} listHref={basePath} />
114
+ ) : (
115
+ <Button asChild>
116
+ <Link href={resourcePath(resource, "new")}>
117
+ <PlusIcon data-icon="inline-start" />
118
+ New {resource.label.toLowerCase()}
119
+ </Link>
120
+ </Button>
121
+ ))}
122
+ </div>
86
123
  </div>
87
124
 
88
125
  {ignoredQuery && (
@@ -106,7 +143,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
106
143
  </Button>
107
144
  ) : (
108
145
  <Button asChild>
109
- <Link href={adminPath(resource, "new")}>
146
+ <Link href={resourcePath(resource, "new")}>
110
147
  <PlusIcon data-icon="inline-start" />
111
148
  New {resource.label.toLowerCase()}
112
149
  </Link>
@@ -119,6 +156,9 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
119
156
  <Table>
120
157
  <TableHeader className="bg-muted">
121
158
  <TableRow className="hover:bg-muted">
159
+ <TableHead className="w-10">
160
+ <SelectAllCheckbox label={resource.pluralLabel.toLowerCase()} />
161
+ </TableHead>
122
162
  {columns.map((column) => {
123
163
  const sortable = column.key === "createdAt" || (column.def.kind !== "timestamp" && isSortable(column.def));
124
164
  const active = sortField === column.key;
@@ -151,10 +191,17 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
151
191
  const id = String(row.id);
152
192
  return (
153
193
  <TableRow key={id}>
194
+ <TableCell className="w-10">
195
+ <RowCheckbox id={id} label={rowLabel(resource, row)} />
196
+ </TableCell>
154
197
  {columns.map((column, index) => {
155
198
  const value = row[column.key];
156
199
  let content: React.ReactNode = formatValue(column.def, value);
157
- if (column.def.kind === "enum" && value != null) {
200
+ // Timestamps are formatted in the browser: the Worker's clock is UTC,
201
+ // so a date rendered here would be in nobody's timezone.
202
+ if ((column.def.kind === "timestamp" || column.def.kind === "datetime") && value != null) {
203
+ content = <LocalTime value={value as string | number | Date} />;
204
+ } else if (column.def.kind === "enum" && value != null) {
158
205
  const tone = statusTone(value);
159
206
  content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
160
207
  } else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
@@ -190,7 +237,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
190
237
  } else if (column.def.kind === "belongsTo" && typeof value === "string") {
191
238
  const target = byName.get(column.def.target);
192
239
  content = target ? (
193
- <Link href={adminPath(target, value, "edit")} className="hover:underline">
240
+ <Link href={resourcePath(target, value)} className="hover:underline">
194
241
  {titles[column.key]?.[value] ?? value}
195
242
  </Link>
196
243
  ) : (
@@ -198,11 +245,26 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
198
245
  );
199
246
  } else if (index === 0) {
200
247
  content = (
201
- <Link href={adminPath(resource, id, "edit")} className="font-medium hover:underline">
248
+ <Link href={resourcePath(resource, id)} className="font-medium hover:underline">
202
249
  {content}
203
250
  </Link>
204
251
  );
205
252
  }
253
+ // The first column is the link to the record; the rest of the simple
254
+ // ones can be changed where they are.
255
+ if (index !== 0 && permissions.update && column.def.kind !== "timestamp" && isInlineEditable(column.def)) {
256
+ content = (
257
+ <InlineCell
258
+ resourceName={resource.name}
259
+ id={id}
260
+ fieldKey={column.key}
261
+ def={column.def as StoredField & { label: string }}
262
+ value={value}
263
+ >
264
+ {content}
265
+ </InlineCell>
266
+ );
267
+ }
206
268
  return (
207
269
  <TableCell
208
270
  key={column.key}
@@ -219,10 +281,13 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
219
281
  })}
220
282
  <TableCell className="text-right">
221
283
  <RowActions
222
- resourceName={resource.name}
223
- label={resource.label}
284
+ resource={resource}
224
285
  id={id}
225
- editHref={adminPath(resource, id, "edit")}
286
+ record={modalForms ? row : undefined}
287
+ relations={relations}
288
+ listHref={basePath}
289
+ editHref={resourcePath(resource, id, "edit")}
290
+ detailHref={resourcePath(resource, id)}
226
291
  canUpdate={permissions.update}
227
292
  canDelete={permissions.delete}
228
293
  />
@@ -235,13 +300,22 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
235
300
  </div>
236
301
  )}
237
302
 
238
- {meta.total > 0 && (
303
+ {(meta.total > 0 || rows.length > 0) && (
239
304
  <div className="flex flex-col items-center justify-between gap-3 text-sm text-muted-foreground sm:flex-row">
240
305
  <p className="tabular-nums">
241
- {first}–{last} of {meta.total} {meta.total === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()}
306
+ {meta.exactTotal ? (
307
+ <>
308
+ {first}–{last} of {meta.total.toLocaleString()} {meta.total === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()}
309
+ </>
310
+ ) : (
311
+ <>
312
+ Showing {rows.length} of more than {meta.total.toLocaleString()} {resource.pluralLabel.toLowerCase()}
313
+ </>
314
+ )}
242
315
  {activeFilters > 0 && ` · ${activeFilters} filter${activeFilters === 1 ? "" : "s"} applied`}
243
316
  </p>
244
- {meta.totalPages > 1 && (
317
+
318
+ {meta.exactTotal && meta.totalPages > 1 && (
245
319
  <Pagination className="mx-0 w-auto">
246
320
  <PaginationContent>
247
321
  <PaginationItem>
@@ -268,12 +342,62 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
268
342
  </PaginationContent>
269
343
  </Pagination>
270
344
  )}
345
+
346
+ {/* Past the count limit, pages are walked with cursors: numbering them would mean
347
+ counting every row, and jumping to page 20,000 would mean reading the 500,000
348
+ rows before it. */}
349
+ {!meta.exactTotal && (
350
+ <div className="flex items-center gap-2">
351
+ <Button variant="outline" size="sm" asChild={Boolean(meta.prevCursor)} disabled={!meta.prevCursor}>
352
+ {meta.prevCursor ? (
353
+ <Link href={hrefWith(basePath, params, { cursor: meta.prevCursor, page: null })}>
354
+ <ChevronLeftIcon data-icon="inline-start" />
355
+ Previous
356
+ </Link>
357
+ ) : (
358
+ <span>
359
+ <ChevronLeftIcon data-icon="inline-start" />
360
+ Previous
361
+ </span>
362
+ )}
363
+ </Button>
364
+ <Button variant="outline" size="sm" asChild={Boolean(meta.nextCursor)} disabled={!meta.nextCursor}>
365
+ {meta.nextCursor ? (
366
+ <Link href={hrefWith(basePath, params, { cursor: meta.nextCursor, page: null })}>
367
+ Next
368
+ <ChevronRightIcon data-icon="inline-end" />
369
+ </Link>
370
+ ) : (
371
+ <span>
372
+ Next
373
+ <ChevronRightIcon data-icon="inline-end" />
374
+ </span>
375
+ )}
376
+ </Button>
377
+ </div>
378
+ )}
271
379
  </div>
272
380
  )}
273
- </div>
381
+
382
+ <SelectionBar
383
+ resourceName={resource.name}
384
+ label={resource.label}
385
+ pluralLabel={resource.pluralLabel}
386
+ canDelete={permissions.delete}
387
+ rows={rows}
388
+ columns={exportColumns}
389
+ />
390
+ </div>
391
+ </TableSelection>
274
392
  );
275
393
  }
276
394
 
395
+ /** What a row is called, for the checkbox's label. */
396
+ function rowLabel(resource: Resource, row: Record<string, unknown>): string {
397
+ const value = row[resource.titleField];
398
+ return typeof value === "string" && value ? value : String(row.id);
399
+ }
400
+
277
401
  /** Up to five page numbers centred on the current page. */
278
402
  function pageWindow(page: number, totalPages: number): number[] {
279
403
  const start = Math.max(1, Math.min(page - 2, totalPages - 4));
@@ -3,9 +3,10 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
- import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
6
+ import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
- import { deleteRecordAction } from "@/app/admin/actions";
8
+ import type { Resource } from "@flaredev/core";
9
+ import { deleteRecordAction } from "@/app/dashboard/actions";
9
10
  import {
10
11
  AlertDialog,
11
12
  AlertDialogCancel,
@@ -25,25 +26,40 @@ import {
25
26
  DropdownMenuTrigger,
26
27
  } from "@/components/ui/dropdown-menu";
27
28
  import { Spinner } from "@/components/ui/spinner";
29
+ import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
28
30
 
31
+ /**
32
+ * Edit and delete for one row. Edit opens a dialog when the row's record came with it
33
+ * (site.dashboard.forms is "modal"), and otherwise goes to the form page.
34
+ */
29
35
  export function RowActions({
30
- resourceName,
31
- label,
36
+ resource,
32
37
  id,
38
+ record,
39
+ relations = {},
40
+ listHref,
33
41
  editHref,
42
+ detailHref,
34
43
  canUpdate = true,
35
44
  canDelete = true,
36
45
  }: {
37
- resourceName: string;
38
- label: string;
46
+ resource: Resource;
39
47
  id: string;
48
+ /** The record itself, when the form opens in a dialog. */
49
+ record?: Record<string, unknown>;
50
+ relations?: FormRelations;
51
+ listHref: string;
40
52
  editHref: string;
53
+ /** The record's own page. */
54
+ detailHref: string;
41
55
  canUpdate?: boolean;
42
56
  canDelete?: boolean;
43
57
  }) {
44
58
  const router = useRouter();
45
59
  const [confirming, setConfirming] = useState(false);
60
+ const [editing, setEditing] = useState(false);
46
61
  const [pending, startTransition] = useTransition();
62
+ const { name: resourceName, label } = resource;
47
63
 
48
64
  function onDelete() {
49
65
  startTransition(async () => {
@@ -58,7 +74,7 @@ export function RowActions({
58
74
  });
59
75
  }
60
76
 
61
- if (!canUpdate && !canDelete) return null;
77
+
62
78
 
63
79
  return (
64
80
  <>
@@ -69,14 +85,30 @@ export function RowActions({
69
85
  </Button>
70
86
  </DropdownMenuTrigger>
71
87
  <DropdownMenuContent align="end">
88
+ <DropdownMenuGroup>
89
+ <DropdownMenuItem asChild>
90
+ <Link href={detailHref}>
91
+ <ExternalLinkIcon />
92
+ Open
93
+ </Link>
94
+ </DropdownMenuItem>
95
+ </DropdownMenuGroup>
96
+ <DropdownMenuSeparator />
72
97
  {canUpdate && (
73
98
  <DropdownMenuGroup>
74
- <DropdownMenuItem asChild>
75
- <Link href={editHref}>
99
+ {record ? (
100
+ <DropdownMenuItem onSelect={() => setEditing(true)}>
76
101
  <PencilIcon />
77
102
  Edit
78
- </Link>
79
- </DropdownMenuItem>
103
+ </DropdownMenuItem>
104
+ ) : (
105
+ <DropdownMenuItem asChild>
106
+ <Link href={editHref}>
107
+ <PencilIcon />
108
+ Edit
109
+ </Link>
110
+ </DropdownMenuItem>
111
+ )}
80
112
  </DropdownMenuGroup>
81
113
  )}
82
114
  {canUpdate && canDelete && <DropdownMenuSeparator />}
@@ -91,6 +123,19 @@ export function RowActions({
91
123
  </DropdownMenuContent>
92
124
  </DropdownMenu>
93
125
 
126
+ {record && canUpdate && (
127
+ <ResourceFormDialog
128
+ resource={resource}
129
+ relations={relations}
130
+ listHref={listHref}
131
+ mode="edit"
132
+ id={id}
133
+ record={record}
134
+ open={editing}
135
+ onOpenChange={setEditing}
136
+ />
137
+ )}
138
+
94
139
  <AlertDialog open={confirming} onOpenChange={setConfirming}>
95
140
  <AlertDialogContent>
96
141
  <AlertDialogHeader>
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { BookmarkPlusIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { saveViewAction } from "@/app/dashboard/actions";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
10
+ import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
11
+ import { Input } from "@/components/ui/input";
12
+ import { Spinner } from "@/components/ui/spinner";
13
+
14
+ /**
15
+ * Keep this search, these filters, this sort and these columns as a named view.
16
+ *
17
+ * Only offered once the table is actually filtered — saving "all of them" as a view is
18
+ * saving the list you're already on.
19
+ */
20
+ export function SaveViewButton({ resourceName, label }: { resourceName: string; label: string }) {
21
+ const params = useSearchParams();
22
+ const router = useRouter();
23
+ const [open, setOpen] = useState(false);
24
+ const [name, setName] = useState("");
25
+ const [pending, startTransition] = useTransition();
26
+
27
+ const query = new URLSearchParams(params.toString());
28
+ query.delete("page");
29
+ query.delete("cursor");
30
+ if ([...query.keys()].length === 0) return null;
31
+
32
+ const save = () => {
33
+ startTransition(async () => {
34
+ const result = await saveViewAction(resourceName, name, query.toString());
35
+ if (!result.ok) {
36
+ toast.error(result.error);
37
+ return;
38
+ }
39
+ setOpen(false);
40
+ setName("");
41
+ toast.success(`Saved "${name.trim()}" to the sidebar.`);
42
+ router.refresh();
43
+ });
44
+ };
45
+
46
+ return (
47
+ <>
48
+ <Button variant="outline" size="icon" aria-label="Save this view" onClick={() => setOpen(true)}>
49
+ <BookmarkPlusIcon />
50
+ </Button>
51
+
52
+ <Dialog open={open} onOpenChange={setOpen}>
53
+ <DialogContent className="sm:max-w-md">
54
+ <DialogHeader>
55
+ <DialogTitle>Save this view</DialogTitle>
56
+ <DialogDescription>It appears under {label.toLowerCase()} in the sidebar, for you.</DialogDescription>
57
+ </DialogHeader>
58
+ <Field>
59
+ <FieldLabel htmlFor="view-name">Name</FieldLabel>
60
+ <Input
61
+ id="view-name"
62
+ value={name}
63
+ onChange={(event) => setName(event.target.value)}
64
+ placeholder="Overdue invoices"
65
+ maxLength={60}
66
+ autoFocus
67
+ onKeyDown={(event) => {
68
+ if (event.key === "Enter" && name.trim()) save();
69
+ }}
70
+ />
71
+ <FieldDescription>Keeps the search, filters, sorting and columns you have now.</FieldDescription>
72
+ </Field>
73
+ <DialogFooter>
74
+ <Button variant="outline" onClick={() => setOpen(false)} disabled={pending}>
75
+ Cancel
76
+ </Button>
77
+ <Button onClick={save} disabled={pending || !name.trim()}>
78
+ {pending && <Spinner data-icon="inline-start" />}
79
+ Save view
80
+ </Button>
81
+ </DialogFooter>
82
+ </DialogContent>
83
+ </Dialog>
84
+ </>
85
+ );
86
+ }
@@ -0,0 +1,69 @@
1
+ import Link from "next/link";
2
+ import { ArrowRightIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export interface Stat {
7
+ label: string;
8
+ value: number | string;
9
+ /** Under the number: "12 this week", "3 need attention". */
10
+ hint?: string;
11
+ /** Change against the previous period, as a percentage. Up isn't always good — see `goodDirection`. */
12
+ change?: number;
13
+ goodDirection?: "up" | "down";
14
+ icon?: React.ComponentType<{ className?: string }>;
15
+ href?: string;
16
+ }
17
+
18
+ const format = (value: number | string) => (typeof value === "number" ? value.toLocaleString() : value);
19
+
20
+ /** One number, what it means, and where to go for the detail behind it. */
21
+ export function StatCard({ label, value, hint, change, goodDirection = "up", icon: Icon, href }: Stat) {
22
+ const rising = (change ?? 0) >= 0;
23
+ const good = rising === (goodDirection === "up");
24
+ const Trend = rising ? TrendingUpIcon : TrendingDownIcon;
25
+
26
+ const card = (
27
+ <Card className={cn("h-full gap-3", href && "transition-colors hover:border-primary/40")}>
28
+ <CardHeader className="pb-0">
29
+ <CardDescription className="flex items-center gap-2">
30
+ {Icon && <Icon className="size-4" />}
31
+ {label}
32
+ </CardDescription>
33
+ <CardTitle className="text-3xl font-semibold tabular-nums">{format(value)}</CardTitle>
34
+ </CardHeader>
35
+ <CardContent className="flex items-center justify-between gap-2">
36
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
37
+ {change !== undefined && (
38
+ <span className={cn("inline-flex items-center gap-1 font-medium", good ? "text-success" : "text-danger")}>
39
+ <Trend className="size-3.5" />
40
+ {Math.abs(change).toFixed(change % 1 === 0 ? 0 : 1)}%
41
+ </span>
42
+ )}
43
+ {hint && <span>{hint}</span>}
44
+ </div>
45
+ {href && <ArrowRightIcon className="size-4 text-muted-foreground" aria-hidden="true" />}
46
+ </CardContent>
47
+ </Card>
48
+ );
49
+
50
+ return href ? (
51
+ <Link href={href} className="rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
52
+ {card}
53
+ </Link>
54
+ ) : (
55
+ card
56
+ );
57
+ }
58
+
59
+ /** A row of stats that wraps: four across on a desktop, two on a tablet, one on a phone. */
60
+ export function StatCards({ stats }: { stats: Stat[] }) {
61
+ if (stats.length === 0) return null;
62
+ return (
63
+ <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
64
+ {stats.map((stat) => (
65
+ <StatCard key={stat.label} {...stat} />
66
+ ))}
67
+ </div>
68
+ );
69
+ }