@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,1069 @@
1
+ <script lang="ts">
2
+ import { providePageResource } from "@hearthlog/runtime/runtime/page-data.svelte.js";
3
+ import PageDataStatus from "@hearthlog/shell/components/layout/PageDataStatus.svelte";
4
+ import DataPlaceholder from "@hearthlog/ui/components/data/DataPlaceholder.svelte";
5
+ import DataValue from "@hearthlog/ui/components/data/DataValue.svelte";
6
+ import {
7
+ executeLocalCommand,
8
+ type LocalCommandResult,
9
+ } from "@hearthlog/runtime/commands/index.js";
10
+
11
+ import { refreshPageData as invalidate } from "@hearthlog/runtime/runtime/refresh.js";
12
+ import type { EntityTypeRecord } from "@hearthlog/runtime/db/dynamic-ddl.js";
13
+
14
+
15
+ interface EntityTypeWithStatic extends EntityTypeRecord {
16
+ is_static?: boolean;
17
+ }
18
+ import { generateExportSnippet } from "@hearthlog/runtime/event/code-export.js";
19
+ import type { DynamicFieldDefinition } from "@hearthlog/runtime/event/meta-entity-types.js";
20
+ import { fade, fly } from "svelte/transition";
21
+ import { onMount } from "svelte";
22
+ import CommandConfirmDialog from "@hearthlog/ui/components/commands/CommandConfirmDialog.svelte";
23
+ import CommandDrawer from "@hearthlog/ui/components/commands/CommandDrawer.svelte";
24
+ import CalendarSourceFields from "@hearthlog/shell/components/entities/CalendarSourceFields.svelte";
25
+ import { parseCalendarConfig } from "@hearthlog/contracts/calendar/config";
26
+ import {
27
+ calendarDraftError,
28
+ calendarFromDraft,
29
+ draftFromCalendar,
30
+ emptyCalendarDraft,
31
+ type CalendarSourceDraft,
32
+ } from "@hearthlog/runtime/calendar/draft.js";
33
+ import CommandOnly from "@hearthlog/ui/components/commands/CommandOnly.svelte";
34
+ import Modal from "@hearthlog/ui/components/Modal.svelte";
35
+ import EmptyState from "@hearthlog/ui/components/EmptyState.svelte";
36
+ import PageHeader from "@hearthlog/ui/components/PageHeader.svelte";
37
+ import Button from "@hearthlog/ui/components/ui/Button.svelte";
38
+ import Input from "@hearthlog/ui/components/ui/Input.svelte";
39
+ import Checkbox from "@hearthlog/ui/components/ui/Checkbox.svelte";
40
+ import { notify } from "@hearthlog/ui/state/notifications.svelte.js";
41
+ import { contextualHref } from "@hearthlog/runtime/runtime/view.js";
42
+ import { tenant } from "@hearthlog/contracts/tenant/config";
43
+ import IconSearch from "~icons/mdi/magnify";
44
+ import IconPlus from "~icons/mdi/plus";
45
+ import IconPencil from "~icons/mdi/pencil-outline";
46
+ import IconCode from "~icons/mdi/code-tags";
47
+ import IconTrash from "~icons/mdi/trash-can-outline";
48
+ import IconArrowRight from "~icons/mdi/arrow-right";
49
+
50
+ let { data: routeData } = $props();
51
+ const resource = $derived(routeData.pageResource);
52
+ const data = $derived(resource.data);
53
+ providePageResource(() => resource);
54
+ let entityTypes = $derived(data.entityTypes as EntityTypeWithStatic[]);
55
+ const entityHref = (name: string) => contextualHref(`/entities/${name}`);
56
+
57
+ function safeParse(json: string, name: string): any[] {
58
+ try {
59
+ return JSON.parse(json);
60
+ } catch (e) {
61
+ console.error(`[Entities] Failed to parse fields for ${name}:`, e);
62
+ return [];
63
+ }
64
+ }
65
+
66
+ onMount(() => {
67
+ // console.log("[Entities] onMount");
68
+ // return () => console.log("[Entities] onDestroy");
69
+ });
70
+
71
+ // Filter and Search
72
+ let searchQuery = $state("");
73
+ let filteredEntityTypes = $derived(
74
+ entityTypes.filter(
75
+ (et: EntityTypeRecord) =>
76
+ et.display_name
77
+ .toLowerCase()
78
+ .includes(searchQuery.toLowerCase()) ||
79
+ et.name.toLowerCase().includes(searchQuery.toLowerCase()),
80
+ ),
81
+ );
82
+
83
+ // Form state
84
+ let isCreating = $state(false);
85
+ let newName = $state("");
86
+ let newDisplayName = $state("");
87
+ let newFields = $state<DynamicFieldDefinition[]>([]);
88
+ let calendarDraft = $state<CalendarSourceDraft>(emptyCalendarDraft());
89
+ const calendarDraftInvalid = $derived(
90
+ calendarDraft.enabled &&
91
+ calendarDraftError(calendarDraft, newFields) !== null,
92
+ );
93
+
94
+ // Export modal state
95
+ let showExportModal = $state(false);
96
+ let exportCode = $state("");
97
+
98
+ // Confirmation dialog states
99
+ let showArchiveConfirm = $state(false);
100
+ let itemToArchive = $state<EntityTypeRecord | null>(null);
101
+ let showRedefineConfirm = $state(false);
102
+
103
+ // Field being edited
104
+ let editingFieldIndex = $state<number | null>(null);
105
+ let editingField = $state<DynamicFieldDefinition>({
106
+ name: "",
107
+ type: "string",
108
+ payloadKey: "",
109
+ });
110
+
111
+ function resetForm() {
112
+ newName = "";
113
+ newDisplayName = "";
114
+ newFields = [];
115
+ calendarDraft = emptyCalendarDraft();
116
+ isCreating = false;
117
+ editingFieldIndex = null;
118
+ }
119
+
120
+ function addField() {
121
+ if (!editingField.name) return;
122
+
123
+ const payloadKey =
124
+ editingField.payloadKey || toCamelCase(editingField.name);
125
+
126
+ const field: DynamicFieldDefinition = {
127
+ ...editingField,
128
+ name: toSnakeCase(editingField.name),
129
+ payloadKey,
130
+ };
131
+
132
+ if (editingFieldIndex !== null) {
133
+ newFields[editingFieldIndex] = field;
134
+ } else {
135
+ newFields = [...newFields, field];
136
+ }
137
+
138
+ editingField = { name: "", type: "string", payloadKey: "" };
139
+ editingFieldIndex = null;
140
+ }
141
+
142
+ function removeField(index: number) {
143
+ newFields = newFields.filter((_, i) => i !== index);
144
+ }
145
+
146
+ function editField(index: number) {
147
+ editingFieldIndex = index;
148
+ editingField = { ...newFields[index] };
149
+ }
150
+
151
+ function toSnakeCase(str: string): string {
152
+ return str
153
+ .toLowerCase()
154
+ .replace(/\s+/g, "_")
155
+ .replace(/[^a-z0-9_]/g, "");
156
+ }
157
+
158
+ function toCamelCase(str: string): string {
159
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
160
+ }
161
+
162
+ function reportProjectionState(result: LocalCommandResult): void {
163
+ if (result.projectionPending) {
164
+ notify.info(
165
+ "Definition saved locally. Its database projection will retry on startup.",
166
+ );
167
+ }
168
+ }
169
+
170
+ async function handleCreate() {
171
+ if (!newName.trim() || newFields.length === 0) return;
172
+ if (calendarDraftInvalid) return;
173
+
174
+ const name = toSnakeCase(newName);
175
+ const tableName = name + "s";
176
+ const calendar = calendarFromDraft(calendarDraft);
177
+
178
+ const result = await executeLocalCommand({
179
+ id: crypto.randomUUID(),
180
+ type: "ENTITY_TYPE_DEFINED",
181
+ timestamp: Date.now(),
182
+ payload: {
183
+ id: crypto.randomUUID(),
184
+ name,
185
+ displayName: newDisplayName || newName,
186
+ tableName,
187
+ fields: newFields,
188
+ ...(calendar.length > 0 ? { calendar } : {}),
189
+ },
190
+ } as any);
191
+ reportProjectionState(result);
192
+
193
+ await invalidate("app:entity-types");
194
+ resetForm();
195
+ }
196
+
197
+ async function handleArchive(entityType: EntityTypeRecord) {
198
+ itemToArchive = entityType;
199
+ showArchiveConfirm = true;
200
+ }
201
+
202
+ async function executeArchive() {
203
+ if (!itemToArchive) return;
204
+ const entityType = itemToArchive;
205
+ showArchiveConfirm = false;
206
+ itemToArchive = null;
207
+
208
+ const result = await executeLocalCommand({
209
+ id: crypto.randomUUID(),
210
+ type: "ENTITY_TYPE_ARCHIVED",
211
+ timestamp: Date.now(),
212
+ payload: {
213
+ id: entityType.id,
214
+ },
215
+ } as any);
216
+ reportProjectionState(result);
217
+
218
+ await invalidate("app:entity-types");
219
+ }
220
+
221
+ function handleExport(entityType: EntityTypeRecord) {
222
+ exportCode = generateExportSnippet(entityType);
223
+ showExportModal = true;
224
+ }
225
+
226
+ async function copyToClipboard() {
227
+ await navigator.clipboard.writeText(exportCode);
228
+ notify.success("Entity definition copied");
229
+ }
230
+
231
+ // ============================================================================
232
+ // Edit Mode - Destructive Redesign
233
+ // ============================================================================
234
+
235
+ let isEditing = $state<EntityTypeRecord | null>(null);
236
+
237
+ function startEdit(entityType: EntityTypeRecord) {
238
+ isEditing = entityType;
239
+ newName = entityType.name;
240
+ newDisplayName = entityType.display_name;
241
+ newFields = safeParse(entityType.fields_json, entityType.name);
242
+ calendarDraft = draftFromCalendar(
243
+ parseCalendarConfig(entityType.calendar_json),
244
+ );
245
+ editingFieldIndex = null;
246
+ editingField = { name: "", type: "string", payloadKey: "" };
247
+ }
248
+
249
+ function cancelEdit() {
250
+ isEditing = null;
251
+ resetForm();
252
+ }
253
+
254
+ async function handleRedefinition() {
255
+ if (!isEditing) return;
256
+ showRedefineConfirm = true;
257
+ }
258
+
259
+ async function executeRedefinition() {
260
+ if (!isEditing) return;
261
+ showRedefineConfirm = false;
262
+ if (calendarDraftInvalid) return;
263
+
264
+ // Archive and replace the definition as one durable local command.
265
+ const name = toSnakeCase(newName);
266
+ const tableName = name + "s";
267
+ const timestamp = Date.now();
268
+ const calendar = calendarFromDraft(calendarDraft);
269
+ const result = await executeLocalCommand([
270
+ {
271
+ id: crypto.randomUUID(),
272
+ type: "ENTITY_TYPE_ARCHIVED",
273
+ timestamp,
274
+ payload: { id: isEditing.id },
275
+ },
276
+ {
277
+ id: crypto.randomUUID(),
278
+ type: "ENTITY_TYPE_DEFINED",
279
+ timestamp: timestamp + 1,
280
+ payload: {
281
+ id: crypto.randomUUID(),
282
+ name,
283
+ displayName: newDisplayName || newName,
284
+ tableName,
285
+ fields: newFields,
286
+ ...(calendar.length > 0 ? { calendar } : {}),
287
+ },
288
+ },
289
+ ] as any);
290
+ reportProjectionState(result);
291
+
292
+ await invalidate("app:entity-types");
293
+
294
+ isEditing = null;
295
+ resetForm();
296
+ }
297
+ </script>
298
+
299
+ <svelte:head>
300
+ <title>Entity Types | stor</title>
301
+ </svelte:head>
302
+
303
+ <PageDataStatus {resource} />
304
+
305
+
306
+ <div class="flex h-full flex-col overflow-hidden bg-[var(--stor-bg)]">
307
+ <!-- Header Area -->
308
+ {#snippet entityDescription()}
309
+ <DataValue>{filteredEntityTypes.length}</DataValue>
310
+ <DataValue>{filteredEntityTypes.length === 1 ? ' collection' : ' collections'}</DataValue>
311
+ {#if searchQuery.trim()}<span aria-hidden="true"> · </span>matching “{searchQuery.trim()}”{/if}
312
+ {/snippet}
313
+ <PageHeader title="Entities" class="shrink-0 px-3 pt-4 pb-2" context={entityDescription}>
314
+ <label class="relative min-w-0">
315
+ <span class="sr-only">Search types</span>
316
+ <IconSearch class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--stor-muted-text)]" />
317
+ <Input placeholder="Search types…" bind:value={searchQuery} class="pl-9 md:w-64" />
318
+ </label>
319
+ <CommandOnly>
320
+ {#if tenant.dynamicEntities}
321
+ <Button onclick={() => (isCreating = true)}><IconPlus class="h-4 w-4" /> New type</Button>
322
+ {/if}
323
+ </CommandOnly>
324
+ </PageHeader>
325
+
326
+ <!-- Main Content Grid -->
327
+ <div class="flex-1 overflow-y-auto p-3">
328
+ <div class="w-full">
329
+ {#if resource.loading}
330
+ <DataPlaceholder rows={5} />
331
+ {:else if filteredEntityTypes.length === 0}
332
+ <div in:fade>
333
+ <EmptyState
334
+ title="No entity types found"
335
+ description="Create a custom data structure to start storing specialized records."
336
+ >
337
+ <CommandOnly>
338
+ {#if tenant.dynamicEntities}<Button onclick={() => (isCreating = true)}>Create entity type</Button>{/if}
339
+ </CommandOnly>
340
+ </EmptyState>
341
+ </div>
342
+ {:else}
343
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
344
+ {#each filteredEntityTypes as entityType (entityType.id)}
345
+ {@const fields = safeParse(
346
+ entityType.fields_json,
347
+ entityType.name,
348
+ )}
349
+ <article class="stor-panel group flex flex-col p-[var(--stor-panel-padding)] transition-shadow hover:shadow-[var(--stor-shadow-md)]">
350
+ <div class="flex items-start justify-between gap-3">
351
+ <div class="min-w-0">
352
+ <a
353
+ href={entityHref(entityType.name)}
354
+ class="block truncate text-base font-semibold text-[var(--stor-ink)] transition-colors hover:text-[var(--stor-accent-strong)]"
355
+ >
356
+ {entityType.display_name}
357
+ </a>
358
+ <p class="mt-0.5 truncate font-mono text-[11px] uppercase tracking-wider text-[var(--stor-muted-text)]">
359
+ {entityType.name}
360
+ </p>
361
+ </div>
362
+ <span class="shrink-0 rounded-full bg-[var(--stor-surface-muted)] px-2.5 py-1 text-[10.5px] font-semibold uppercase tracking-wide text-[var(--stor-muted-text)]">
363
+ {fields.length} {fields.length === 1 ? "field" : "fields"}
364
+ </span>
365
+ </div>
366
+
367
+ <ul class="mt-3 flex flex-wrap gap-1.5">
368
+ {#each fields.slice(0, 3) as field}
369
+ <li class="inline-flex items-center gap-1.5 rounded-lg py-1 pr-2 text-xs">
370
+ <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--stor-accent)]"></span>
371
+ <span class="font-medium text-[var(--stor-ink)]">{field.name}</span>
372
+ <span class="font-mono text-[10.5px] text-[var(--stor-muted-text)]">{field.type}</span>
373
+ </li>
374
+ {/each}
375
+ {#if fields.length > 3}
376
+ <li class="inline-flex items-center rounded-lg px-2 py-1 text-xs font-medium text-[var(--stor-muted-text)]">
377
+ +{fields.length - 3} more
378
+ </li>
379
+ {/if}
380
+ </ul>
381
+
382
+ <div class="mt-4 flex items-center justify-between gap-2 border-t border-[var(--stor-border)] pt-3">
383
+ <a
384
+ href={entityHref(entityType.name)}
385
+ class="group/link inline-flex items-center gap-1 text-sm font-semibold text-[var(--stor-accent-strong)] transition hover:text-[var(--stor-accent)]"
386
+ >
387
+ Open records
388
+ <IconArrowRight class="h-4 w-4 transition-transform group-hover/link:translate-x-0.5" />
389
+ </a>
390
+ {#if !entityType.is_static}
391
+ <div class="flex items-center gap-0.5">
392
+ <CommandOnly>
393
+ <Button
394
+ variant="ghost"
395
+ size="icon"
396
+ onclick={() => startEdit(entityType)}
397
+ title="Edit schema"
398
+ aria-label={`Edit ${entityType.display_name} schema`}
399
+ >
400
+ <IconPencil class="h-4 w-4" />
401
+ </Button>
402
+ </CommandOnly>
403
+ <Button
404
+ variant="ghost"
405
+ size="icon"
406
+ onclick={() => handleExport(entityType)}
407
+ title="Export code"
408
+ aria-label={`Export ${entityType.display_name} code`}
409
+ >
410
+ <IconCode class="h-4 w-4" />
411
+ </Button>
412
+ <CommandOnly>
413
+ <Button
414
+ variant="ghost"
415
+ size="icon"
416
+ onclick={() => handleArchive(entityType)}
417
+ class="hover:bg-[var(--stor-negative-soft)] hover:text-[var(--stor-negative)]"
418
+ title="Archive"
419
+ aria-label={`Archive ${entityType.display_name}`}
420
+ >
421
+ <IconTrash class="h-4 w-4" />
422
+ </Button>
423
+ </CommandOnly>
424
+ </div>
425
+ {:else}
426
+ <span class="select-none rounded-full bg-[var(--stor-surface-muted)] px-2.5 py-1 text-[10.5px] font-semibold uppercase tracking-wide text-[var(--stor-muted-text)]">
427
+ Built-in
428
+ </span>
429
+ {/if}
430
+ </div>
431
+ </article>
432
+ {/each}
433
+ </div>
434
+ {/if}
435
+ </div>
436
+ </div>
437
+ </div>
438
+
439
+ <CommandDrawer
440
+ show={isCreating}
441
+ size="lg"
442
+ title="Define new entity"
443
+ description="Create a reusable schema for a new kind of record."
444
+ onClose={resetForm}
445
+ >
446
+ <div class="space-y-8">
447
+ <!-- Basic Info -->
448
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
449
+ <div class="space-y-1.5">
450
+ <label class="text-sm font-semibold text-[var(--stor-ink)] ml-1"
451
+ >Entity ID (e.g. task)</label
452
+ >
453
+ <input
454
+ type="text"
455
+ placeholder="snake_case_name"
456
+ bind:value={newName}
457
+ class="stor-native-field"
458
+ />
459
+ </div>
460
+ <div class="space-y-1.5">
461
+ <label class="text-sm font-semibold text-[var(--stor-ink)] ml-1"
462
+ >Display Name (e.g. Task)</label
463
+ >
464
+ <input
465
+ type="text"
466
+ placeholder="Pretty Name"
467
+ bind:value={newDisplayName}
468
+ class="stor-native-field"
469
+ />
470
+ </div>
471
+ </div>
472
+
473
+ <!-- Fields Management -->
474
+ <div class="space-y-4">
475
+ <div class="flex items-center justify-between">
476
+ <h3 class="text-base font-semibold text-[var(--stor-ink)]">
477
+ Fields Configuration
478
+ </h3>
479
+ <span
480
+ class="text-xs bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)] px-3 py-1 rounded-full font-semibold uppercase tracking-wide"
481
+ >
482
+ {newFields.length} total fields
483
+ </span>
484
+ </div>
485
+
486
+ <div
487
+ class="space-y-3 rounded-xl bg-[var(--stor-surface-muted)] p-4"
488
+ >
489
+ <!-- Field Form -->
490
+ <div class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
491
+ <div class="md:col-span-3 space-y-1">
492
+ <label
493
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
494
+ >Field Name</label
495
+ >
496
+ <input
497
+ type="text"
498
+ placeholder="title"
499
+ bind:value={editingField.name}
500
+ class="stor-native-field"
501
+ />
502
+ </div>
503
+ <div class="md:col-span-2 space-y-1">
504
+ <label
505
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
506
+ >Type</label
507
+ >
508
+ <select
509
+ bind:value={editingField.type}
510
+ class="stor-native-field"
511
+ >
512
+ <option value="string">String</option>
513
+ <option value="number">Number</option>
514
+ <option value="boolean">Boolean</option>
515
+ <option value="datetime">Datetime</option>
516
+ <option value="reference">Reference</option>
517
+ </select>
518
+ </div>
519
+ {#if editingField.type === "reference"}
520
+ <div class="md:col-span-3 space-y-1">
521
+ <label
522
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
523
+ >References</label
524
+ >
525
+ <select
526
+ bind:value={editingField.referenceTo}
527
+ class="stor-native-field"
528
+ >
529
+ <option value="">Select entity...</option>
530
+ {#each entityTypes as et}
531
+ <option value={et.name}
532
+ >{et.display_name}</option
533
+ >
534
+ {/each}
535
+ </select>
536
+ </div>
537
+ <div class="md:col-span-2 space-y-1">
538
+ <label
539
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
540
+ >Default</label
541
+ >
542
+ <input
543
+ type="text"
544
+ placeholder="optional"
545
+ bind:value={editingField.default}
546
+ class="stor-native-field"
547
+ />
548
+ </div>
549
+ {:else if editingField.type === "datetime"}
550
+ <div class="md:col-span-3 space-y-1">
551
+ <label
552
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
553
+ >Default</label
554
+ >
555
+ <div
556
+ class="flex items-center gap-2 px-3 py-2 bg-[var(--stor-surface)] border border-[var(--stor-border)] rounded-lg cursor-pointer hover:bg-[var(--stor-surface-muted)] transition-colors whitespace-nowrap"
557
+ >
558
+ <Checkbox
559
+ checked={editingField.default ===
560
+ "datetime('now')"}
561
+ onCheckedChange={(checked) =>
562
+ (editingField.default = checked
563
+ ? "datetime('now')"
564
+ : undefined)}
565
+ class="h-4 w-4 rounded"
566
+ />
567
+ <span class="text-sm text-[var(--stor-muted-text)]">now()</span
568
+ >
569
+ </div>
570
+ </div>
571
+ {:else}
572
+ <div class="md:col-span-3 space-y-1">
573
+ <label
574
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
575
+ >Default</label
576
+ >
577
+ <input
578
+ type="text"
579
+ placeholder="optional"
580
+ bind:value={editingField.default}
581
+ class="stor-native-field"
582
+ />
583
+ </div>
584
+ {/if}
585
+ <div class="md:col-span-2">
586
+ <Button onclick={addField} class="w-full">
587
+ {editingFieldIndex !== null ? "Update" : "Add"}
588
+ </Button>
589
+ </div>
590
+ </div>
591
+
592
+ <!-- Fields List -->
593
+ {#if newFields.length > 0}
594
+ <div class="mt-6 space-y-2">
595
+ {#each newFields as field, index}
596
+ <div
597
+ class="flex items-center gap-3 p-3 bg-[var(--stor-surface)] border border-[var(--stor-border)] rounded-lg group/item"
598
+ in:fly={{ x: -10, duration: 200 }}
599
+ >
600
+ <div
601
+ class="w-8 h-8 bg-[var(--stor-surface-muted)] rounded-lg flex items-center justify-center text-xs font-bold text-[var(--stor-muted-text)]"
602
+ >
603
+ {index + 1}
604
+ </div>
605
+ <div class="flex-1">
606
+ <div class="flex items-center gap-2">
607
+ <span
608
+ class="font-semibold text-sm text-[var(--stor-ink)]"
609
+ >{field.name}</span
610
+ >
611
+ <span
612
+ class="text-[10.5px] bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)] px-1.5 py-0.5 rounded font-mono uppercase"
613
+ >{field.type}</span
614
+ >
615
+ </div>
616
+ </div>
617
+ <div
618
+ class="flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover/item:opacity-100 sm:group-focus-within/item:opacity-100"
619
+ >
620
+ <button
621
+ onclick={() => editField(index)}
622
+ class="p-1.5 text-[var(--stor-muted-text)] hover:text-[var(--stor-ink)] hover:bg-[var(--stor-surface-muted)] rounded-lg transition-all"
623
+ >
624
+ <svg
625
+ class="w-4 h-4"
626
+ fill="none"
627
+ stroke="currentColor"
628
+ viewBox="0 0 24 24"
629
+ >
630
+ <path
631
+ stroke-linecap="round"
632
+ stroke-linejoin="round"
633
+ stroke-width="2"
634
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
635
+ />
636
+ </svg>
637
+ </button>
638
+ <button
639
+ onclick={() => removeField(index)}
640
+ class="p-1.5 text-[var(--stor-muted-text)] hover:text-[var(--stor-negative)] hover:bg-[var(--stor-negative-soft)] rounded-lg transition-all"
641
+ >
642
+ <svg
643
+ class="w-4 h-4"
644
+ fill="none"
645
+ stroke="currentColor"
646
+ viewBox="0 0 24 24"
647
+ >
648
+ <path
649
+ stroke-linecap="round"
650
+ stroke-linejoin="round"
651
+ stroke-width="2"
652
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
653
+ />
654
+ </svg>
655
+ </button>
656
+ </div>
657
+ </div>
658
+ {/each}
659
+ </div>
660
+ {/if}
661
+ </div>
662
+ </div>
663
+
664
+ <CalendarSourceFields
665
+ fields={newFields}
666
+ bind:draft={calendarDraft}
667
+ defaultLabel={newDisplayName}
668
+ />
669
+
670
+ </div>
671
+ {#snippet footer()}
672
+ <Button variant="secondary" onclick={resetForm}>Discard</Button>
673
+ <Button
674
+ onclick={handleCreate}
675
+ disabled={!newName.trim() || newFields.length === 0 || calendarDraftInvalid}
676
+ >
677
+ Define Type
678
+ </Button>
679
+
680
+ {/snippet}
681
+ </CommandDrawer>
682
+
683
+ <CommandDrawer
684
+ show={!!isEditing}
685
+ size="lg"
686
+ title="Edit entity schema"
687
+ description="Review the schema carefully before redefining this entity type."
688
+ onClose={cancelEdit}
689
+ >
690
+ {#if isEditing}
691
+ <div class="space-y-8">
692
+ <!-- Warning Banner -->
693
+ <div
694
+ class="px-8 py-4 bg-[var(--stor-warning-soft)] border-b border-[var(--stor-border)] -mx-6 -mt-6 mb-6"
695
+ >
696
+ <div class="flex gap-3 items-start">
697
+ <svg
698
+ class="w-5 h-5 text-[var(--stor-warning)] flex-shrink-0 mt-0.5"
699
+ fill="none"
700
+ stroke="currentColor"
701
+ viewBox="0 0 24 24"
702
+ >
703
+ <path
704
+ stroke-linecap="round"
705
+ stroke-linejoin="round"
706
+ stroke-width="2"
707
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
708
+ />
709
+ </svg>
710
+ <div>
711
+ <h3 class="font-bold text-[var(--stor-warning)] text-sm">
712
+ Destructive Operation
713
+ </h3>
714
+ <p class="text-[var(--stor-warning)] text-xs mt-0.5">
715
+ Saving changes will <strong
716
+ >delete all existing {isEditing.display_name} data</strong
717
+ >. This is intended for the design phase only.
718
+ </p>
719
+ </div>
720
+ </div>
721
+ </div>
722
+
723
+ <!-- Basic Info -->
724
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
725
+ <div class="space-y-1.5">
726
+ <label class="text-sm font-semibold text-[var(--stor-ink)] ml-1"
727
+ >Entity ID</label
728
+ >
729
+ <input
730
+ type="text"
731
+ placeholder="snake_case_name"
732
+ bind:value={newName}
733
+ disabled
734
+ class="stor-native-field"
735
+ />
736
+ <p class="text-[10.5px] text-[var(--stor-muted-text)] ml-1">
737
+ Cannot be changed
738
+ </p>
739
+ </div>
740
+ <div class="space-y-1.5">
741
+ <label class="text-sm font-semibold text-[var(--stor-ink)] ml-1"
742
+ >Display Name</label
743
+ >
744
+ <input
745
+ type="text"
746
+ placeholder="Pretty Name"
747
+ bind:value={newDisplayName}
748
+ class="stor-native-field"
749
+ />
750
+ </div>
751
+ </div>
752
+
753
+ <!-- Fields Management -->
754
+ <div class="space-y-4">
755
+ <div class="flex items-center justify-between">
756
+ <h3 class="text-base font-semibold text-[var(--stor-ink)]">
757
+ Fields Configuration
758
+ </h3>
759
+ <span
760
+ class="text-xs bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)] px-3 py-1 rounded-full font-semibold uppercase tracking-wide"
761
+ >
762
+ {newFields.length} total fields
763
+ </span>
764
+ </div>
765
+
766
+ <div
767
+ class="space-y-3 rounded-xl bg-[var(--stor-surface-muted)] p-4"
768
+ >
769
+ <!-- Field Form -->
770
+ <div
771
+ class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end"
772
+ >
773
+ <div class="md:col-span-3 space-y-1">
774
+ <label
775
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
776
+ >Field Name</label
777
+ >
778
+ <input
779
+ type="text"
780
+ placeholder="title"
781
+ bind:value={editingField.name}
782
+ class="stor-native-field"
783
+ />
784
+ </div>
785
+ <div class="md:col-span-2 space-y-1">
786
+ <label
787
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
788
+ >Type</label
789
+ >
790
+ <select
791
+ bind:value={editingField.type}
792
+ class="stor-native-field"
793
+ >
794
+ <option value="string">String</option>
795
+ <option value="number">Number</option>
796
+ <option value="boolean">Boolean</option>
797
+ <option value="datetime">Datetime</option>
798
+ <option value="reference">Reference</option>
799
+ </select>
800
+ </div>
801
+ {#if editingField.type === "reference"}
802
+ <div class="md:col-span-3 space-y-1">
803
+ <label
804
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
805
+ >References</label
806
+ >
807
+ <select
808
+ bind:value={editingField.referenceTo}
809
+ class="stor-native-field"
810
+ >
811
+ <option value="">Select entity...</option>
812
+ {#each entityTypes as et}
813
+ <option value={et.name}
814
+ >{et.display_name}</option
815
+ >
816
+ {/each}
817
+ </select>
818
+ </div>
819
+ <div class="md:col-span-2 space-y-1">
820
+ <label
821
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
822
+ >Default</label
823
+ >
824
+ <input
825
+ type="text"
826
+ placeholder="optional"
827
+ bind:value={editingField.default}
828
+ class="stor-native-field"
829
+ />
830
+ </div>
831
+ {:else if editingField.type === "datetime"}
832
+ <div class="md:col-span-3 space-y-1">
833
+ <label
834
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
835
+ >Default</label
836
+ >
837
+ <div
838
+ class="flex items-center gap-2 px-3 py-2 bg-[var(--stor-surface)] border border-[var(--stor-border)] rounded-lg cursor-pointer hover:bg-[var(--stor-surface-muted)] transition-colors whitespace-nowrap"
839
+ >
840
+ <Checkbox
841
+ checked={editingField.default ===
842
+ "datetime('now')"}
843
+ onCheckedChange={(checked) =>
844
+ (editingField.default = checked
845
+ ? "datetime('now')"
846
+ : undefined)}
847
+ class="h-4 w-4 rounded"
848
+ />
849
+ <span class="text-sm text-[var(--stor-muted-text)]"
850
+ >now()</span
851
+ >
852
+ </div>
853
+ </div>
854
+ {:else}
855
+ <div class="md:col-span-3 space-y-1">
856
+ <label
857
+ class="text-[10.5px] font-bold text-[var(--stor-muted-text)] uppercase ml-1"
858
+ >Default</label
859
+ >
860
+ <input
861
+ type="text"
862
+ placeholder="optional"
863
+ bind:value={editingField.default}
864
+ class="stor-native-field"
865
+ />
866
+ </div>
867
+ {/if}
868
+ <div class="md:col-span-2">
869
+ <Button onclick={addField} class="w-full">
870
+ {editingFieldIndex !== null ? "Update" : "Add"}
871
+ </Button>
872
+ </div>
873
+ </div>
874
+
875
+ <!-- Fields List -->
876
+ {#if newFields.length > 0}
877
+ <div class="mt-6 space-y-2">
878
+ {#each newFields as field, index}
879
+ <div
880
+ class="flex items-center gap-3 p-3 bg-[var(--stor-surface)] border border-[var(--stor-border)] rounded-lg group/item"
881
+ in:fly={{ x: -10, duration: 200 }}
882
+ >
883
+ <div
884
+ class="w-8 h-8 bg-[var(--stor-surface-muted)] rounded-lg flex items-center justify-center text-xs font-bold text-[var(--stor-muted-text)]"
885
+ >
886
+ {index + 1}
887
+ </div>
888
+ <div class="flex-1">
889
+ <div class="flex items-center gap-2">
890
+ <span
891
+ class="font-semibold text-sm text-[var(--stor-ink)]"
892
+ >{field.name}</span
893
+ >
894
+ <span
895
+ class="text-[10.5px] bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)] px-1.5 py-0.5 rounded font-mono uppercase"
896
+ >{field.type}</span
897
+ >
898
+ {#if field.referenceTo}
899
+ <span
900
+ class="text-[10.5px] text-[var(--stor-accent-strong)]"
901
+ >→ {field.referenceTo}</span
902
+ >
903
+ {/if}
904
+ </div>
905
+ </div>
906
+ <div
907
+ class="flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover/item:opacity-100 sm:group-focus-within/item:opacity-100"
908
+ >
909
+ <button
910
+ onclick={() => editField(index)}
911
+ class="p-1.5 text-[var(--stor-muted-text)] hover:text-[var(--stor-ink)] hover:bg-[var(--stor-surface-muted)] rounded-lg transition-all"
912
+ >
913
+ <svg
914
+ class="w-4 h-4"
915
+ fill="none"
916
+ stroke="currentColor"
917
+ viewBox="0 0 24 24"
918
+ >
919
+ <path
920
+ stroke-linecap="round"
921
+ stroke-linejoin="round"
922
+ stroke-width="2"
923
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
924
+ />
925
+ </svg>
926
+ </button>
927
+ <button
928
+ onclick={() => removeField(index)}
929
+ class="p-1.5 text-[var(--stor-muted-text)] hover:text-[var(--stor-negative)] hover:bg-[var(--stor-negative-soft)] rounded-lg transition-all"
930
+ >
931
+ <svg
932
+ class="w-4 h-4"
933
+ fill="none"
934
+ stroke="currentColor"
935
+ viewBox="0 0 24 24"
936
+ >
937
+ <path
938
+ stroke-linecap="round"
939
+ stroke-linejoin="round"
940
+ stroke-width="2"
941
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
942
+ />
943
+ </svg>
944
+ </button>
945
+ </div>
946
+ </div>
947
+ {/each}
948
+ </div>
949
+ {/if}
950
+ </div>
951
+ </div>
952
+
953
+ <CalendarSourceFields
954
+ fields={newFields}
955
+ bind:draft={calendarDraft}
956
+ defaultLabel={newDisplayName}
957
+ />
958
+
959
+ </div>
960
+ {/if}
961
+ {#snippet footer()}
962
+ <Button variant="secondary" onclick={cancelEdit}>Cancel</Button>
963
+ <Button
964
+ onclick={handleRedefinition}
965
+ disabled={newFields.length === 0 || calendarDraftInvalid}
966
+ >
967
+ <svg
968
+ class="w-5 h-5"
969
+ fill="none"
970
+ stroke="currentColor"
971
+ viewBox="0 0 24 24"
972
+ >
973
+ <path
974
+ stroke-linecap="round"
975
+ stroke-linejoin="round"
976
+ stroke-width="2"
977
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
978
+ />
979
+ </svg>
980
+ Save & Reset Data
981
+ </Button>
982
+
983
+ {/snippet}
984
+ </CommandDrawer>
985
+
986
+ <!-- Export Modal -->
987
+ <Modal
988
+ show={showExportModal}
989
+ title="Code Export"
990
+ onClose={() => (showExportModal = false)}
991
+ >
992
+ <div class="space-y-6">
993
+ <div class="bg-stone-900 rounded-xl p-6 relative group">
994
+ <pre
995
+ class="text-stone-300 text-sm overflow-x-auto font-mono leading-relaxed"><code
996
+ >{exportCode}</code
997
+ ></pre>
998
+ <button
999
+ onclick={copyToClipboard}
1000
+ class="absolute top-4 right-4 px-4 py-2 bg-white/10 hover:bg-white/20 text-white text-xs font-bold rounded-xl backdrop-blur-md transition-all opacity-0 group-hover:opacity-100"
1001
+ >
1002
+ Copy Snippet
1003
+ </button>
1004
+ </div>
1005
+
1006
+ <div class="rounded-lg bg-[var(--stor-surface-muted)] p-4 flex gap-3 text-[var(--stor-info)]">
1007
+ <svg
1008
+ class="w-5 h-5 flex-shrink-0"
1009
+ fill="none"
1010
+ stroke="currentColor"
1011
+ viewBox="0 0 24 24"
1012
+ >
1013
+ <path
1014
+ stroke-linecap="round"
1015
+ stroke-linejoin="round"
1016
+ stroke-width="2"
1017
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1018
+ />
1019
+ </svg>
1020
+ <p class="text-xs font-medium leading-relaxed">
1021
+ Copy this snippet into your <code>entity-definitions.ts</code> file
1022
+ to convert this dynamic type into a fully-typed static entity with
1023
+ auto-generated code.
1024
+ </p>
1025
+ </div>
1026
+
1027
+ <div class="flex justify-end pt-4">
1028
+ <button
1029
+ onclick={() => (showExportModal = false)}
1030
+ class="rounded-xl bg-[var(--stor-action)] px-8 py-3 font-semibold text-[var(--stor-action-text)] transition-all hover:bg-[var(--stor-action-hover)]"
1031
+ >
1032
+ Done
1033
+ </button>
1034
+ </div>
1035
+ </div>
1036
+ </Modal>
1037
+
1038
+ <CommandConfirmDialog
1039
+ show={showArchiveConfirm}
1040
+ title="Archive Entity Type"
1041
+ message={`Archive "${itemToArchive?.display_name}"? The data will be preserved but the entity type will be hidden.`}
1042
+ onConfirm={executeArchive}
1043
+ onCancel={() => (showArchiveConfirm = false)}
1044
+ />
1045
+
1046
+ <CommandConfirmDialog
1047
+ show={showRedefineConfirm}
1048
+ title="Confirm Redefinition"
1049
+ message={`⚠️ WARNING: This will DELETE ALL existing ${isEditing?.display_name} data!\n\nThe entity type will be redefined with the new schema, and all existing records will be permanently removed.\n\nAre you sure you want to continue?`}
1050
+ onConfirm={executeRedefinition}
1051
+ onCancel={() => (showRedefineConfirm = false)}
1052
+ />
1053
+
1054
+ <style>
1055
+ /* Simple custom scrollbar for better look */
1056
+ ::-webkit-scrollbar {
1057
+ width: 8px;
1058
+ }
1059
+ ::-webkit-scrollbar-track {
1060
+ background: transparent;
1061
+ }
1062
+ ::-webkit-scrollbar-thumb {
1063
+ background: #e7e5e4;
1064
+ border-radius: 10px;
1065
+ }
1066
+ ::-webkit-scrollbar-thumb:hover {
1067
+ background: #d6d3d1;
1068
+ }
1069
+ </style>