runeforge 0.0.55 → 0.0.56

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 (54) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1342 -1342
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +22 -22
  5. package/dist/components/Modal.svelte +75 -75
  6. package/dist/components/common/Header.svelte +40 -40
  7. package/dist/components/crud/EmbeddedField.svelte +175 -175
  8. package/dist/components/crud/Field.svelte +469 -376
  9. package/dist/components/crud/GenericCRUD.svelte +426 -426
  10. package/dist/components/crud/SearchInput.svelte +53 -53
  11. package/dist/components/crud/columns/Avatar.svelte +15 -15
  12. package/dist/components/crud/columns/Icon.svelte +8 -8
  13. package/dist/components/crud/views/Create.svelte +232 -232
  14. package/dist/components/crud/views/Read.svelte +124 -124
  15. package/dist/components/crud/views/Update.svelte +208 -208
  16. package/dist/components/crud/views/list/List.svelte +291 -291
  17. package/dist/components/crud/views/list/Modals.svelte +56 -56
  18. package/dist/components/crud/views/list/Table.svelte +176 -176
  19. package/dist/components/crud/views/list/Toolbar.svelte +341 -341
  20. package/dist/components/form/Button.svelte +27 -27
  21. package/dist/components/form/Label.svelte +37 -37
  22. package/dist/components/form/MultiSelect.svelte +248 -248
  23. package/dist/components/form/PasswordInput.svelte +68 -68
  24. package/dist/components/form/Required.svelte +1 -1
  25. package/dist/components/form/Select.svelte +209 -209
  26. package/dist/components/form/Tree.svelte +62 -62
  27. package/dist/components/form/TreeNode.svelte +66 -66
  28. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  29. package/dist/components/table/ColumnFilter.svelte +168 -168
  30. package/dist/components/table/PaginatedTable.svelte +536 -536
  31. package/dist/components/table/Paginator.svelte +113 -113
  32. package/dist/components/table/SortHeader.svelte +43 -43
  33. package/dist/components/table/TableBody.svelte +150 -150
  34. package/dist/components/table/TableHeader.svelte +88 -88
  35. package/dist/i18n/en.js +1 -0
  36. package/dist/i18n/es.js +1 -0
  37. package/dist/i18n/types.d.ts +1 -0
  38. package/dist/icons/defaults/Clear.svelte +6 -6
  39. package/dist/icons/defaults/Create.svelte +6 -6
  40. package/dist/icons/defaults/Delete.svelte +6 -6
  41. package/dist/icons/defaults/Download.svelte +7 -7
  42. package/dist/icons/defaults/Edit.svelte +7 -7
  43. package/dist/icons/defaults/Filter.svelte +6 -6
  44. package/dist/icons/defaults/FilterActive.svelte +6 -6
  45. package/dist/icons/defaults/Folder.svelte +6 -6
  46. package/dist/icons/defaults/Grip.svelte +7 -7
  47. package/dist/icons/defaults/Home.svelte +6 -6
  48. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  49. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  50. package/dist/icons/defaults/SortAsc.svelte +6 -6
  51. package/dist/icons/defaults/SortDesc.svelte +6 -6
  52. package/dist/icons/defaults/SortNone.svelte +6 -6
  53. package/dist/icons/defaults/View.svelte +7 -7
  54. package/package.json +1 -1
@@ -1,426 +1,426 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import { page } from '$app/state';
3
- import { goto } from '$app/navigation';
4
- import List from './views/list/List.svelte';
5
- import Read from './views/Read.svelte';
6
- import Create from './views/Create.svelte';
7
- import Update from './views/Update.svelte';
8
- import { AUTO_EXCLUDED } from './utils/constants.js';
9
- import {
10
- resolveFormatter,
11
- truncateFormatter,
12
- inferType,
13
- buildFieldDefinitions
14
- } from './utils/resolution.js';
15
- import { defaultItemLabel } from './utils/embedded.js';
16
- import { isFilterable } from '../table/utils.js';
17
- import type { AttributeMetadata } from '../../types/attribute.js';
18
- import type {
19
- ActionConfiguration,
20
- ColumnDefinition,
21
- CustomAction,
22
- FieldDefinition,
23
- ListActions,
24
- ListConfig,
25
- ViewBasedCustomBulkAction
26
- } from '../../types/crud.js';
27
- import type {
28
- FilterSnapshot,
29
- PaginatedEnvelope,
30
- ServerPagination,
31
- SortDirection,
32
- TableQuery
33
- } from '../../types/table.js';
34
-
35
- let {
36
- data = undefined as Record<string, unknown> | undefined,
37
- dataKey = undefined as string | undefined,
38
- idKey = '_id',
39
- labelOne = '',
40
- labelMany = '',
41
- icon,
42
- pageSize = 10,
43
- creation = {} as ActionConfiguration<T>,
44
- update = {} as ActionConfiguration<T>,
45
- read = {} as ActionConfiguration<T>,
46
- deletion = {} as ActionConfiguration<T>,
47
- actions = {} as ListActions<T>,
48
- config = {} as ListConfig<T>,
49
- columns = undefined as ColumnDefinition<T>[] | undefined,
50
- fields = undefined as FieldDefinition<T>[] | undefined,
51
- meta = undefined as Partial<Record<string, AttributeMetadata>> | undefined,
52
- form = null as { error?: string } | null
53
- }: {
54
- data?: Record<string, unknown>;
55
- dataKey?: string;
56
- idKey?: string;
57
- labelOne?: string;
58
- labelMany?: string;
59
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
- icon?: any;
61
- pageSize?: number;
62
- creation?: ActionConfiguration<T>;
63
- update?: ActionConfiguration<T>;
64
- read?: ActionConfiguration<T>;
65
- deletion?: ActionConfiguration<T>;
66
- /** Extra per-row (`custom`) and per-selection (`bulk`) actions. */
67
- actions?: ListActions<T>;
68
- /** Opt-in list behaviors: free-text search, CSV/Excel export, and
69
- * drag-to-reorder. */
70
- config?: ListConfig<T>;
71
- columns?: ColumnDefinition<T>[];
72
- fields?: FieldDefinition<T>[];
73
- meta?: Partial<Record<string, AttributeMetadata>>;
74
- form?: { error?: string } | null;
75
- } = $props();
76
-
77
- function isEnvelope(value: unknown): value is PaginatedEnvelope<T> {
78
- return (
79
- !!value &&
80
- typeof value === 'object' &&
81
- !Array.isArray(value) &&
82
- Array.isArray((value as Record<string, unknown>).results) &&
83
- typeof (value as Record<string, unknown>).count === 'number'
84
- );
85
- }
86
-
87
- const rawSlice = $derived(data && dataKey ? data[dataKey] : undefined);
88
- const envelope = $derived(isEnvelope(rawSlice) ? rawSlice : undefined);
89
- const entityData = $derived<T[]>(
90
- envelope ? envelope.results : Array.isArray(rawSlice) ? (rawSlice as T[]) : []
91
- );
92
-
93
- const serverPagination = $derived<ServerPagination | undefined>(
94
- envelope
95
- ? {
96
- page: envelope.page,
97
- pageSize: envelope.pageSize,
98
- totalPages: Math.max(1, Math.ceil(envelope.count / envelope.pageSize)),
99
- total: envelope.count
100
- }
101
- : undefined
102
- );
103
-
104
- const viewParam = $derived(page.url.searchParams.get('view'));
105
- const idParam = $derived(page.url.searchParams.get('id'));
106
-
107
- const creating = $derived(viewParam === 'create');
108
- const reading = $derived(idParam !== null && viewParam === null);
109
- const editing = $derived(idParam !== null && viewParam === 'edit');
110
-
111
- const excluded = $derived(new Set([...AUTO_EXCLUDED, idKey]));
112
-
113
- const singleInstance = $derived<T | undefined>(
114
- Object.values(page.data as Record<string, unknown>).find(
115
- (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && idKey in (v as object)
116
- ) as T | undefined
117
- );
118
-
119
- let activeAction = $state<{ action: CustomAction<T>; item: T } | null>(null);
120
- let activeBulkAction = $state<{ action: ViewBasedCustomBulkAction<T>; items: T[] } | null>(
121
- null
122
- );
123
-
124
- async function runAction(action: CustomAction<T>, item: T) {
125
- if (!(action.condition?.(item) ?? true)) return;
126
- if (action.href) {
127
- await goto(action.href(item));
128
- return;
129
- }
130
- activeAction = { action, item };
131
- }
132
-
133
- function runBulkAction(action: ViewBasedCustomBulkAction<T>, items: T[]) {
134
- activeBulkAction = { action, items };
135
- }
136
-
137
- let duplicateSeed = $state<Record<string, unknown> | undefined>(undefined);
138
-
139
- async function navList() {
140
- duplicateSeed = undefined;
141
- await goto(lastListState.search || '?');
142
- }
143
- async function navCreate() {
144
- duplicateSeed = undefined;
145
- await goto('?view=create');
146
- }
147
- async function navDuplicate(record: Record<string, unknown>) {
148
- duplicateSeed = record;
149
- await goto('?view=create');
150
- }
151
- async function navRead(item: T) {
152
- await goto(`?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}`);
153
- }
154
- async function navEdit(item: T) {
155
- await goto(
156
- `?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}&view=edit`
157
- );
158
- }
159
-
160
- let lastListState = $state<{ data: T[]; search: string }>({ data: [], search: '' });
161
- $effect(() => {
162
- if (!creating && !reading && !editing) {
163
- lastListState = { data: entityData, search: page.url.search };
164
- }
165
- });
166
-
167
- // "Save and continue" on the Update form: jumps to editing the record that
168
- // follows the current one in the last loaded list, falling back to
169
- // re-editing the same instance when it's the last one (or isn't found).
170
- function nextInstance(current: T): T {
171
- const currentId = (current as Record<string, unknown>)[idKey];
172
- const idx = lastListState.data.findIndex(
173
- (item) => (item as Record<string, unknown>)[idKey] === currentId
174
- );
175
- return (idx !== -1 ? lastListState.data[idx + 1] : undefined) ?? current;
176
- }
177
- async function navContinueEdit() {
178
- if (!singleInstance) return;
179
- await navEdit(nextInstance(singleInstance));
180
- }
181
-
182
- const resolvedColumns: ColumnDefinition<T>[] = $derived(
183
- columns ??
184
- (meta
185
- ? (Object.entries(meta) as [string, AttributeMetadata][])
186
- .filter(([, m]) => !m.excludedFromList)
187
- .map(([k, m]) => {
188
- const embeddedFields =
189
- m.type === 'embedded' && m.fields
190
- ? buildFieldDefinitions(m.fields, data, 'excludedFromList', new Set())
191
- : undefined;
192
- // Arrays of objects have no sensible raw cell value, so an
193
- // embedded column without an explicit formatter falls back to
194
- // joining each item's label (itemLabel, or the same
195
- // sub-field-joining summary the embedded form list uses).
196
- const resolvedFormatter =
197
- resolveFormatter(m, data) ??
198
- (embeddedFields
199
- ? (value: unknown) =>
200
- Array.isArray(value)
201
- ? value
202
- .map((item) =>
203
- m.itemLabel
204
- ? m.itemLabel(item as Record<string, unknown>)
205
- : defaultItemLabel(embeddedFields, item as Record<string, unknown>)
206
- )
207
- .join(', ')
208
- : ''
209
- : undefined);
210
- const formatter =
211
- m.truncateUpTo != null
212
- ? truncateFormatter(resolvedFormatter, m.truncateUpTo)
213
- : resolvedFormatter;
214
- return {
215
- attribute: k as keyof T & string,
216
- title: m.label ?? k,
217
- type: m.type,
218
- formatter,
219
- component: m.component,
220
- sortable: m.sortable,
221
- filterable: m.filterable,
222
- filterOptions: m.filterOptions,
223
- fields: embeddedFields,
224
- itemLabel: m.itemLabel
225
- };
226
- })
227
- : entityData.length > 0
228
- ? (Object.keys(entityData[0]) as (keyof T & string)[])
229
- .filter((k) => !excluded.has(k))
230
- .map((k) => ({ attribute: k, title: k }))
231
- : [])
232
- );
233
-
234
- // Server-pagination mode only: GenericCRUD owns `page`/`ordering`/per-column
235
- // filter query params the same way it already owns `view`/`id`, so pagination
236
- // state survives reloads/direct links and `+page.svelte` never has to change.
237
- const orderingParam = $derived(page.url.searchParams.get('ordering'));
238
- const initialSort = $derived(
239
- orderingParam
240
- ? {
241
- column: orderingParam.startsWith('-') ? orderingParam.slice(1) : orderingParam,
242
- direction: (orderingParam.startsWith('-') ? 'desc' : 'asc') as SortDirection
243
- }
244
- : undefined
245
- );
246
-
247
- const initialFilters = $derived.by<Partial<FilterSnapshot> | undefined>(() => {
248
- if (!envelope) return undefined;
249
- const text: Record<string, string> = {};
250
- const values: Record<string, string[]> = {};
251
- const dateRanges: Record<string, { from: string; to: string }> = {};
252
- for (const col of resolvedColumns) {
253
- if (!isFilterable(col)) continue;
254
- const raw = page.url.searchParams.get(col.attribute);
255
- if (raw != null) {
256
- if (col.type === 'boolean') values[col.attribute] = raw.split(',').filter(Boolean);
257
- else text[col.attribute] = raw;
258
- }
259
- const from = page.url.searchParams.get(`${col.attribute}_from`);
260
- const to = page.url.searchParams.get(`${col.attribute}_to`);
261
- if (from || to) dateRanges[col.attribute] = { from: from ?? '', to: to ?? '' };
262
- }
263
- return { text, values, dateRanges };
264
- });
265
-
266
- async function handlePaginationChange(query: TableQuery) {
267
- // Local, synchronous query-string builder consumed immediately by goto();
268
- // not rendered/reactive state, so plain URLSearchParams is correct here.
269
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
270
- const params = new URLSearchParams(page.url.searchParams);
271
- params.delete('view');
272
- params.delete('id');
273
-
274
- if (query.page > 1) params.set('page', String(query.page));
275
- else params.delete('page');
276
-
277
- if (query.ordering) params.set('ordering', query.ordering);
278
- else params.delete('ordering');
279
-
280
- for (const col of resolvedColumns) {
281
- params.delete(col.attribute);
282
- params.delete(`${col.attribute}_from`);
283
- params.delete(`${col.attribute}_to`);
284
- }
285
- for (const [k, v] of Object.entries(query.filters.text)) {
286
- if (v) params.set(k, v);
287
- }
288
- for (const [k, vs] of Object.entries(query.filters.values)) {
289
- if (vs.length) params.set(k, vs.join(','));
290
- }
291
- for (const [k, r] of Object.entries(query.filters.dateRanges)) {
292
- if (r.from) params.set(`${k}_from`, r.from);
293
- if (r.to) params.set(`${k}_to`, r.to);
294
- }
295
-
296
- const qs = params.toString();
297
- await goto(qs ? `?${qs}` : '?', { keepFocus: true, noScroll: true });
298
- }
299
-
300
- const resolvedFields: FieldDefinition<T>[] = $derived(
301
- fields ??
302
- (meta
303
- ? buildFieldDefinitions<T>(meta, data, 'excludedFromCreate', excluded)
304
- : entityData.length > 0
305
- ? (Object.entries(entityData[0]) as [string, unknown][])
306
- .filter(([k]) => !excluded.has(k))
307
- .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
308
- : [])
309
- );
310
-
311
- const resolvedReadFields: FieldDefinition<T>[] = $derived(
312
- fields ??
313
- (meta
314
- ? buildFieldDefinitions<T>(meta, data, 'excludedFromRead', excluded)
315
- : entityData.length > 0
316
- ? (Object.entries(entityData[0]) as [string, unknown][])
317
- .filter(([k]) => !excluded.has(k))
318
- .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
319
- : [])
320
- );
321
-
322
- const resolvedUpdateFields: FieldDefinition<T>[] = $derived(
323
- fields ??
324
- (meta
325
- ? buildFieldDefinitions<T>(meta, data, 'excludedFromUpdate', excluded)
326
- : entityData.length > 0
327
- ? (Object.entries(entityData[0]) as [string, unknown][])
328
- .filter(([k]) => !excluded.has(k))
329
- .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
330
- : [])
331
- );
332
-
333
- const serverError = $derived(
334
- creating || reading || editing || activeAction !== null || activeBulkAction !== null
335
- ? (form?.error ?? '')
336
- : ''
337
- );
338
- </script>
339
-
340
- {#if creating}
341
- <Create
342
- {labelOne}
343
- {labelMany}
344
- {icon}
345
- fields={resolvedFields}
346
- {creation}
347
- {serverError}
348
- seed={duplicateSeed}
349
- onCancel={navList}
350
- onSuccess={navList}
351
- />
352
- {:else if reading}
353
- <Read
354
- {labelOne}
355
- {labelMany}
356
- {icon}
357
- {idKey}
358
- fields={resolvedReadFields}
359
- instance={singleInstance ?? ({} as T)}
360
- {read}
361
- onCancel={navList}
362
- />
363
- {:else if editing}
364
- <Update
365
- {labelOne}
366
- {labelMany}
367
- {icon}
368
- {idKey}
369
- fields={resolvedUpdateFields}
370
- instance={singleInstance ?? ({} as T)}
371
- {update}
372
- {serverError}
373
- onCancel={navList}
374
- onSuccess={navList}
375
- onContinue={navContinueEdit}
376
- onDuplicate={navDuplicate}
377
- />
378
- {:else}
379
- <List
380
- data={entityData}
381
- {labelOne}
382
- {labelMany}
383
- {icon}
384
- {pageSize}
385
- {idKey}
386
- {creation}
387
- {update}
388
- {read}
389
- {deletion}
390
- {actions}
391
- {config}
392
- columns={resolvedColumns}
393
- pagination={serverPagination}
394
- {initialSort}
395
- {initialFilters}
396
- onPaginationChange={handlePaginationChange}
397
- onCreate={navCreate}
398
- onEdit={navEdit}
399
- onView={navRead}
400
- onAction={runAction}
401
- onBulkAction={runBulkAction}
402
- />
403
- {/if}
404
-
405
- {#if activeAction !== null}
406
- {@const ActionView = activeAction.action.view}
407
- <ActionView
408
- instance={activeAction.item}
409
- label={activeAction.action.label}
410
- endpoint={activeAction.action.endpoint}
411
- {serverError}
412
- onCancel={() => (activeAction = null)}
413
- onSuccess={() => (activeAction = null)}
414
- />
415
- {/if}
416
-
417
- {#if activeBulkAction !== null}
418
- {@const BulkActionView = activeBulkAction.action.view}
419
- <BulkActionView
420
- items={activeBulkAction.items}
421
- label={activeBulkAction.action.label}
422
- {serverError}
423
- onCancel={() => (activeBulkAction = null)}
424
- onSuccess={() => (activeBulkAction = null)}
425
- />
426
- {/if}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import { page } from '$app/state';
3
+ import { goto } from '$app/navigation';
4
+ import List from './views/list/List.svelte';
5
+ import Read from './views/Read.svelte';
6
+ import Create from './views/Create.svelte';
7
+ import Update from './views/Update.svelte';
8
+ import { AUTO_EXCLUDED } from './utils/constants.js';
9
+ import {
10
+ resolveFormatter,
11
+ truncateFormatter,
12
+ inferType,
13
+ buildFieldDefinitions
14
+ } from './utils/resolution.js';
15
+ import { defaultItemLabel } from './utils/embedded.js';
16
+ import { isFilterable } from '../table/utils.js';
17
+ import type { AttributeMetadata } from '../../types/attribute.js';
18
+ import type {
19
+ ActionConfiguration,
20
+ ColumnDefinition,
21
+ CustomAction,
22
+ FieldDefinition,
23
+ ListActions,
24
+ ListConfig,
25
+ ViewBasedCustomBulkAction
26
+ } from '../../types/crud.js';
27
+ import type {
28
+ FilterSnapshot,
29
+ PaginatedEnvelope,
30
+ ServerPagination,
31
+ SortDirection,
32
+ TableQuery
33
+ } from '../../types/table.js';
34
+
35
+ let {
36
+ data = undefined as Record<string, unknown> | undefined,
37
+ dataKey = undefined as string | undefined,
38
+ idKey = '_id',
39
+ labelOne = '',
40
+ labelMany = '',
41
+ icon,
42
+ pageSize = 10,
43
+ creation = {} as ActionConfiguration<T>,
44
+ update = {} as ActionConfiguration<T>,
45
+ read = {} as ActionConfiguration<T>,
46
+ deletion = {} as ActionConfiguration<T>,
47
+ actions = {} as ListActions<T>,
48
+ config = {} as ListConfig<T>,
49
+ columns = undefined as ColumnDefinition<T>[] | undefined,
50
+ fields = undefined as FieldDefinition<T>[] | undefined,
51
+ meta = undefined as Partial<Record<string, AttributeMetadata>> | undefined,
52
+ form = null as { error?: string } | null
53
+ }: {
54
+ data?: Record<string, unknown>;
55
+ dataKey?: string;
56
+ idKey?: string;
57
+ labelOne?: string;
58
+ labelMany?: string;
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ icon?: any;
61
+ pageSize?: number;
62
+ creation?: ActionConfiguration<T>;
63
+ update?: ActionConfiguration<T>;
64
+ read?: ActionConfiguration<T>;
65
+ deletion?: ActionConfiguration<T>;
66
+ /** Extra per-row (`custom`) and per-selection (`bulk`) actions. */
67
+ actions?: ListActions<T>;
68
+ /** Opt-in list behaviors: free-text search, CSV/Excel export, and
69
+ * drag-to-reorder. */
70
+ config?: ListConfig<T>;
71
+ columns?: ColumnDefinition<T>[];
72
+ fields?: FieldDefinition<T>[];
73
+ meta?: Partial<Record<string, AttributeMetadata>>;
74
+ form?: { error?: string } | null;
75
+ } = $props();
76
+
77
+ function isEnvelope(value: unknown): value is PaginatedEnvelope<T> {
78
+ return (
79
+ !!value &&
80
+ typeof value === 'object' &&
81
+ !Array.isArray(value) &&
82
+ Array.isArray((value as Record<string, unknown>).results) &&
83
+ typeof (value as Record<string, unknown>).count === 'number'
84
+ );
85
+ }
86
+
87
+ const rawSlice = $derived(data && dataKey ? data[dataKey] : undefined);
88
+ const envelope = $derived(isEnvelope(rawSlice) ? rawSlice : undefined);
89
+ const entityData = $derived<T[]>(
90
+ envelope ? envelope.results : Array.isArray(rawSlice) ? (rawSlice as T[]) : []
91
+ );
92
+
93
+ const serverPagination = $derived<ServerPagination | undefined>(
94
+ envelope
95
+ ? {
96
+ page: envelope.page,
97
+ pageSize: envelope.pageSize,
98
+ totalPages: Math.max(1, Math.ceil(envelope.count / envelope.pageSize)),
99
+ total: envelope.count
100
+ }
101
+ : undefined
102
+ );
103
+
104
+ const viewParam = $derived(page.url.searchParams.get('view'));
105
+ const idParam = $derived(page.url.searchParams.get('id'));
106
+
107
+ const creating = $derived(viewParam === 'create');
108
+ const reading = $derived(idParam !== null && viewParam === null);
109
+ const editing = $derived(idParam !== null && viewParam === 'edit');
110
+
111
+ const excluded = $derived(new Set([...AUTO_EXCLUDED, idKey]));
112
+
113
+ const singleInstance = $derived<T | undefined>(
114
+ Object.values(page.data as Record<string, unknown>).find(
115
+ (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && idKey in (v as object)
116
+ ) as T | undefined
117
+ );
118
+
119
+ let activeAction = $state<{ action: CustomAction<T>; item: T } | null>(null);
120
+ let activeBulkAction = $state<{ action: ViewBasedCustomBulkAction<T>; items: T[] } | null>(
121
+ null
122
+ );
123
+
124
+ async function runAction(action: CustomAction<T>, item: T) {
125
+ if (!(action.condition?.(item) ?? true)) return;
126
+ if (action.href) {
127
+ await goto(action.href(item));
128
+ return;
129
+ }
130
+ activeAction = { action, item };
131
+ }
132
+
133
+ function runBulkAction(action: ViewBasedCustomBulkAction<T>, items: T[]) {
134
+ activeBulkAction = { action, items };
135
+ }
136
+
137
+ let duplicateSeed = $state<Record<string, unknown> | undefined>(undefined);
138
+
139
+ async function navList() {
140
+ duplicateSeed = undefined;
141
+ await goto(lastListState.search || '?');
142
+ }
143
+ async function navCreate() {
144
+ duplicateSeed = undefined;
145
+ await goto('?view=create');
146
+ }
147
+ async function navDuplicate(record: Record<string, unknown>) {
148
+ duplicateSeed = record;
149
+ await goto('?view=create');
150
+ }
151
+ async function navRead(item: T) {
152
+ await goto(`?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}`);
153
+ }
154
+ async function navEdit(item: T) {
155
+ await goto(
156
+ `?id=${encodeURIComponent(String((item as Record<string, unknown>)[idKey] ?? ''))}&view=edit`
157
+ );
158
+ }
159
+
160
+ let lastListState = $state<{ data: T[]; search: string }>({ data: [], search: '' });
161
+ $effect(() => {
162
+ if (!creating && !reading && !editing) {
163
+ lastListState = { data: entityData, search: page.url.search };
164
+ }
165
+ });
166
+
167
+ // "Save and continue" on the Update form: jumps to editing the record that
168
+ // follows the current one in the last loaded list, falling back to
169
+ // re-editing the same instance when it's the last one (or isn't found).
170
+ function nextInstance(current: T): T {
171
+ const currentId = (current as Record<string, unknown>)[idKey];
172
+ const idx = lastListState.data.findIndex(
173
+ (item) => (item as Record<string, unknown>)[idKey] === currentId
174
+ );
175
+ return (idx !== -1 ? lastListState.data[idx + 1] : undefined) ?? current;
176
+ }
177
+ async function navContinueEdit() {
178
+ if (!singleInstance) return;
179
+ await navEdit(nextInstance(singleInstance));
180
+ }
181
+
182
+ const resolvedColumns: ColumnDefinition<T>[] = $derived(
183
+ columns ??
184
+ (meta
185
+ ? (Object.entries(meta) as [string, AttributeMetadata][])
186
+ .filter(([, m]) => !m.excludedFromList)
187
+ .map(([k, m]) => {
188
+ const embeddedFields =
189
+ m.type === 'embedded' && m.fields
190
+ ? buildFieldDefinitions(m.fields, data, 'excludedFromList', new Set())
191
+ : undefined;
192
+ // Arrays of objects have no sensible raw cell value, so an
193
+ // embedded column without an explicit formatter falls back to
194
+ // joining each item's label (itemLabel, or the same
195
+ // sub-field-joining summary the embedded form list uses).
196
+ const resolvedFormatter =
197
+ resolveFormatter(m, data) ??
198
+ (embeddedFields
199
+ ? (value: unknown) =>
200
+ Array.isArray(value)
201
+ ? value
202
+ .map((item) =>
203
+ m.itemLabel
204
+ ? m.itemLabel(item as Record<string, unknown>)
205
+ : defaultItemLabel(embeddedFields, item as Record<string, unknown>)
206
+ )
207
+ .join(', ')
208
+ : ''
209
+ : undefined);
210
+ const formatter =
211
+ m.truncateUpTo != null
212
+ ? truncateFormatter(resolvedFormatter, m.truncateUpTo)
213
+ : resolvedFormatter;
214
+ return {
215
+ attribute: k as keyof T & string,
216
+ title: m.label ?? k,
217
+ type: m.type,
218
+ formatter,
219
+ component: m.component,
220
+ sortable: m.sortable,
221
+ filterable: m.filterable,
222
+ filterOptions: m.filterOptions,
223
+ fields: embeddedFields,
224
+ itemLabel: m.itemLabel
225
+ };
226
+ })
227
+ : entityData.length > 0
228
+ ? (Object.keys(entityData[0]) as (keyof T & string)[])
229
+ .filter((k) => !excluded.has(k))
230
+ .map((k) => ({ attribute: k, title: k }))
231
+ : [])
232
+ );
233
+
234
+ // Server-pagination mode only: GenericCRUD owns `page`/`ordering`/per-column
235
+ // filter query params the same way it already owns `view`/`id`, so pagination
236
+ // state survives reloads/direct links and `+page.svelte` never has to change.
237
+ const orderingParam = $derived(page.url.searchParams.get('ordering'));
238
+ const initialSort = $derived(
239
+ orderingParam
240
+ ? {
241
+ column: orderingParam.startsWith('-') ? orderingParam.slice(1) : orderingParam,
242
+ direction: (orderingParam.startsWith('-') ? 'desc' : 'asc') as SortDirection
243
+ }
244
+ : undefined
245
+ );
246
+
247
+ const initialFilters = $derived.by<Partial<FilterSnapshot> | undefined>(() => {
248
+ if (!envelope) return undefined;
249
+ const text: Record<string, string> = {};
250
+ const values: Record<string, string[]> = {};
251
+ const dateRanges: Record<string, { from: string; to: string }> = {};
252
+ for (const col of resolvedColumns) {
253
+ if (!isFilterable(col)) continue;
254
+ const raw = page.url.searchParams.get(col.attribute);
255
+ if (raw != null) {
256
+ if (col.type === 'boolean') values[col.attribute] = raw.split(',').filter(Boolean);
257
+ else text[col.attribute] = raw;
258
+ }
259
+ const from = page.url.searchParams.get(`${col.attribute}_from`);
260
+ const to = page.url.searchParams.get(`${col.attribute}_to`);
261
+ if (from || to) dateRanges[col.attribute] = { from: from ?? '', to: to ?? '' };
262
+ }
263
+ return { text, values, dateRanges };
264
+ });
265
+
266
+ async function handlePaginationChange(query: TableQuery) {
267
+ // Local, synchronous query-string builder consumed immediately by goto();
268
+ // not rendered/reactive state, so plain URLSearchParams is correct here.
269
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
270
+ const params = new URLSearchParams(page.url.searchParams);
271
+ params.delete('view');
272
+ params.delete('id');
273
+
274
+ if (query.page > 1) params.set('page', String(query.page));
275
+ else params.delete('page');
276
+
277
+ if (query.ordering) params.set('ordering', query.ordering);
278
+ else params.delete('ordering');
279
+
280
+ for (const col of resolvedColumns) {
281
+ params.delete(col.attribute);
282
+ params.delete(`${col.attribute}_from`);
283
+ params.delete(`${col.attribute}_to`);
284
+ }
285
+ for (const [k, v] of Object.entries(query.filters.text)) {
286
+ if (v) params.set(k, v);
287
+ }
288
+ for (const [k, vs] of Object.entries(query.filters.values)) {
289
+ if (vs.length) params.set(k, vs.join(','));
290
+ }
291
+ for (const [k, r] of Object.entries(query.filters.dateRanges)) {
292
+ if (r.from) params.set(`${k}_from`, r.from);
293
+ if (r.to) params.set(`${k}_to`, r.to);
294
+ }
295
+
296
+ const qs = params.toString();
297
+ await goto(qs ? `?${qs}` : '?', { keepFocus: true, noScroll: true });
298
+ }
299
+
300
+ const resolvedFields: FieldDefinition<T>[] = $derived(
301
+ fields ??
302
+ (meta
303
+ ? buildFieldDefinitions<T>(meta, data, 'excludedFromCreate', excluded)
304
+ : entityData.length > 0
305
+ ? (Object.entries(entityData[0]) as [string, unknown][])
306
+ .filter(([k]) => !excluded.has(k))
307
+ .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
308
+ : [])
309
+ );
310
+
311
+ const resolvedReadFields: FieldDefinition<T>[] = $derived(
312
+ fields ??
313
+ (meta
314
+ ? buildFieldDefinitions<T>(meta, data, 'excludedFromRead', excluded)
315
+ : entityData.length > 0
316
+ ? (Object.entries(entityData[0]) as [string, unknown][])
317
+ .filter(([k]) => !excluded.has(k))
318
+ .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
319
+ : [])
320
+ );
321
+
322
+ const resolvedUpdateFields: FieldDefinition<T>[] = $derived(
323
+ fields ??
324
+ (meta
325
+ ? buildFieldDefinitions<T>(meta, data, 'excludedFromUpdate', excluded)
326
+ : entityData.length > 0
327
+ ? (Object.entries(entityData[0]) as [string, unknown][])
328
+ .filter(([k]) => !excluded.has(k))
329
+ .map(([k, v]) => ({ attribute: k as keyof T & string, type: inferType(k, v) }))
330
+ : [])
331
+ );
332
+
333
+ const serverError = $derived(
334
+ creating || reading || editing || activeAction !== null || activeBulkAction !== null
335
+ ? (form?.error ?? '')
336
+ : ''
337
+ );
338
+ </script>
339
+
340
+ {#if creating}
341
+ <Create
342
+ {labelOne}
343
+ {labelMany}
344
+ {icon}
345
+ fields={resolvedFields}
346
+ {creation}
347
+ {serverError}
348
+ seed={duplicateSeed}
349
+ onCancel={navList}
350
+ onSuccess={navList}
351
+ />
352
+ {:else if reading}
353
+ <Read
354
+ {labelOne}
355
+ {labelMany}
356
+ {icon}
357
+ {idKey}
358
+ fields={resolvedReadFields}
359
+ instance={singleInstance ?? ({} as T)}
360
+ {read}
361
+ onCancel={navList}
362
+ />
363
+ {:else if editing}
364
+ <Update
365
+ {labelOne}
366
+ {labelMany}
367
+ {icon}
368
+ {idKey}
369
+ fields={resolvedUpdateFields}
370
+ instance={singleInstance ?? ({} as T)}
371
+ {update}
372
+ {serverError}
373
+ onCancel={navList}
374
+ onSuccess={navList}
375
+ onContinue={navContinueEdit}
376
+ onDuplicate={navDuplicate}
377
+ />
378
+ {:else}
379
+ <List
380
+ data={entityData}
381
+ {labelOne}
382
+ {labelMany}
383
+ {icon}
384
+ {pageSize}
385
+ {idKey}
386
+ {creation}
387
+ {update}
388
+ {read}
389
+ {deletion}
390
+ {actions}
391
+ {config}
392
+ columns={resolvedColumns}
393
+ pagination={serverPagination}
394
+ {initialSort}
395
+ {initialFilters}
396
+ onPaginationChange={handlePaginationChange}
397
+ onCreate={navCreate}
398
+ onEdit={navEdit}
399
+ onView={navRead}
400
+ onAction={runAction}
401
+ onBulkAction={runBulkAction}
402
+ />
403
+ {/if}
404
+
405
+ {#if activeAction !== null}
406
+ {@const ActionView = activeAction.action.view}
407
+ <ActionView
408
+ instance={activeAction.item}
409
+ label={activeAction.action.label}
410
+ endpoint={activeAction.action.endpoint}
411
+ {serverError}
412
+ onCancel={() => (activeAction = null)}
413
+ onSuccess={() => (activeAction = null)}
414
+ />
415
+ {/if}
416
+
417
+ {#if activeBulkAction !== null}
418
+ {@const BulkActionView = activeBulkAction.action.view}
419
+ <BulkActionView
420
+ items={activeBulkAction.items}
421
+ label={activeBulkAction.action.label}
422
+ {serverError}
423
+ onCancel={() => (activeBulkAction = null)}
424
+ onSuccess={() => (activeBulkAction = null)}
425
+ />
426
+ {/if}