@zerotal/admin 1.0.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 (77) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/package.json +78 -0
  5. package/src/Cluster.ts +50 -0
  6. package/src/Panel.ts +288 -0
  7. package/src/PanelInstance.ts +644 -0
  8. package/src/Resource.ts +918 -0
  9. package/src/actions/Action.ts +607 -0
  10. package/src/actions/ImportRecordsJob.ts +108 -0
  11. package/src/actions/csv.ts +123 -0
  12. package/src/actions/index.ts +39 -0
  13. package/src/actions/render.tsx +181 -0
  14. package/src/actions/transfer.ts +307 -0
  15. package/src/actions/xlsx.ts +304 -0
  16. package/src/auth/AuthLayout.tsx +34 -0
  17. package/src/auth/index.ts +13 -0
  18. package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
  19. package/src/auth/pages/LoginPage.tsx +121 -0
  20. package/src/auth/pages/ProfilePage.tsx +216 -0
  21. package/src/auth/pages/ResetPasswordPage.tsx +103 -0
  22. package/src/auth/pages/VerifyEmailPage.tsx +68 -0
  23. package/src/auth/register.ts +44 -0
  24. package/src/authRoles.ts +141 -0
  25. package/src/commands/MakeAdminResourceCommand.ts +181 -0
  26. package/src/config.ts +128 -0
  27. package/src/dashboardLayout.ts +101 -0
  28. package/src/databaseMedia.ts +148 -0
  29. package/src/databaseNotifications.ts +169 -0
  30. package/src/form/Field.ts +928 -0
  31. package/src/form/ResourceForm.ts +48 -0
  32. package/src/form/Section.ts +364 -0
  33. package/src/form/editors.ts +43 -0
  34. package/src/form/index.ts +59 -0
  35. package/src/history.ts +151 -0
  36. package/src/impersonation.ts +126 -0
  37. package/src/index.ts +380 -0
  38. package/src/infolist/Entry.ts +537 -0
  39. package/src/infolist/Section.ts +99 -0
  40. package/src/infolist/index.ts +38 -0
  41. package/src/media.ts +297 -0
  42. package/src/notifications.ts +65 -0
  43. package/src/pages/AdminPage.ts +100 -0
  44. package/src/pages/ConsolePage.tsx +324 -0
  45. package/src/pages/DashboardPage.tsx +264 -0
  46. package/src/pages/MediaPage.tsx +346 -0
  47. package/src/pages/NotificationsPage.tsx +155 -0
  48. package/src/pages/RecordViewPage.tsx +951 -0
  49. package/src/pages/ResourceFormPage.tsx +1856 -0
  50. package/src/pages/ResourceListPage.tsx +2552 -0
  51. package/src/pages/RolesPage.tsx +325 -0
  52. package/src/pages/SearchPage.tsx +169 -0
  53. package/src/plugin.ts +283 -0
  54. package/src/provider/AdminAbilityMiddleware.ts +25 -0
  55. package/src/provider/AdminGuardMiddleware.ts +29 -0
  56. package/src/provider/AdminProvider.ts +334 -0
  57. package/src/relations/RelationManager.ts +114 -0
  58. package/src/renderHooks.ts +86 -0
  59. package/src/roles.ts +175 -0
  60. package/src/savedViews.ts +79 -0
  61. package/src/support/ability.ts +73 -0
  62. package/src/support/authorize.ts +105 -0
  63. package/src/support/countCache.ts +37 -0
  64. package/src/support/hostPage.ts +30 -0
  65. package/src/table/Column.ts +353 -0
  66. package/src/table/Constraint.ts +238 -0
  67. package/src/table/Filter.ts +275 -0
  68. package/src/table/Group.ts +73 -0
  69. package/src/table/Tab.ts +77 -0
  70. package/src/testing.ts +121 -0
  71. package/src/theme.ts +70 -0
  72. package/src/ui/AdminLayout.tsx +355 -0
  73. package/src/ui/Breadcrumbs.tsx +84 -0
  74. package/src/ui/environmentIndicator.tsx +63 -0
  75. package/src/ui/icons.tsx +124 -0
  76. package/src/widgets/Widget.ts +251 -0
  77. package/src/widgets/render.tsx +154 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Import and export actions — moving records in and out of the panel as CSV.
3
+ *
4
+ * static headerActions() {
5
+ * return [createAction(), exportAction(), importAction()];
6
+ * }
7
+ *
8
+ * static bulkActions() {
9
+ * return [bulkExportAction(), bulkDeleteAction()];
10
+ * }
11
+ *
12
+ * An export reflects the list exactly as the user left it: the same search, the
13
+ * same filters, the same tab, the same sort. That is the whole point — someone
14
+ * who has narrowed a table to the twelve rows they care about expects twelve
15
+ * rows in the file, not the entire table.
16
+ */
17
+ import { Action } from "./Action.ts";
18
+ import type { ActionContext } from "./Action.ts";
19
+ import { toCsv, parseCsv, guessColumnMapping } from "./csv.ts";
20
+ import { toXlsx } from "./xlsx.ts";
21
+ import { flattenFields, fileUpload, select } from "../form/index.ts";
22
+ import type { Column } from "../table/Column.ts";
23
+ import type { ResourceClass } from "../Panel.ts";
24
+ import { DEFAULT_PANEL_ID } from "../Panel.ts";
25
+
26
+ /**
27
+ * How many rows one import may create.
28
+ *
29
+ * A synchronous import holds a WebSocket round-trip open, so a hundred-thousand
30
+ * row file would look like a hang and time out half-written. Files beyond this
31
+ * belong on a queue; the action says so rather than trying and failing.
32
+ */
33
+ export const IMPORT_ROW_LIMIT = 2000;
34
+
35
+ /** Columns that may leave the panel, in table order. */
36
+ function exportableColumns(resource: ResourceClass): Column[] {
37
+ return resource.columns().filter((c) => c._exportable);
38
+ }
39
+
40
+ /** What an export is written as. */
41
+ export type ExportFormat = "csv" | "xlsx";
42
+
43
+ const MIME: Record<ExportFormat, string> = {
44
+ csv: "text/csv",
45
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
46
+ };
47
+
48
+ /** `orders-2026-07-28.csv` — dated, so repeated exports don't overwrite each other. */
49
+ function exportFilename(resource: ResourceClass, format: ExportFormat): string {
50
+ const date = new Date().toISOString().slice(0, 10);
51
+ return `${resource.getSlug()}-${date}.${format}`;
52
+ }
53
+
54
+ function deliver(ctx: ActionContext, rows: Record<string, unknown>[], format: ExportFormat): void {
55
+ // `download` is present on every panel page; a custom host that lacks it gets
56
+ // told rather than silently doing nothing.
57
+ if (typeof ctx.page.download !== "function") {
58
+ ctx.page.flash("This page cannot deliver downloads.", "warning");
59
+ return;
60
+ }
61
+ const columns = exportableColumns(ctx.resource);
62
+ const body =
63
+ format === "xlsx"
64
+ ? toXlsx(rows, columns, { sheet: ctx.resource.getPluralLabel() })
65
+ : toCsv(rows, columns);
66
+ ctx.page.download(exportFilename(ctx.resource, format), body, MIME[format]);
67
+ }
68
+
69
+ /**
70
+ * Header action → download every row the current list query returns.
71
+ *
72
+ * Reads `ctx.listOptions`, which the list page fills with its live scope, and
73
+ * asks for all of it rather than the visible page.
74
+ */
75
+ export function exportAction(format: ExportFormat = "csv"): Action {
76
+ return new Action(format === "xlsx" ? "export-xlsx" : "export")
77
+ .label(format === "xlsx" ? "Export to Excel" : "Export")
78
+ .icon("download")
79
+ .run(async (ctx) => {
80
+ const { resource, listOptions } = ctx;
81
+ const rows = await resource.listAll(listOptions ?? {});
82
+ if (rows.length === 0) {
83
+ ctx.page.flash("Nothing to export.", "warning");
84
+ return;
85
+ }
86
+ deliver(ctx, rows, format);
87
+ })
88
+ .authorize((_rec, ctx) => ctx.resource.can("viewAny"));
89
+ }
90
+
91
+ /** Bulk action → download just the selected rows. */
92
+ export function bulkExportAction(format: ExportFormat = "csv"): Action {
93
+ return new Action(format === "xlsx" ? "bulk-export-xlsx" : "bulk-export")
94
+ .label(format === "xlsx" ? "Export to Excel" : "Export")
95
+ .icon("download")
96
+ .asBulk()
97
+ .run(async (ctx) => {
98
+ // Bulk dispatch hands over ids; the rows are loaded here rather than for
99
+ // every bulk action, most of which never need them.
100
+ const rows = ctx.records?.length
101
+ ? (ctx.records as Record<string, unknown>[])
102
+ : ((await Promise.all((ctx.ids ?? []).map((id) => ctx.resource.find(id)))).filter(
103
+ Boolean,
104
+ ) as Record<string, unknown>[]);
105
+
106
+ if (rows.length === 0) {
107
+ ctx.page.flash("Nothing to export.", "warning");
108
+ return;
109
+ }
110
+ deliver(ctx, rows, format);
111
+ })
112
+ .authorize((_rec, ctx) => ctx.resource.can("viewAny"));
113
+ }
114
+
115
+ /** The outcome of an import, as reported back to the user. */
116
+ export interface ImportResult {
117
+ created: number;
118
+ /** One message per rejected row, already prefixed with its line number. */
119
+ failures: string[];
120
+ }
121
+
122
+ /**
123
+ * Turn CSV text into records on `resource`.
124
+ *
125
+ * Every row is validated through the resource's own form fields, so an import
126
+ * cannot write anything a human couldn't have typed into the create form. A row
127
+ * that fails is reported and skipped — one bad line out of five hundred should
128
+ * not discard the other four hundred and ninety-nine.
129
+ */
130
+ export async function importCsv(
131
+ resource: ResourceClass,
132
+ csv: string,
133
+ /** Column index → field key. Omit to infer from the header row. */
134
+ mapping?: Record<number, string>,
135
+ /** `limit` overrides the row cap — a queued import has no request to hold open. */
136
+ options: { limit?: number } = {},
137
+ ): Promise<ImportResult> {
138
+ const rows = parseCsv(csv);
139
+ const result: ImportResult = { created: 0, failures: [] };
140
+ if (rows.length < 2) {
141
+ result.failures.push("The file has no data rows.");
142
+ return result;
143
+ }
144
+
145
+ const fields = flattenFields(resource.form());
146
+ const resolved =
147
+ mapping ??
148
+ guessColumnMapping(
149
+ rows[0]!,
150
+ fields.map((f) => ({ key: f._key, label: f.getLabel() })),
151
+ );
152
+ if (Object.keys(resolved).length === 0) {
153
+ result.failures.push("No column in the file matches a field on this resource.");
154
+ return result;
155
+ }
156
+
157
+ const byKey = new Map(fields.map((f) => [f._key, f]));
158
+ const dataRows = rows.slice(1);
159
+ const limit = options.limit ?? IMPORT_ROW_LIMIT;
160
+ if (dataRows.length > limit) {
161
+ result.failures.push(
162
+ `The file has ${dataRows.length} rows; ${limit} is the most one import can take. ` +
163
+ "Queue the import to lift that.",
164
+ );
165
+ return result;
166
+ }
167
+
168
+ for (const [i, row] of dataRows.entries()) {
169
+ // Line numbers are what the user sees in their spreadsheet: 1 is the header.
170
+ const line = i + 2;
171
+ // A row that is entirely blank is trailing whitespace, not a failure.
172
+ if (row.every((cell) => cell.trim() === "")) continue;
173
+
174
+ const data: Record<string, unknown> = {};
175
+ for (const [index, key] of Object.entries(resolved)) {
176
+ const cell = row[Number(index)];
177
+ if (cell === undefined) continue;
178
+ data[key] = cell;
179
+ }
180
+
181
+ const missing = fields
182
+ .filter((f) => f._required && !String(data[f._key] ?? "").trim())
183
+ .map((f) => f.getLabel());
184
+ if (missing.length > 0) {
185
+ result.failures.push(`Row ${line}: missing ${missing.join(", ")}.`);
186
+ continue;
187
+ }
188
+
189
+ try {
190
+ for (const [key, value] of Object.entries(data)) {
191
+ const field = byKey.get(key);
192
+ if (field) data[key] = await field.dehydrate(value);
193
+ }
194
+ const record = await resource.create(resource.mutateBeforeSave(data, "create"));
195
+ await resource.afterSave((record ?? data) as Record<string, unknown>, "create");
196
+ result.created++;
197
+ } catch (err) {
198
+ result.failures.push(`Row ${line}: ${err instanceof Error ? err.message : String(err)}`);
199
+ }
200
+ }
201
+
202
+ return result;
203
+ }
204
+
205
+ /** Field key a mapping select writes to, for CSV column `index`. */
206
+ export const MAPPING_FIELD_PREFIX = "map_";
207
+
208
+ /**
209
+ * Header action → upload a CSV and create a record per row.
210
+ *
211
+ * The modal has two steps in one screen: pick a file, and then — once its header
212
+ * row is readable — one select per column saying which field it feeds. The
213
+ * selects start on whatever the header names match, so a file the panel exported
214
+ * needs no adjustment, and a file from somewhere else needs only the columns that
215
+ * didn't line up.
216
+ */
217
+ export function importAction(options: { queue?: boolean } = {}): Action {
218
+ return new Action("import")
219
+ .label("Import")
220
+ .icon("upload")
221
+ .modalHeading("Import records")
222
+ .modalSubmitLabel("Import")
223
+ .formUsing((data, resource) => {
224
+ const file = fileUpload("file")
225
+ .label("CSV file")
226
+ .accept(".csv,text/csv")
227
+ .required()
228
+ .helperText("The first row must be a header.");
229
+
230
+ const csv = String(data["file"] ?? "");
231
+ if (!csv.trim()) return [file];
232
+
233
+ const rows = parseCsv(csv);
234
+ const headers = rows[0] ?? [];
235
+ if (headers.length === 0) return [file];
236
+
237
+ // Fields the resource can actually accept, plus the option to skip.
238
+ const target = flattenFields(resource.form());
239
+ const options: Record<string, string> = { "": "— skip this column —" };
240
+ for (const f of target) options[f._key] = f.getLabel();
241
+
242
+ return [
243
+ file,
244
+ ...headers.map((header, i) =>
245
+ select(`${MAPPING_FIELD_PREFIX}${i}`)
246
+ .label(header || `Column ${i + 1}`)
247
+ .options(options)
248
+ .helperText(`${rows.length - 1} row${rows.length === 2 ? "" : "s"} of data`),
249
+ ),
250
+ ];
251
+ })
252
+ .authorize((_rec, ctx) => ctx.resource.can("create"))
253
+ .run(async (ctx) => {
254
+ const csv = String(ctx.data?.["file"] ?? "");
255
+ if (!csv.trim()) {
256
+ ctx.page.flash("Choose a CSV file to import.", "warning");
257
+ return;
258
+ }
259
+
260
+ // Whatever the mapping selects hold wins; an untouched modal falls back to
261
+ // inference, which is the same thing the selects were seeded with.
262
+ const chosen: Record<number, string> = {};
263
+ for (const [key, value] of Object.entries(ctx.data ?? {})) {
264
+ if (!key.startsWith(MAPPING_FIELD_PREFIX)) continue;
265
+ const field = String(value ?? "");
266
+ if (field) chosen[Number(key.slice(MAPPING_FIELD_PREFIX.length))] = field;
267
+ }
268
+ const mapping = Object.keys(chosen).length > 0 ? chosen : undefined;
269
+
270
+ // Hand a big file to the queue when the app asked for it. A worker has no
271
+ // request to hold open, so the row cap doesn't apply there.
272
+ if (options.queue) {
273
+ const { dispatchImport } = await import("./ImportRecordsJob.ts");
274
+ const queued = await dispatchImport({
275
+ panelId: ctx.panelId ?? DEFAULT_PANEL_ID,
276
+ slug: ctx.slug,
277
+ csv,
278
+ mapping,
279
+ });
280
+ if (queued) {
281
+ ctx.page.flash("Import queued. Rows will appear as the worker gets through them.");
282
+ return;
283
+ }
284
+ // No queue configured — importing inline beats silently doing nothing.
285
+ }
286
+
287
+ const { created, failures } = await importCsv(ctx.resource, csv, mapping);
288
+
289
+ if (created === 0) {
290
+ ctx.page.flash(failures[0] ?? "Nothing was imported.", "warning");
291
+ return;
292
+ }
293
+ const label = created === 1 ? ctx.resource.getLabel() : ctx.resource.getPluralLabel();
294
+ if (failures.length === 0) {
295
+ ctx.page.flash(`Imported ${created} ${label.toLowerCase()}.`);
296
+ return;
297
+ }
298
+ // Lead with what worked, then the first few problems — a hundred identical
299
+ // failures in a flash message helps nobody.
300
+ const shown = failures.slice(0, 3).join(" ");
301
+ const rest = failures.length > 3 ? ` (+${failures.length - 3} more)` : "";
302
+ ctx.page.flash(
303
+ `Imported ${created} ${label.toLowerCase()}; ${failures.length} skipped. ${shown}${rest}`,
304
+ "warning",
305
+ );
306
+ });
307
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * A spreadsheet writer, for exports that need to open in Excel as a spreadsheet
3
+ * rather than as text.
4
+ *
5
+ * CSV is the better interchange format and stays the default. This exists for
6
+ * the case CSV genuinely cannot serve: a recipient who opens the file, sees
7
+ * `007` turned into `7` and a leading `=` treated as a formula, and reasonably
8
+ * calls the export broken. Writing real cell types fixes that at the source.
9
+ *
10
+ * An `.xlsx` is a ZIP of XML parts, so the whole format is built here from two
11
+ * small pieces — a ZIP writer and a sheet serialiser — rather than pulling in a
12
+ * spreadsheet library for what an export button needs. The scope is deliberately
13
+ * one sheet with a header row and typed cells: no formulas, charts or merges.
14
+ */
15
+ import type { Column } from "../table/Column.ts";
16
+
17
+ // ── ZIP ──────────────────────────────────────────────────────────────────────
18
+
19
+ /** Entries are stored uncompressed — valid ZIP, and an export is written once. */
20
+ const STORED = 0;
21
+
22
+ const CRC_TABLE = (() => {
23
+ const table = new Uint32Array(256);
24
+ for (let i = 0; i < 256; i++) {
25
+ let c = i;
26
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
27
+ table[i] = c >>> 0;
28
+ }
29
+ return table;
30
+ })();
31
+
32
+ function crc32(bytes: Uint8Array): number {
33
+ let c = 0xffffffff;
34
+ for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8);
35
+ return (c ^ 0xffffffff) >>> 0;
36
+ }
37
+
38
+ interface ZipEntry {
39
+ name: string;
40
+ data: Uint8Array;
41
+ }
42
+
43
+ /**
44
+ * Pack entries into a ZIP archive.
45
+ *
46
+ * Timestamps are fixed rather than taken from the clock, so exporting the same
47
+ * rows twice produces byte-identical files — which makes the output testable and
48
+ * keeps a checksum meaningful.
49
+ */
50
+ function zip(entries: ZipEntry[]): Uint8Array {
51
+ const chunks: Uint8Array[] = [];
52
+ const central: Uint8Array[] = [];
53
+ let offset = 0;
54
+
55
+ for (const entry of entries) {
56
+ const name = new TextEncoder().encode(entry.name);
57
+ const sum = crc32(entry.data);
58
+
59
+ const local = new Uint8Array(30 + name.length);
60
+ const lv = new DataView(local.buffer);
61
+ lv.setUint32(0, 0x04034b50, true); // local file header
62
+ lv.setUint16(4, 20, true); // version needed
63
+ lv.setUint16(6, 0, true); // flags
64
+ lv.setUint16(8, STORED, true);
65
+ lv.setUint16(10, 0, true); // time
66
+ lv.setUint16(12, 0x0021, true); // date — 1980-01-01, the ZIP epoch
67
+ lv.setUint32(14, sum, true);
68
+ lv.setUint32(18, entry.data.length, true);
69
+ lv.setUint32(22, entry.data.length, true);
70
+ lv.setUint16(26, name.length, true);
71
+ lv.setUint16(28, 0, true); // extra field length
72
+ local.set(name, 30);
73
+
74
+ chunks.push(local, entry.data);
75
+
76
+ const dir = new Uint8Array(46 + name.length);
77
+ const dv = new DataView(dir.buffer);
78
+ dv.setUint32(0, 0x02014b50, true); // central directory header
79
+ dv.setUint16(4, 20, true); // version made by
80
+ dv.setUint16(6, 20, true); // version needed
81
+ dv.setUint16(8, 0, true);
82
+ dv.setUint16(10, STORED, true);
83
+ dv.setUint16(12, 0, true);
84
+ dv.setUint16(14, 0x0021, true);
85
+ dv.setUint32(16, sum, true);
86
+ dv.setUint32(20, entry.data.length, true);
87
+ dv.setUint32(24, entry.data.length, true);
88
+ dv.setUint16(28, name.length, true);
89
+ dv.setUint32(42, offset, true); // offset of the local header
90
+ dir.set(name, 46);
91
+ central.push(dir);
92
+
93
+ offset += local.length + entry.data.length;
94
+ }
95
+
96
+ const centralSize = central.reduce((n, c) => n + c.length, 0);
97
+ const end = new Uint8Array(22);
98
+ const ev = new DataView(end.buffer);
99
+ ev.setUint32(0, 0x06054b50, true); // end of central directory
100
+ ev.setUint16(8, entries.length, true);
101
+ ev.setUint16(10, entries.length, true);
102
+ ev.setUint32(12, centralSize, true);
103
+ ev.setUint32(16, offset, true);
104
+
105
+ const all = [...chunks, ...central, end];
106
+ const out = new Uint8Array(all.reduce((n, c) => n + c.length, 0));
107
+ let at = 0;
108
+ for (const chunk of all) {
109
+ out.set(chunk, at);
110
+ at += chunk.length;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // ── Sheet ────────────────────────────────────────────────────────────────────
116
+
117
+ const utf8 = (text: string): Uint8Array => new TextEncoder().encode(text);
118
+
119
+ /**
120
+ * Whether a character is one XML will not accept.
121
+ *
122
+ * Tab, newline and carriage return are legal; the rest of the C0 range is not,
123
+ * and a single one of them makes the whole file unopenable. Checked by code
124
+ * point rather than by a regular expression, because a regex literal holding
125
+ * raw control characters is unreadable and easy to mangle.
126
+ */
127
+ function isIllegalXmlChar(code: number): boolean {
128
+ return code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d;
129
+ }
130
+
131
+ function escapeXml(text: string): string {
132
+ let out = "";
133
+ for (const char of text) {
134
+ const code = char.codePointAt(0) ?? 0;
135
+ if (isIllegalXmlChar(code)) continue;
136
+ out +=
137
+ char === "&"
138
+ ? "&amp;"
139
+ : char === "<"
140
+ ? "&lt;"
141
+ : char === ">"
142
+ ? "&gt;"
143
+ : char === '"'
144
+ ? "&quot;"
145
+ : char;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** `0 → A`, `26 → AA`. */
151
+ function columnLetter(index: number): string {
152
+ let out = "";
153
+ let n = index;
154
+ do {
155
+ out = String.fromCharCode(65 + (n % 26)) + out;
156
+ n = Math.floor(n / 26) - 1;
157
+ } while (n >= 0);
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Days since 1899-12-30, the epoch spreadsheets count dates from.
163
+ *
164
+ * The two-day offset from 1900-01-01 is the usual one: the format deliberately
165
+ * repeats a bug that treated 1900 as a leap year, and every reader expects it.
166
+ */
167
+ function excelSerial(date: Date): number {
168
+ return date.getTime() / 86_400_000 + 25_569;
169
+ }
170
+
171
+ type CellValue = string | number | boolean | Date | null;
172
+
173
+ function cellXml(ref: string, value: CellValue): string {
174
+ if (value == null || value === "") return "";
175
+ if (typeof value === "number") {
176
+ return Number.isFinite(value) ? `<c r="${ref}"><v>${value}</v></c>` : "";
177
+ }
178
+ if (typeof value === "boolean") return `<c r="${ref}" t="b"><v>${value ? 1 : 0}</v></c>`;
179
+ if (value instanceof Date) {
180
+ return `<c r="${ref}" s="2"><v>${excelSerial(value)}</v></c>`;
181
+ }
182
+ // Inline rather than via a shared-string table: one pass, no second index to
183
+ // keep consistent, and the size difference does not matter for an export.
184
+ return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(value)}</t></is></c>`;
185
+ }
186
+
187
+ /**
188
+ * Read a cell's value with its type intact.
189
+ *
190
+ * A column's rendered text is the fallback, not the first choice: exporting
191
+ * "R1,299.00" as a string gives a spreadsheet nothing to sum.
192
+ */
193
+ function cellValue(column: Column, row: Record<string, unknown>): CellValue {
194
+ const raw = column.raw(row);
195
+ if (raw == null) return null;
196
+ if (typeof raw === "number" || typeof raw === "boolean") return raw;
197
+ if (typeof raw === "bigint") return Number(raw);
198
+ if (raw instanceof Date) return raw;
199
+ if (typeof raw === "string") {
200
+ // A date column arrives as an ISO string from most drivers; keeping it a
201
+ // date is what lets the recipient sort and filter by it.
202
+ if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})/.test(raw)) {
203
+ const parsed = new Date(raw);
204
+ if (!Number.isNaN(parsed.getTime())) return parsed;
205
+ }
206
+ return raw;
207
+ }
208
+ return column.cell(row).text;
209
+ }
210
+
211
+ const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
212
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
213
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
214
+ <Default Extension="xml" ContentType="application/xml"/>
215
+ <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
216
+ <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
217
+ <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
218
+ </Types>`;
219
+
220
+ const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
221
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
222
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
223
+ </Relationships>`;
224
+
225
+ const WORKBOOK_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
226
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
227
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
228
+ <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
229
+ </Relationships>`;
230
+
231
+ // Three styles: default, bold (the header row), and a date format.
232
+ const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
233
+ <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
234
+ <numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy-mm-dd hh:mm"/></numFmts>
235
+ <fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
236
+ <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
237
+ <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
238
+ <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
239
+ <cellXfs count="3">
240
+ <xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
241
+ <xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>
242
+ <xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
243
+ </cellXfs>
244
+ <cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
245
+ </styleSheet>`;
246
+
247
+ /** Sheet names may not carry these, and may not exceed 31 characters. */
248
+ function sheetName(name: string): string {
249
+ const cleaned = name.replace(/[\\/?*[\]:]/g, " ").trim();
250
+ return escapeXml(cleaned.slice(0, 31) || "Sheet1");
251
+ }
252
+
253
+ /**
254
+ * Build an `.xlsx` workbook: one sheet, a bold header row from the column
255
+ * labels, and one row per record with values typed rather than stringified.
256
+ *
257
+ * The header row is frozen and an auto-filter is set over the used range, since
258
+ * an exported table is nearly always going to be sorted or filtered on arrival.
259
+ */
260
+ export function toXlsx(
261
+ rows: Record<string, unknown>[],
262
+ columns: Column[],
263
+ options: { sheet?: string } = {},
264
+ ): Uint8Array {
265
+ const header = columns
266
+ .map((c, i) => {
267
+ const ref = `${columnLetter(i)}1`;
268
+ return `<c r="${ref}" t="inlineStr" s="1"><is><t xml:space="preserve">${escapeXml(c.getLabel())}</t></is></c>`;
269
+ })
270
+ .join("");
271
+
272
+ const body = rows
273
+ .map((row, r) => {
274
+ const cells = columns
275
+ .map((c, i) => cellXml(`${columnLetter(i)}${r + 2}`, cellValue(c, row)))
276
+ .join("");
277
+ return `<row r="${r + 2}">${cells}</row>`;
278
+ })
279
+ .join("");
280
+
281
+ const lastColumn = columnLetter(Math.max(0, columns.length - 1));
282
+ const lastRow = rows.length + 1;
283
+ const sheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
284
+ <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
285
+ <sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>
286
+ <sheetData><row r="1">${header}</row>${body}</sheetData>
287
+ <autoFilter ref="A1:${lastColumn}${lastRow}"/>
288
+ </worksheet>`;
289
+
290
+ const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
291
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
292
+ <sheets><sheet name="${sheetName(options.sheet ?? "Sheet1")}" sheetId="1" r:id="rId1"/></sheets>
293
+ </workbook>`;
294
+
295
+ return zip([
296
+ // `[Content_Types].xml` must come first — readers look for it at the front.
297
+ { name: "[Content_Types].xml", data: utf8(CONTENT_TYPES) },
298
+ { name: "_rels/.rels", data: utf8(ROOT_RELS) },
299
+ { name: "xl/workbook.xml", data: utf8(workbook) },
300
+ { name: "xl/_rels/workbook.xml.rels", data: utf8(WORKBOOK_RELS) },
301
+ { name: "xl/styles.xml", data: utf8(STYLES) },
302
+ { name: "xl/worksheets/sheet1.xml", data: utf8(sheet) },
303
+ ]);
304
+ }
@@ -0,0 +1,34 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Minimal centered layout for the guest auth screens (login / forgot / reset).
3
+ // Shares the admin theme head (dark/light + tokens) but not the sidebar shell.
4
+
5
+ import { Layout } from "@zerotal/flow";
6
+ import type { HtmlNode } from "@zerotal/flow";
7
+ import { Panel } from "../Panel.ts";
8
+ import { adminHead } from "../theme.ts";
9
+
10
+ export class AuthLayout extends Layout {
11
+ static override get head(): string {
12
+ const cfg = Panel.config();
13
+ return adminHead(cfg.brand ?? "Admin", cfg.theme);
14
+ }
15
+
16
+ render(slot: HtmlNode): HtmlNode {
17
+ const cfg = Panel.config();
18
+ return (
19
+ <div class="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12 text-foreground">
20
+ <div class="w-full max-w-sm">
21
+ <div class="mb-6 flex flex-col items-center gap-2 text-center">
22
+ <div class="flex h-11 w-11 items-center justify-center rounded-xl bg-primary text-lg font-bold text-primary-foreground shadow-sm">
23
+ {cfg.brand.slice(0, 1).toUpperCase()}
24
+ </div>
25
+ <div class="text-lg font-semibold tracking-tight">{cfg.auth?.heading ?? cfg.brand}</div>
26
+ </div>
27
+ <div class="rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-sm sm:p-7">
28
+ {slot}
29
+ </div>
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@zerotal/admin/auth` — the opt-in auth screens. Kept on a separate subpath so
3
+ * the `@zerotal/auth` dependency these modules pull in stays optional for apps
4
+ * that don't use the built-in auth pages. Enable them with
5
+ * `Panel.auth({ enabled: true, ... })`; `AdminProvider` mounts the routes.
6
+ */
7
+ export { AuthLayout } from "./AuthLayout.tsx";
8
+ export { LoginPage } from "./pages/LoginPage.tsx";
9
+ export { ProfilePage } from "./pages/ProfilePage.tsx";
10
+ export { ForgotPasswordPage } from "./pages/ForgotPasswordPage.tsx";
11
+ export { ResetPasswordPage } from "./pages/ResetPasswordPage.tsx";
12
+ export { VerifyEmailPage } from "./pages/VerifyEmailPage.tsx";
13
+ export { registerAuthRoutes } from "./register.ts";