@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.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/app.template.html +184 -0
- package/src/components/ai/DecisionReviewSheet.svelte +226 -0
- package/src/components/ai/SuggestButton.svelte +23 -0
- package/src/components/ai/SuggestionChip.svelte +13 -0
- package/src/components/ai/SuggestionMark.svelte +15 -0
- package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
- package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
- package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
- package/src/components/calendar/source-icons.ts +19 -0
- package/src/components/data-table/DataTableValue.svelte +14 -0
- package/src/components/data-table/EntityActionsCell.svelte +36 -0
- package/src/components/data-table/EntityRefsCell.svelte +58 -0
- package/src/components/data-table/EntityValue.svelte +42 -0
- package/src/components/data-table/entity-columns.ts +87 -0
- package/src/components/data-table/sqlite-columns.ts +42 -0
- package/src/components/entities/CalendarSourceFields.svelte +149 -0
- package/src/components/history/ChangeRecord.svelte +30 -0
- package/src/components/history/ForkComposer.svelte +174 -0
- package/src/components/history/HistoryScrubber.svelte +245 -0
- package/src/components/history/LogHistory.svelte +334 -0
- package/src/components/history/LogMap.svelte +293 -0
- package/src/components/history/LogTree.svelte +124 -0
- package/src/components/layout/AccountMenu.svelte +111 -0
- package/src/components/layout/AppearanceOptions.svelte +78 -0
- package/src/components/layout/BrandWordmark.svelte +12 -0
- package/src/components/layout/ErrorToast.svelte +34 -0
- package/src/components/layout/LogCreateDrawer.svelte +111 -0
- package/src/components/layout/LogPanel.svelte +225 -0
- package/src/components/layout/LogStatusChip.svelte +137 -0
- package/src/components/layout/MobilePageAction.svelte +44 -0
- package/src/components/layout/MovedBanner.svelte +51 -0
- package/src/components/layout/NavBar.svelte +603 -0
- package/src/components/layout/NavFlyout.svelte +132 -0
- package/src/components/layout/NavItem.svelte +36 -0
- package/src/components/layout/NavMoreDialog.svelte +179 -0
- package/src/components/layout/PageDataStatus.svelte +36 -0
- package/src/components/layout/PhoneGlance.svelte +96 -0
- package/src/components/layout/SidebarResizer.svelte +122 -0
- package/src/components/layout/SyncIndicator.svelte +24 -0
- package/src/components/layout/feature-icons.ts +24 -0
- package/src/components/layout/nav-indicator.ts +128 -0
- package/src/components/layout/sidebar-size.ts +31 -0
- package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
- package/src/components/sync/SyncStateBadge.svelte +17 -0
- package/src/pages/calendar/Page.svelte +1341 -0
- package/src/pages/calendar/load.ts +77 -0
- package/src/pages/developer/DatabaseBrowser.svelte +325 -0
- package/src/pages/developer/Event.svelte +51 -0
- package/src/pages/developer/EventLogManager.svelte +268 -0
- package/src/pages/developer/IndexedDbStats.svelte +93 -0
- package/src/pages/developer/LedgerAdmin.svelte +877 -0
- package/src/pages/developer/LocalSqliteStats.svelte +56 -0
- package/src/pages/developer/Page.svelte +590 -0
- package/src/pages/developer/PendingWorkImport.svelte +49 -0
- package/src/pages/developer/PerformanceDashboard.svelte +262 -0
- package/src/pages/developer/StackInfo.svelte +117 -0
- package/src/pages/developer/SyncLog.svelte +188 -0
- package/src/pages/developer/load.ts +28 -0
- package/src/pages/entities/Page.svelte +1069 -0
- package/src/pages/entities/load.ts +18 -0
- package/src/pages/entity/Page.svelte +655 -0
- package/src/pages/entity/load.ts +79 -0
- package/src/pages/error/ErrorPage.svelte +147 -0
- package/src/pages/layout/Layout.svelte +597 -0
- package/src/pages/layout/load.ts +28 -0
- package/src/pages/redirects/history.ts +20 -0
- package/src/pages/redirects/start.ts +11 -0
- package/src/pages/redirects/today.ts +9 -0
- package/src/service-worker/index.ts +108 -0
- package/src/service-worker/offline-bundle.ts +116 -0
- package/src/service-worker/virtual.d.ts +3 -0
- package/src/styles/index.css +11 -0
- package/src/styles/shell.css +296 -0
- package/static/_headers +4 -0
- package/static/favicon.ico +0 -0
- package/static/offline.html +44 -0
- package/static/replay/sw.js +2 -0
- 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>
|