@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +991 -344
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -31
- package/templates/app/app/dashboard/account/password/page.tsx +22 -0
- package/templates/app/app/dashboard/account/security/page.tsx +34 -0
- package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
- package/templates/app/app/dashboard/actions.ts +246 -0
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +66 -39
- package/templates/app/app/dashboard/observability/page.tsx +175 -0
- package/templates/app/app/dashboard/page.tsx +108 -27
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
- package/templates/app/components/dashboard/import-dialog.tsx +540 -0
- package/templates/app/components/dashboard/inline-cell.tsx +165 -0
- package/templates/app/components/dashboard/local-time.tsx +77 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/{admin → dashboard}/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +200 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +48 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +55 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/dashboard.ts +165 -0
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +25 -2
- package/templates/app/migrations/meta/0000_snapshot.json +159 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/locks/yarn.lock +3548 -0
- package/templates/app/app/admin/actions.ts +0 -113
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/admin/admin-header.tsx +0 -78
- package/templates/app/components/admin/resource-nav.tsx +0 -120
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- package/templates/app/lib/admin.ts +0 -103
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /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 {
|
|
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 =
|
|
47
|
+
const basePath = resourcePath(resource);
|
|
37
48
|
const permissions = await adminPermissions(resource.name);
|
|
38
49
|
const params = toSearchParams(searchParams);
|
|
39
|
-
const store =
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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={
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
223
|
-
label={resource.label}
|
|
284
|
+
resource={resource}
|
|
224
285
|
id={id}
|
|
225
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
<
|
|
99
|
+
{record ? (
|
|
100
|
+
<DropdownMenuItem onSelect={() => setEditing(true)}>
|
|
76
101
|
<PencilIcon />
|
|
77
102
|
Edit
|
|
78
|
-
</
|
|
79
|
-
|
|
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
|
+
}
|