@hearthlog/shell 0.0.0-stage → 0.1.1

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 (80) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/app.template.html +184 -0
  4. package/src/components/ai/DecisionReviewSheet.svelte +226 -0
  5. package/src/components/ai/SuggestButton.svelte +23 -0
  6. package/src/components/ai/SuggestionChip.svelte +13 -0
  7. package/src/components/ai/SuggestionMark.svelte +15 -0
  8. package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
  9. package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
  10. package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
  11. package/src/components/calendar/source-icons.ts +19 -0
  12. package/src/components/data-table/DataTableValue.svelte +14 -0
  13. package/src/components/data-table/EntityActionsCell.svelte +36 -0
  14. package/src/components/data-table/EntityRefsCell.svelte +58 -0
  15. package/src/components/data-table/EntityValue.svelte +42 -0
  16. package/src/components/data-table/entity-columns.ts +87 -0
  17. package/src/components/data-table/sqlite-columns.ts +42 -0
  18. package/src/components/entities/CalendarSourceFields.svelte +149 -0
  19. package/src/components/history/ChangeRecord.svelte +30 -0
  20. package/src/components/history/ForkComposer.svelte +174 -0
  21. package/src/components/history/HistoryScrubber.svelte +245 -0
  22. package/src/components/history/LogHistory.svelte +334 -0
  23. package/src/components/history/LogMap.svelte +293 -0
  24. package/src/components/history/LogTree.svelte +124 -0
  25. package/src/components/layout/AccountMenu.svelte +111 -0
  26. package/src/components/layout/AppearanceOptions.svelte +78 -0
  27. package/src/components/layout/BrandWordmark.svelte +12 -0
  28. package/src/components/layout/ErrorToast.svelte +34 -0
  29. package/src/components/layout/LogCreateDrawer.svelte +111 -0
  30. package/src/components/layout/LogPanel.svelte +225 -0
  31. package/src/components/layout/LogStatusChip.svelte +137 -0
  32. package/src/components/layout/MobilePageAction.svelte +44 -0
  33. package/src/components/layout/MovedBanner.svelte +51 -0
  34. package/src/components/layout/NavBar.svelte +603 -0
  35. package/src/components/layout/NavFlyout.svelte +132 -0
  36. package/src/components/layout/NavItem.svelte +36 -0
  37. package/src/components/layout/NavMoreDialog.svelte +179 -0
  38. package/src/components/layout/PageDataStatus.svelte +36 -0
  39. package/src/components/layout/PhoneGlance.svelte +96 -0
  40. package/src/components/layout/SidebarResizer.svelte +122 -0
  41. package/src/components/layout/SyncIndicator.svelte +24 -0
  42. package/src/components/layout/feature-icons.ts +24 -0
  43. package/src/components/layout/nav-indicator.ts +128 -0
  44. package/src/components/layout/sidebar-size.ts +31 -0
  45. package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
  46. package/src/components/sync/SyncStateBadge.svelte +17 -0
  47. package/src/pages/calendar/Page.svelte +1341 -0
  48. package/src/pages/calendar/load.ts +77 -0
  49. package/src/pages/developer/DatabaseBrowser.svelte +325 -0
  50. package/src/pages/developer/Event.svelte +51 -0
  51. package/src/pages/developer/EventLogManager.svelte +268 -0
  52. package/src/pages/developer/IndexedDbStats.svelte +93 -0
  53. package/src/pages/developer/LedgerAdmin.svelte +877 -0
  54. package/src/pages/developer/LocalSqliteStats.svelte +56 -0
  55. package/src/pages/developer/Page.svelte +590 -0
  56. package/src/pages/developer/PendingWorkImport.svelte +49 -0
  57. package/src/pages/developer/PerformanceDashboard.svelte +262 -0
  58. package/src/pages/developer/StackInfo.svelte +117 -0
  59. package/src/pages/developer/SyncLog.svelte +188 -0
  60. package/src/pages/developer/load.ts +28 -0
  61. package/src/pages/entities/Page.svelte +1069 -0
  62. package/src/pages/entities/load.ts +18 -0
  63. package/src/pages/entity/Page.svelte +655 -0
  64. package/src/pages/entity/load.ts +79 -0
  65. package/src/pages/error/ErrorPage.svelte +147 -0
  66. package/src/pages/layout/Layout.svelte +597 -0
  67. package/src/pages/layout/load.ts +28 -0
  68. package/src/pages/redirects/history.ts +20 -0
  69. package/src/pages/redirects/start.ts +11 -0
  70. package/src/pages/redirects/today.ts +9 -0
  71. package/src/service-worker/index.ts +108 -0
  72. package/src/service-worker/offline-bundle.ts +116 -0
  73. package/src/service-worker/virtual.d.ts +3 -0
  74. package/src/styles/index.css +11 -0
  75. package/src/styles/shell.css +296 -0
  76. package/static/_headers +4 -0
  77. package/static/favicon.ico +0 -0
  78. package/static/offline.html +44 -0
  79. package/static/replay/sw.js +2 -0
  80. package/README.md +0 -3
@@ -0,0 +1,18 @@
1
+ import { deferPage } from '@hearthlog/runtime/runtime/page-data.svelte.js';
2
+ import type { LoadEvent } from '@sveltejs/kit';
3
+ type PageLoad = (event: LoadEvent) => unknown;
4
+ import { queryDatabase } from '@hearthlog/runtime/db/read-repositories.js';
5
+
6
+ const loadData = async ({ depends }: Parameters<PageLoad>[0]) => {
7
+
8
+ const entityTypes = await queryDatabase((repositories) =>
9
+ repositories.entities.getEntityCatalog());
10
+ return {
11
+ entityTypes,
12
+ };
13
+ };
14
+
15
+ export const load = ((event) => {
16
+ event.depends('app:entity-types');
17
+ return deferPage(event, { entityTypes: [] } as Awaited<ReturnType<typeof loadData>>, () => loadData(event));
18
+ }) satisfies PageLoad;
@@ -0,0 +1,655 @@
1
+ <script lang="ts">
2
+ import DataValue from "@hearthlog/ui/components/data/DataValue.svelte";
3
+ import DataTable from "@hearthlog/ui/components/data-table/DataTable.svelte";
4
+ import DataTablePagination from "@hearthlog/ui/components/data-table/DataTablePagination.svelte";
5
+ import {
6
+ toEntityTableColumns,
7
+ type DataTableEntityColumn,
8
+ } from "@hearthlog/shell/components/data-table/entity-columns.js";
9
+ import { providePageResource } from "@hearthlog/runtime/runtime/page-data.svelte.js";
10
+ import PageDataStatus from "@hearthlog/shell/components/layout/PageDataStatus.svelte";
11
+ import { executeLocalCommand } from "@hearthlog/runtime/commands/index.js";
12
+ import { goto} from "$app/navigation";
13
+ import { refreshPageData as invalidate } from "@hearthlog/runtime/runtime/refresh.js";
14
+ import { page } from "$app/state";
15
+ import type { EntityTypeRecord } from "@hearthlog/runtime/db/dynamic-ddl.js";
16
+ import type { DynamicFieldDefinition } from "@hearthlog/runtime/event/meta-entity-types.js";
17
+ import type { ReferencedByResult } from "@hearthlog/runtime/event/reference-lookup.js";
18
+ import CommandConfirmDialog from "@hearthlog/ui/components/commands/CommandConfirmDialog.svelte";
19
+ import CommandDrawer from "@hearthlog/ui/components/commands/CommandDrawer.svelte";
20
+ import CommandOnly from "@hearthlog/ui/components/commands/CommandOnly.svelte";
21
+ import EmptyState from "@hearthlog/ui/components/EmptyState.svelte";
22
+ import PageHeader from "@hearthlog/ui/components/PageHeader.svelte";
23
+ import Button from "@hearthlog/ui/components/ui/Button.svelte";
24
+ import Checkbox from "@hearthlog/ui/components/ui/Checkbox.svelte";
25
+ import DateTimePicker from "@hearthlog/ui/components/ui/DateTimePicker.svelte";
26
+ import Field from "@hearthlog/ui/components/ui/Field.svelte";
27
+ import Input from "@hearthlog/ui/components/ui/Input.svelte";
28
+ import Select from "@hearthlog/ui/components/ui/Select.svelte";
29
+ import { untrack } from "svelte";
30
+ import { contextualHref } from "@hearthlog/runtime/runtime/view.js";
31
+ import IconChevronLeft from "~icons/mdi/chevron-left";
32
+ import IconMagnify from "~icons/mdi/magnify";
33
+ import IconPlus from "~icons/mdi/plus";
34
+
35
+ let { data: routeData } = $props();
36
+ const resource = $derived(routeData.pageResource);
37
+ const data = $derived(resource.data);
38
+ providePageResource(() => resource);
39
+ const entityHref = (name: string) => contextualHref(`/entities/${name}`);
40
+
41
+ let entityType = $derived(data.entityType as EntityTypeRecord);
42
+ let fields = $derived(data.fields as DynamicFieldDefinition[]);
43
+ let entities = $derived(data.entities as Record<string, unknown>[]);
44
+ let referenceOptions = $derived(
45
+ (data.referenceOptions ?? {}) as Record<
46
+ string,
47
+ Array<{ id: string; label: string }>
48
+ >,
49
+ );
50
+ let referencedBy = $derived(
51
+ (data.referencedBy ?? {}) as Record<string, ReferencedByResult>,
52
+ );
53
+ let totalRows = $derived((data.totalRows ?? 0) as number);
54
+ let totalPages = $derived((data.totalPages ?? 1) as number);
55
+
56
+ // Form state
57
+ let isCreating = $state(false);
58
+ let isEditing = $state<string | null>(null);
59
+ let formData = $state<Record<string, unknown>>({});
60
+
61
+ // Search state
62
+ let searchQuery = $state("");
63
+
64
+ // Confirmation dialog state
65
+ let showDeleteConfirm = $state(false);
66
+ let itemToDeleteId = $state<string | null>(null);
67
+
68
+ // Sorting, pagination, search: single source of truth for header display
69
+ // AND query, updated synchronously on user intent. The old split (URL
70
+ // $state synced from data in an $effect) lagged a click by one load,
71
+ // resetting TanStack headers to unsorted for a frame — the white flash.
72
+ let sortState = $state<{ id: string | null; desc: boolean }>({
73
+ id: null,
74
+ desc: false,
75
+ });
76
+
77
+ // Pagination state
78
+ let currentPage = $state(1);
79
+ let pageSize = $state(20);
80
+ let lastDataStateKey = "";
81
+
82
+ // Stale-while-revalidate: a sort/page/search navigation creates a fresh
83
+ // PageResource whose initial entities are []. Without a snapshot the table
84
+ // would flash skeleton/empty. viewSnapshot lags one load behind by design,
85
+ // mirroring DatabaseBrowser's viewPage. Declared before any derived that
86
+ // reads it to avoid TDZ access.
87
+ let viewSnapshot = $state<{
88
+ entityType: EntityTypeRecord;
89
+ entities: Record<string, unknown>[];
90
+ fields: DynamicFieldDefinition[];
91
+ referencedBy: Record<string, ReferencedByResult>;
92
+ referenceOptions: Record<
93
+ string,
94
+ Array<{ id: string; label: string }>
95
+ >;
96
+ totalRows: number;
97
+ totalPages: number;
98
+ } | null>(null);
99
+ $effect(() => {
100
+ if (resource.loading) return;
101
+ viewSnapshot = {
102
+ entityType,
103
+ entities,
104
+ fields,
105
+ referencedBy,
106
+ referenceOptions,
107
+ totalRows,
108
+ totalPages,
109
+ };
110
+ });
111
+ const isStale = $derived(resource.loading && viewSnapshot !== null);
112
+ const effectiveEntities = $derived(
113
+ isStale ? (viewSnapshot?.entities ?? []) : entities,
114
+ );
115
+ const effectiveFields = $derived(
116
+ isStale ? (viewSnapshot?.fields ?? []) : fields,
117
+ );
118
+ const effectiveReferencedBy = $derived(
119
+ isStale ? (viewSnapshot?.referencedBy ?? {}) : referencedBy,
120
+ );
121
+ const effectiveReferenceOptions = $derived(
122
+ isStale ? (viewSnapshot?.referenceOptions ?? {}) : referenceOptions,
123
+ );
124
+ const effectiveTotalRows = $derived(
125
+ isStale ? (viewSnapshot?.totalRows ?? 0) : totalRows,
126
+ );
127
+ const effectiveTotalPages = $derived(
128
+ isStale ? (viewSnapshot?.totalPages ?? 1) : totalPages,
129
+ );
130
+ const effectiveEntityType = $derived(
131
+ isStale ? (viewSnapshot?.entityType ?? entityType) : entityType,
132
+ );
133
+ let displayEntities = $derived(effectiveEntities);
134
+ let referenceLabels = $derived.by(() => {
135
+ const labels: Record<string, Record<string, string>> = {};
136
+ for (const [fieldName, options] of Object.entries(
137
+ effectiveReferenceOptions,
138
+ )) {
139
+ labels[fieldName] = Object.fromEntries(
140
+ options.map((option) => [String(option.id), option.label]),
141
+ );
142
+ }
143
+ return labels;
144
+ });
145
+ // Stable callbacks: read live reactive values lazily at render time via
146
+ // untracked peeks, so creating them never subscribes the column
147
+ // derivation below. Only the field signature drives rebuilds.
148
+ const entityCallbacks = {
149
+ getReferencedBy: (id: string) =>
150
+ untrack(() => effectiveReferencedBy)[id] ?? null,
151
+ getReferenceLabel: (fieldName: string, value: unknown) =>
152
+ untrack(() => referenceLabels)[fieldName]?.[
153
+ String(value ?? "")
154
+ ] ?? null,
155
+ entityHref,
156
+ onEdit: (row: Record<string, unknown>) => startEdit(row),
157
+ onDelete: (id: string) => void handleDelete(id),
158
+ };
159
+ // Memoize column defs by field signature. `fields` gets a fresh array
160
+ // identity on every loader run (map/JSON.parse) even when the schema is
161
+ // unchanged; rebuilding columns then makes TanStack tear down and remount
162
+ // every header/cell — the white flash on sort. Cache lives in plain
163
+ // (non-reactive) variables assigned inside $derived.by so re-reads stay
164
+ // cheap and never feed back into the reactive graph.
165
+ let cachedColumns: DataTableEntityColumn[] | null = null;
166
+ let cachedFieldsKey = "";
167
+ function entityFieldsKey(
168
+ fieldList: DynamicFieldDefinition[],
169
+ ): string {
170
+ return fieldList
171
+ .map((field) =>
172
+ [
173
+ field.name,
174
+ field.type,
175
+ field.required ? "req" : "",
176
+ field.referenceTo ?? "",
177
+ field.enum?.join(",") ?? "",
178
+ ].join(":"),
179
+ )
180
+ .join("\n");
181
+ }
182
+ let tableColumns = $derived.by(() => {
183
+ const key = entityFieldsKey(effectiveFields);
184
+ if (cachedColumns && key === cachedFieldsKey) {
185
+ return cachedColumns;
186
+ }
187
+ cachedFieldsKey = key;
188
+ cachedColumns = toEntityTableColumns(
189
+ effectiveFields,
190
+ entityCallbacks,
191
+ );
192
+ return cachedColumns;
193
+ });
194
+ let tableSorting = $derived(sortState);
195
+ let tablePagination = $derived({
196
+ pageIndex: currentPage - 1,
197
+ pageSize,
198
+ rowCount: effectiveTotalRows,
199
+ pageCount: effectiveTotalPages,
200
+ });
201
+ const ENTITY_PAGE_SIZES = [10, 20, 50, 100] as const;
202
+ let showingStart = $derived(
203
+ effectiveTotalRows === 0
204
+ ? 0
205
+ : (currentPage - 1) * pageSize + 1,
206
+ );
207
+ let showingEnd = $derived(
208
+ Math.min(currentPage * pageSize, effectiveTotalRows),
209
+ );
210
+
211
+ // Sync local UI state from the confirmed loader data. sortState is set
212
+ // synchronously in selectSorting (user intent), so only overwrite it when
213
+ // the loader actually confirms a *different* sort — never unconditionally,
214
+ // or the header resets to unsorted for a frame (white flash).
215
+ $effect(() => {
216
+ if (resource.loading) return;
217
+ const nextStateKey = JSON.stringify({
218
+ search: data.search ?? "",
219
+ sortField: data.sortField ?? null,
220
+ sortDirection: data.sortDirection ?? "asc",
221
+ currentPage: data.currentPage ?? 1,
222
+ pageSize: data.pageSize ?? 20,
223
+ });
224
+ if (nextStateKey === lastDataStateKey) return;
225
+ lastDataStateKey = nextStateKey;
226
+ searchQuery = (data.search ?? "") as string;
227
+ const confirmedSort = (data.sortField ?? null) as string | null;
228
+ const confirmedDesc = (data.sortDirection ?? "asc") === "desc";
229
+ // Loader-confirmed params are authoritative on navigation (back/forward,
230
+ // cross-links); keep confirmed + intent in sync instead of comparing
231
+ // against the intent-lagged snapshot key.
232
+ const confirmedKey = JSON.stringify({
233
+ id: confirmedSort,
234
+ desc: confirmedSort ? confirmedDesc : false,
235
+ });
236
+ if (JSON.stringify(sortState) !== confirmedKey) {
237
+ sortState = { id: confirmedSort, desc: confirmedDesc };
238
+ }
239
+ currentPage = (data.currentPage ?? 1) as number;
240
+ pageSize = (data.pageSize ?? 20) as number;
241
+ });
242
+
243
+ $effect(() => {
244
+ if (resource.loading) return;
245
+ const trimmedSearch = searchQuery.trim();
246
+ if (trimmedSearch === ((data.search ?? "") as string)) return;
247
+ const timeout = setTimeout(() => {
248
+ updateRouteQuery({
249
+ search: trimmedSearch || null,
250
+ page: null,
251
+ });
252
+ }, 250);
253
+ return () => clearTimeout(timeout);
254
+ });
255
+
256
+ function updateRouteQuery(
257
+ updates: Record<string, string | number | null>,
258
+ replaceState = true,
259
+ ) {
260
+ const nextUrl = new URL(page.url);
261
+ for (const [key, value] of Object.entries(updates)) {
262
+ if (value === null || value === "") {
263
+ nextUrl.searchParams.delete(key);
264
+ } else {
265
+ nextUrl.searchParams.set(key, String(value));
266
+ }
267
+ }
268
+ const nextQuery = nextUrl.searchParams.toString();
269
+ const nextPath = `${nextUrl.pathname}${nextQuery ? `?${nextQuery}` : ""}${nextUrl.hash}`;
270
+ const currentPath = `${page.url.pathname}${page.url.search}${page.url.hash}`;
271
+ if (nextPath === currentPath) return;
272
+ void goto(nextPath, {
273
+ reset: false,
274
+ replaceState,
275
+ });
276
+ }
277
+
278
+ function setPage(pageNumber: number) {
279
+ const nextPage = Math.min(
280
+ Math.max(1, pageNumber),
281
+ effectiveTotalPages,
282
+ );
283
+ currentPage = nextPage;
284
+ updateRouteQuery({ page: nextPage <= 1 ? null : nextPage });
285
+ }
286
+
287
+ function setPageSize(nextPageSize: number) {
288
+ pageSize = nextPageSize;
289
+ currentPage = 1;
290
+ updateRouteQuery({
291
+ pageSize: nextPageSize === 20 ? null : nextPageSize,
292
+ page: null,
293
+ });
294
+ }
295
+
296
+ function selectSorting(sorting: { id: string | null; desc: boolean }) {
297
+ const validColumn = sorting.id !== null &&
298
+ fields.some((field) => field.name === sorting.id);
299
+ sortState = validColumn ? sorting : { id: null, desc: false };
300
+ currentPage = 1;
301
+ updateRouteQuery({
302
+ sort: validColumn ? sorting.id : null,
303
+ direction: validColumn && sorting.desc ? "desc" : null,
304
+ page: null,
305
+ });
306
+ }
307
+
308
+ function resetForm() {
309
+ formData = {};
310
+ fields.forEach((f) => {
311
+ if (f.default !== undefined) {
312
+ formData[f.payloadKey] = f.default;
313
+ }
314
+ });
315
+ isCreating = false;
316
+ isEditing = null;
317
+ }
318
+
319
+ function startCreate() {
320
+ resetForm();
321
+ isCreating = true;
322
+ }
323
+
324
+ function startEdit(entity: Record<string, unknown>) {
325
+ formData = {};
326
+ fields.forEach((f) => {
327
+ formData[f.payloadKey] = entity[f.name];
328
+ });
329
+ isEditing = entity.id as string;
330
+ }
331
+
332
+ function getEventPrefix(): string {
333
+ return entityType.name.toUpperCase() + "_";
334
+ }
335
+
336
+ async function handleCreate() {
337
+ const prefix = getEventPrefix();
338
+ const entityId = crypto.randomUUID();
339
+
340
+ await executeLocalCommand({
341
+ id: crypto.randomUUID(),
342
+ type: `${prefix}ADDED`,
343
+ timestamp: Date.now(),
344
+ payload: {
345
+ id: entityId,
346
+ ...formData,
347
+ },
348
+ } as any);
349
+
350
+ await invalidate("app:entities");
351
+ resetForm();
352
+ }
353
+
354
+ async function handleUpdate() {
355
+ if (!isEditing) return;
356
+
357
+ const prefix = getEventPrefix();
358
+
359
+ await executeLocalCommand({
360
+ id: crypto.randomUUID(),
361
+ type: `${prefix}UPDATED`,
362
+ timestamp: Date.now(),
363
+ payload: {
364
+ id: isEditing,
365
+ ...formData,
366
+ },
367
+ } as any);
368
+
369
+ await invalidate("app:entities");
370
+ resetForm();
371
+ }
372
+
373
+ async function handleDelete(entityId: string) {
374
+ itemToDeleteId = entityId;
375
+ showDeleteConfirm = true;
376
+ }
377
+
378
+ async function executeDelete() {
379
+ if (!itemToDeleteId) return;
380
+ const entityId = itemToDeleteId;
381
+ showDeleteConfirm = false;
382
+ itemToDeleteId = null;
383
+
384
+ const prefix = getEventPrefix();
385
+
386
+ await executeLocalCommand({
387
+ id: crypto.randomUUID(),
388
+ type: `${prefix}REMOVED`,
389
+ timestamp: Date.now(),
390
+ payload: {
391
+ id: entityId,
392
+ },
393
+ } as any);
394
+
395
+ await invalidate("app:entities");
396
+ }
397
+
398
+ function getInputType(fieldType: string): string {
399
+ switch (fieldType) {
400
+ case "number":
401
+ return "number";
402
+ case "boolean":
403
+ return "checkbox";
404
+ case "datetime":
405
+ return "datetime-local";
406
+ default:
407
+ return "text";
408
+ }
409
+ }
410
+
411
+ function formatDatetimeForInput(value: unknown): string {
412
+ if (!value) return "";
413
+ const rawValue = String(value).trim();
414
+ const localDateTime = rawValue.match(
415
+ /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/,
416
+ );
417
+ if (localDateTime) {
418
+ return `${localDateTime[1]}T${localDateTime[2]}`;
419
+ }
420
+
421
+ try {
422
+ const date = new Date(value as string);
423
+ if (Number.isNaN(date.getTime())) return "";
424
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
425
+ } catch {
426
+ return "";
427
+ }
428
+ }
429
+ </script>
430
+
431
+ <svelte:head>
432
+ <title>{effectiveEntityType.display_name} | Entities | stor</title>
433
+ </svelte:head>
434
+
435
+ <PageDataStatus {resource} />
436
+
437
+
438
+ <div class="flex h-full flex-col overflow-hidden bg-[var(--stor-bg)]">
439
+ <div class="shrink-0 px-3 pt-4 pb-2">
440
+ <a
441
+ href={contextualHref("/entities")}
442
+ class="inline-flex items-center gap-1 text-xs font-medium text-[var(--stor-muted-text)] transition hover:text-[var(--stor-ink)]"
443
+ >
444
+ <IconChevronLeft class="h-3.5 w-3.5" /> Entity types
445
+ </a>
446
+ {#snippet recordCount()}
447
+ <DataValue>{effectiveTotalRows}</DataValue>
448
+ <DataValue>{effectiveTotalRows === 1 ? " record" : " records"}</DataValue>
449
+ {/snippet}
450
+ <PageHeader title={effectiveEntityType.display_name} context={recordCount} showTitle class="mt-1">
451
+ <label class="relative min-w-0">
452
+ <span class="sr-only">Search records</span>
453
+ <IconMagnify class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--stor-muted-text)]" />
454
+ <Input placeholder="Search records..." bind:value={searchQuery} class="pl-9 md:w-64" />
455
+ </label>
456
+
457
+ <CommandOnly>
458
+ <Button onclick={startCreate}>
459
+ <IconPlus class="h-4 w-4" />
460
+ <span class="hidden sm:inline">Add {effectiveEntityType.display_name}</span>
461
+ <span class="sm:hidden">Add</span>
462
+ </Button>
463
+ </CommandOnly>
464
+ </PageHeader>
465
+ </div>
466
+
467
+ <!-- Data Content -->
468
+ <div class="flex-1 overflow-auto p-3">
469
+ <div class="w-full">
470
+ <div
471
+ class="stor-panel overflow-hidden"
472
+ >
473
+ <div
474
+ class:opacity-60={isStale}
475
+ class:transition-opacity={isStale}
476
+ aria-busy={isStale || undefined}
477
+ >
478
+ {#snippet emptyAddRecord()}
479
+ <EmptyState
480
+ title={searchQuery.trim()
481
+ ? "No records found"
482
+ : "No records yet"}
483
+ description={searchQuery.trim()
484
+ ? `No ${effectiveEntityType.display_name.toLowerCase()} records match this search.`
485
+ : `Add the first ${effectiveEntityType.display_name.toLowerCase()} record to this collection.`}
486
+ >
487
+ {#if !searchQuery.trim()}
488
+ <CommandOnly>
489
+ <button
490
+ onclick={startCreate}
491
+ class="rounded-lg bg-[var(--stor-action)] px-4 py-2.5 text-sm font-semibold text-[var(--stor-action-text)] transition hover:bg-[var(--stor-action-hover)]"
492
+ >
493
+ Add {effectiveEntityType.display_name}
494
+ </button>
495
+ </CommandOnly>
496
+ {/if}
497
+ </EmptyState>
498
+ {/snippet}
499
+ <DataTable
500
+ columns={tableColumns}
501
+ data={displayEntities}
502
+ pagination={tablePagination}
503
+ sorting={tableSorting}
504
+ onSortingChange={selectSorting}
505
+ isLoading={resource.loading && !isStale}
506
+ emptyContent={emptyAddRecord}
507
+ testId="entity-table"
508
+ scrollTestId="entity-table-scroll"
509
+ />
510
+ </div>
511
+
512
+ <DataTablePagination
513
+ currentPage={currentPage}
514
+ totalPages={effectiveTotalPages}
515
+ totalRows={effectiveTotalRows}
516
+ pageSize={pageSize}
517
+ pageSizes={ENTITY_PAGE_SIZES}
518
+ showingStart={showingStart}
519
+ showingEnd={showingEnd}
520
+ onSelectPage={setPage}
521
+ onSelectPageSize={setPageSize}
522
+ />
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+
528
+ <CommandDrawer
529
+ show={!!(isCreating || isEditing)}
530
+ title={isEditing ? "Edit record" : `Add ${effectiveEntityType.display_name}`}
531
+ description={isEditing
532
+ ? "Update this record without leaving the collection."
533
+ : `Create a new ${effectiveEntityType.display_name.toLowerCase()} record.`}
534
+ onClose={resetForm}
535
+ >
536
+ <div class="space-y-5">
537
+ <div class="grid grid-cols-1 gap-4">
538
+ {#each effectiveFields as field}
539
+ {@const fieldId = `entity-field-${field.payloadKey}`}
540
+ <Field
541
+ label={field.name}
542
+ htmlFor={fieldId}
543
+ required={field.required}
544
+ >
545
+ {#if field.type === "boolean"}
546
+ <div
547
+ class="flex items-center rounded-lg border border-[var(--stor-border)] bg-[var(--stor-surface)] p-3"
548
+ >
549
+ <Checkbox
550
+ id={fieldId}
551
+ checked={Boolean(formData[field.payloadKey])}
552
+ onCheckedChange={(checked) =>
553
+ (formData[field.payloadKey] = checked)}
554
+ />
555
+ <span
556
+ class="ml-3 text-sm text-[var(--stor-muted-text)]"
557
+ >Active / Enabled</span
558
+ >
559
+ </div>
560
+ {:else if field.type === "reference" && effectiveReferenceOptions[field.name]}
561
+ <Select
562
+ id={fieldId}
563
+ name={fieldId}
564
+ value={String(formData[field.payloadKey] ?? "")}
565
+ onValueChange={(value: string) =>
566
+ (formData[field.payloadKey] = value)}
567
+ placeholder={`Select ${field.referenceTo}...`}
568
+ options={[
569
+ {
570
+ value: "",
571
+ label: `Select ${field.referenceTo}...`,
572
+ },
573
+ ...effectiveReferenceOptions[field.name].map((opt) => ({
574
+ value: String(opt.id),
575
+ label: opt.label,
576
+ })),
577
+ ]}
578
+ />
579
+ {:else if field.enum && field.enum.length > 0}
580
+ <Select
581
+ id={fieldId}
582
+ name={fieldId}
583
+ value={String(formData[field.payloadKey] ?? "")}
584
+ onValueChange={(value: string) =>
585
+ (formData[field.payloadKey] = value)}
586
+ placeholder="Select option..."
587
+ options={[
588
+ { value: "", label: "Select option..." },
589
+ ...field.enum.map((option) => ({
590
+ value: option,
591
+ label: option,
592
+ })),
593
+ ]}
594
+ />
595
+ {:else if field.type === "datetime"}
596
+ <DateTimePicker
597
+ id={fieldId}
598
+ value={formatDatetimeForInput(
599
+ formData[field.payloadKey],
600
+ )}
601
+ onValueChange={(value: string) =>
602
+ (formData[field.payloadKey] = value)}
603
+ placeholder={`Choose ${field.name.toLowerCase()}...`}
604
+ />
605
+ {:else}
606
+ <Input
607
+ id={fieldId}
608
+ type={getInputType(field.type)}
609
+ bind:value={formData[field.payloadKey]}
610
+ placeholder={`Enter ${field.name.toLowerCase()}...`}
611
+ />
612
+ {/if}
613
+ </Field>
614
+ {/each}
615
+ </div>
616
+
617
+
618
+ </div>
619
+ {#snippet footer()}
620
+ <Button
621
+ onclick={resetForm}
622
+ variant="secondary"
623
+ size="sm"
624
+ >
625
+ Cancel
626
+ </Button>
627
+ <Button
628
+ onclick={isEditing ? handleUpdate : handleCreate}
629
+ >
630
+ {isEditing ? "Save changes" : "Add record"}
631
+ </Button>
632
+ {/snippet}
633
+ </CommandDrawer>
634
+
635
+ <CommandConfirmDialog
636
+ show={showDeleteConfirm}
637
+ title="Delete Item"
638
+ message="Are you sure you want to delete this item? This action cannot be undone."
639
+ onConfirm={executeDelete}
640
+ onCancel={() => (showDeleteConfirm = false)}
641
+ />
642
+
643
+ <style>
644
+ /* Custom scrollbar */
645
+ ::-webkit-scrollbar {
646
+ width: 8px;
647
+ }
648
+ ::-webkit-scrollbar-track {
649
+ background: transparent;
650
+ }
651
+ ::-webkit-scrollbar-thumb {
652
+ background: #e7e5e4;
653
+ border-radius: 10px;
654
+ }
655
+ </style>