runeforge 0.0.55 → 0.0.57

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 (61) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1355 -1342
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +73 -22
  5. package/dist/components/IconRenderer.svelte.d.ts +2 -0
  6. package/dist/components/Modal.svelte +75 -75
  7. package/dist/components/common/Header.svelte +40 -40
  8. package/dist/components/crud/EmbeddedField.svelte +175 -175
  9. package/dist/components/crud/Field.svelte +469 -376
  10. package/dist/components/crud/GenericCRUD.svelte +426 -426
  11. package/dist/components/crud/SearchInput.svelte +53 -53
  12. package/dist/components/crud/columns/Avatar.svelte +15 -15
  13. package/dist/components/crud/columns/Icon.svelte +8 -8
  14. package/dist/components/crud/views/Create.svelte +232 -232
  15. package/dist/components/crud/views/Read.svelte +124 -124
  16. package/dist/components/crud/views/Update.svelte +208 -208
  17. package/dist/components/crud/views/list/List.svelte +291 -291
  18. package/dist/components/crud/views/list/Modals.svelte +56 -56
  19. package/dist/components/crud/views/list/Table.svelte +176 -176
  20. package/dist/components/crud/views/list/Toolbar.svelte +341 -341
  21. package/dist/components/form/Button.svelte +27 -27
  22. package/dist/components/form/Label.svelte +37 -37
  23. package/dist/components/form/MultiSelect.svelte +248 -248
  24. package/dist/components/form/PasswordInput.svelte +68 -68
  25. package/dist/components/form/Required.svelte +1 -1
  26. package/dist/components/form/Select.svelte +209 -209
  27. package/dist/components/form/Tree.svelte +62 -62
  28. package/dist/components/form/TreeNode.svelte +66 -66
  29. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  30. package/dist/components/table/ColumnFilter.svelte +168 -168
  31. package/dist/components/table/PaginatedTable.svelte +536 -536
  32. package/dist/components/table/Paginator.svelte +113 -113
  33. package/dist/components/table/SortHeader.svelte +43 -43
  34. package/dist/components/table/TableBody.svelte +150 -150
  35. package/dist/components/table/TableHeader.svelte +88 -88
  36. package/dist/i18n/en.js +1 -0
  37. package/dist/i18n/es.js +1 -0
  38. package/dist/i18n/types.d.ts +1 -0
  39. package/dist/icons/context.d.ts +5 -0
  40. package/dist/icons/context.js +10 -0
  41. package/dist/icons/defaults/Clear.svelte +6 -6
  42. package/dist/icons/defaults/Create.svelte +6 -6
  43. package/dist/icons/defaults/Delete.svelte +6 -6
  44. package/dist/icons/defaults/Download.svelte +7 -7
  45. package/dist/icons/defaults/Edit.svelte +7 -7
  46. package/dist/icons/defaults/Filter.svelte +6 -6
  47. package/dist/icons/defaults/FilterActive.svelte +6 -6
  48. package/dist/icons/defaults/Folder.svelte +6 -6
  49. package/dist/icons/defaults/Grip.svelte +7 -7
  50. package/dist/icons/defaults/Home.svelte +6 -6
  51. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  52. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  53. package/dist/icons/defaults/SortAsc.svelte +6 -6
  54. package/dist/icons/defaults/SortDesc.svelte +6 -6
  55. package/dist/icons/defaults/SortNone.svelte +6 -6
  56. package/dist/icons/defaults/View.svelte +7 -7
  57. package/dist/icons/sets/bootstrap.js +1 -4
  58. package/dist/icons/types.d.ts +0 -1
  59. package/dist/index.d.ts +1 -1
  60. package/dist/index.js +1 -1
  61. package/package.json +1 -1
@@ -1,124 +1,124 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import { untrack } from 'svelte';
3
- import { deserialize } from '$app/forms';
4
- import Field from '../Field.svelte';
5
- import Button from '../../form/Button.svelte';
6
- import Header from '../../common/Header.svelte';
7
- import { getIconSet } from '../../../icons/context.js';
8
- import { defaultIconSet } from '../../../icons/sets/default.js';
9
- import { groupFields } from '../utils/grouping.js';
10
- import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
11
- import { getStrings } from '../../../i18n/context.js';
12
-
13
- const strings = getStrings();
14
-
15
- let {
16
- labelOne = '',
17
- labelMany = '',
18
- icon,
19
- idKey = '_id',
20
- fields = [] as FieldDefinition<T>[],
21
- instance = {} as T,
22
- read = {} as ActionConfiguration<T>,
23
- onCancel,
24
- }: {
25
- labelOne?: string;
26
- labelMany?: string;
27
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
- icon?: any;
29
- idKey?: string;
30
- fields?: FieldDefinition<T>[];
31
- instance?: T;
32
- read?: ActionConfiguration<T>;
33
- onCancel?: () => void;
34
- } = $props();
35
-
36
- const icons = $derived(getIconSet() ?? defaultIconSet);
37
- const entityIcon = $derived(icon ?? icons.folder);
38
-
39
- let record = $state<Record<string, unknown>>(untrack(() => instance as Record<string, unknown>));
40
-
41
- $effect(() => {
42
- const endpoint = read.endpoint;
43
- const id = (instance as Record<string, unknown>)[idKey];
44
- if (!endpoint || id == null) return;
45
-
46
- let cancelled = false;
47
- (async () => {
48
- const fd = new FormData();
49
- fd.set('id', String(id));
50
- const res = await fetch(endpoint, { method: 'POST', body: fd });
51
- const result = deserialize(await res.text());
52
- if (!cancelled && result.type === 'success') {
53
- const data = result.data as Record<string, unknown> | undefined;
54
- const fetched = data
55
- ? Object.values(data).find(
56
- (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && idKey in (v as object)
57
- )
58
- : undefined;
59
- if (fetched && typeof fetched === 'object') record = fetched as Record<string, unknown>;
60
- }
61
- })();
62
- return () => { cancelled = true; };
63
- });
64
-
65
- const groups = $derived(groupFields(fields));
66
- </script>
67
-
68
- <div class="flex flex-col gap-6">
69
-
70
- <Header
71
- title={labelMany}
72
- breadcrumbs={[
73
- { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
74
- { label: read.label ?? labelOne, icon: icons.view },
75
- ]}
76
- />
77
-
78
- <div class="fields-panel mx-auto flex w-full flex-col gap-4 px-4">
79
- {#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
80
- {#if group.title}
81
- <fieldset class="fieldset border border-base-300 rounded-box p-4">
82
- <legend class="fieldset-legend px-2">{group.title}</legend>
83
- <div class="flex flex-col gap-4">
84
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
85
- {#if row.length > 1}
86
- <div class="flex flex-col gap-4 md:flex-row">
87
- {#each row as field (field.attribute)}
88
- <Field {field} {record} readonly class="md:min-w-0 md:flex-1" />
89
- {/each}
90
- </div>
91
- {:else}
92
- <Field field={row[0]} {record} readonly />
93
- {/if}
94
- {/each}
95
- </div>
96
- </fieldset>
97
- {:else}
98
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
99
- {#if row.length > 1}
100
- <div class="flex flex-col gap-4 md:flex-row">
101
- {#each row as field (field.attribute)}
102
- <Field {field} {record} readonly class="md:min-w-0 md:flex-1" />
103
- {/each}
104
- </div>
105
- {:else}
106
- <Field field={row[0]} {record} readonly />
107
- {/if}
108
- {/each}
109
- {/if}
110
- {/each}
111
-
112
- <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
113
- <Button variant="ghost" onclick={() => onCancel?.()}>
114
- {strings.back}
115
- </Button>
116
- </div>
117
- </div>
118
- </div>
119
-
120
- <style>
121
- .fields-panel {
122
- max-width: var(--runeforge-form-max-width, 32rem);
123
- }
124
- </style>
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import { untrack } from 'svelte';
3
+ import { deserialize } from '$app/forms';
4
+ import Field from '../Field.svelte';
5
+ import Button from '../../form/Button.svelte';
6
+ import Header from '../../common/Header.svelte';
7
+ import { getIconSet } from '../../../icons/context.js';
8
+ import { defaultIconSet } from '../../../icons/sets/default.js';
9
+ import { groupFields } from '../utils/grouping.js';
10
+ import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
11
+ import { getStrings } from '../../../i18n/context.js';
12
+
13
+ const strings = getStrings();
14
+
15
+ let {
16
+ labelOne = '',
17
+ labelMany = '',
18
+ icon,
19
+ idKey = '_id',
20
+ fields = [] as FieldDefinition<T>[],
21
+ instance = {} as T,
22
+ read = {} as ActionConfiguration<T>,
23
+ onCancel,
24
+ }: {
25
+ labelOne?: string;
26
+ labelMany?: string;
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ icon?: any;
29
+ idKey?: string;
30
+ fields?: FieldDefinition<T>[];
31
+ instance?: T;
32
+ read?: ActionConfiguration<T>;
33
+ onCancel?: () => void;
34
+ } = $props();
35
+
36
+ const icons = $derived(getIconSet() ?? defaultIconSet);
37
+ const entityIcon = $derived(icon ?? icons.folder);
38
+
39
+ let record = $state<Record<string, unknown>>(untrack(() => instance as Record<string, unknown>));
40
+
41
+ $effect(() => {
42
+ const endpoint = read.endpoint;
43
+ const id = (instance as Record<string, unknown>)[idKey];
44
+ if (!endpoint || id == null) return;
45
+
46
+ let cancelled = false;
47
+ (async () => {
48
+ const fd = new FormData();
49
+ fd.set('id', String(id));
50
+ const res = await fetch(endpoint, { method: 'POST', body: fd });
51
+ const result = deserialize(await res.text());
52
+ if (!cancelled && result.type === 'success') {
53
+ const data = result.data as Record<string, unknown> | undefined;
54
+ const fetched = data
55
+ ? Object.values(data).find(
56
+ (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && idKey in (v as object)
57
+ )
58
+ : undefined;
59
+ if (fetched && typeof fetched === 'object') record = fetched as Record<string, unknown>;
60
+ }
61
+ })();
62
+ return () => { cancelled = true; };
63
+ });
64
+
65
+ const groups = $derived(groupFields(fields));
66
+ </script>
67
+
68
+ <div class="flex flex-col gap-6">
69
+
70
+ <Header
71
+ title={labelMany}
72
+ breadcrumbs={[
73
+ { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
74
+ { label: read.label ?? labelOne, icon: icons.view },
75
+ ]}
76
+ />
77
+
78
+ <div class="fields-panel mx-auto flex w-full flex-col gap-4 px-4">
79
+ {#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
80
+ {#if group.title}
81
+ <fieldset class="fieldset border border-base-300 rounded-box p-4">
82
+ <legend class="fieldset-legend px-2">{group.title}</legend>
83
+ <div class="flex flex-col gap-4">
84
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
85
+ {#if row.length > 1}
86
+ <div class="flex flex-col gap-4 md:flex-row">
87
+ {#each row as field (field.attribute)}
88
+ <Field {field} {record} readonly class="md:min-w-0 md:flex-1" />
89
+ {/each}
90
+ </div>
91
+ {:else}
92
+ <Field field={row[0]} {record} readonly />
93
+ {/if}
94
+ {/each}
95
+ </div>
96
+ </fieldset>
97
+ {:else}
98
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
99
+ {#if row.length > 1}
100
+ <div class="flex flex-col gap-4 md:flex-row">
101
+ {#each row as field (field.attribute)}
102
+ <Field {field} {record} readonly class="md:min-w-0 md:flex-1" />
103
+ {/each}
104
+ </div>
105
+ {:else}
106
+ <Field field={row[0]} {record} readonly />
107
+ {/if}
108
+ {/each}
109
+ {/if}
110
+ {/each}
111
+
112
+ <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
113
+ <Button variant="ghost" onclick={() => onCancel?.()}>
114
+ {strings.back}
115
+ </Button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ <style>
121
+ .fields-panel {
122
+ max-width: var(--runeforge-form-max-width, 32rem);
123
+ }
124
+ </style>
@@ -1,208 +1,208 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import { untrack } from 'svelte';
3
- import { enhance } from '$app/forms';
4
- import Field from '../Field.svelte';
5
- import Button from '../../form/Button.svelte';
6
- import Header from '../../common/Header.svelte';
7
- import { getIconSet } from '../../../icons/context.js';
8
- import { defaultIconSet } from '../../../icons/sets/default.js';
9
- import { validateAll } from '../utils/validation.js';
10
- import { groupFields } from '../utils/grouping.js';
11
- import { applyDuplicateOmit, seedRecord } from '../utils/embedded.js';
12
- import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
13
- import { getStrings } from '../../../i18n/context.js';
14
-
15
- const strings = getStrings();
16
-
17
- let {
18
- labelOne = '',
19
- labelMany = '',
20
- icon,
21
- idKey = '_id',
22
- fields = [] as FieldDefinition<T>[],
23
- instance = {} as T,
24
- update = {} as ActionConfiguration<T>,
25
- serverError = '',
26
- onCancel,
27
- onSuccess,
28
- onContinue,
29
- onDuplicate,
30
- }: {
31
- labelOne?: string;
32
- labelMany?: string;
33
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- icon?: any;
35
- idKey?: string;
36
- fields?: FieldDefinition<T>[];
37
- instance?: T;
38
- update?: ActionConfiguration<T>;
39
- serverError?: string;
40
- onCancel?: () => void;
41
- onSuccess?: () => void;
42
- onContinue?: () => void;
43
- onDuplicate?: (record: Record<string, unknown>) => void;
44
- } = $props();
45
-
46
- const icons = $derived(getIconSet() ?? defaultIconSet);
47
- const entityIcon = $derived(icon ?? icons.folder);
48
-
49
- let fieldErrors = $state<Record<string, string>>({});
50
- let internalError = $state('');
51
- let continuing = $state(false);
52
- let duplicating = $state(false);
53
-
54
- let record = $state<Record<string, unknown>>(
55
- untrack(() => seedRecord(fields, instance as Record<string, unknown>))
56
- );
57
-
58
- $effect(() => {
59
- const id = (instance as Record<string, unknown>)[idKey];
60
- if (!id) return;
61
- untrack(() => { record = seedRecord(fields, instance as Record<string, unknown>); });
62
- });
63
-
64
- const groups = $derived(groupFields(fields));
65
- const hasFileField = $derived(fields.some((f) => f.type === 'file'));
66
-
67
- const continueEnabled = $derived(update.continue?.enabled ?? false);
68
- const continueLabel = $derived(update.continue?.label ?? strings.saveAndContinue);
69
- const continueClass = $derived(update.continue?.class ?? '');
70
-
71
- const duplicationEnabled = $derived(update.duplication?.enabled ?? false);
72
- const duplicationLabel = $derived(update.duplication?.label ?? strings.duplicate);
73
- const duplicationClass = $derived(update.duplication?.class ?? '');
74
-
75
- const errorEntries = $derived([
76
- ...((serverError || internalError) ? [['_global', internalError || serverError] as [string, string]] : []),
77
- ...Object.entries(fieldErrors),
78
- ]);
79
- </script>
80
-
81
- <div class="flex flex-col gap-6">
82
-
83
- <Header
84
- title={labelMany}
85
- breadcrumbs={[
86
- { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
87
- { label: update.label ?? labelOne, icon: icons.edit },
88
- ]}
89
- />
90
-
91
- {#if errorEntries.length > 0}
92
- <div role="alert" class="alert alert-error">
93
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
94
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
95
- </svg>
96
- <ul class="list-disc list-inside text-sm">
97
- {#each errorEntries as [key, msg] (key)}
98
- <li>{msg}</li>
99
- {/each}
100
- </ul>
101
- </div>
102
- {/if}
103
-
104
- <form
105
- method="POST"
106
- action={update.endpoint ?? '?/update'}
107
- enctype={hasFileField ? 'multipart/form-data' : undefined}
108
- class="mx-auto flex w-full flex-col gap-4 px-4"
109
- use:enhance={({ formData, cancel }) => {
110
- fieldErrors = {};
111
- internalError = '';
112
- const errs = validateAll(fields, formData, strings);
113
- if (Object.keys(errs).length > 0) {
114
- fieldErrors = errs;
115
- cancel();
116
- return;
117
- }
118
- return async ({ result, update: updateForm }) => {
119
- if (result.type === 'success' || result.type === 'redirect') {
120
- await updateForm({ reset: false });
121
- if (continuing) {
122
- continuing = false;
123
- onContinue?.();
124
- } else if (duplicating) {
125
- duplicating = false;
126
- onDuplicate?.(applyDuplicateOmit(fields, record, update.duplication?.omit));
127
- } else {
128
- onSuccess?.();
129
- }
130
- } else if (result.type === 'error') {
131
- internalError = result.error?.message ?? strings.serverError;
132
- } else {
133
- await updateForm({ reset: false });
134
- }
135
- };
136
- }}
137
- >
138
- <input type="hidden" name="id" value={String(record[idKey] ?? '')} />
139
-
140
- {#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
141
- {#if group.title}
142
- <fieldset class="fieldset border border-base-300 rounded-box p-4">
143
- <legend class="fieldset-legend px-2">{group.title}</legend>
144
- <div class="flex flex-col gap-4">
145
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
146
- {#if row.length > 1}
147
- <div class="flex flex-col gap-4 md:flex-row">
148
- {#each row as field (field.attribute)}
149
- <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
150
- {/each}
151
- </div>
152
- {:else}
153
- <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
154
- {/if}
155
- {/each}
156
- </div>
157
- </fieldset>
158
- {:else}
159
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
160
- {#if row.length > 1}
161
- <div class="flex flex-col gap-4 md:flex-row">
162
- {#each row as field (field.attribute)}
163
- <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
164
- {/each}
165
- </div>
166
- {:else}
167
- <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
168
- {/if}
169
- {/each}
170
- {/if}
171
- {/each}
172
-
173
- <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
174
- <Button variant="ghost" onclick={() => onCancel?.()}>
175
- {strings.cancel}
176
- </Button>
177
- {#if duplicationEnabled}
178
- <Button
179
- type="submit"
180
- variant="warning"
181
- class={duplicationClass}
182
- onclick={() => { continuing = false; duplicating = true; }}
183
- >
184
- {duplicationLabel}
185
- </Button>
186
- {/if}
187
- {#if continueEnabled}
188
- <Button
189
- type="submit"
190
- variant="secondary"
191
- class={continueClass}
192
- onclick={() => { continuing = true; duplicating = false; }}
193
- >
194
- {continueLabel}
195
- </Button>
196
- {/if}
197
- <Button type="submit" variant="primary" onclick={() => { continuing = false; duplicating = false; }}>
198
- {strings.save}
199
- </Button>
200
- </div>
201
- </form>
202
- </div>
203
-
204
- <style>
205
- form {
206
- max-width: var(--runeforge-form-max-width, 32rem);
207
- }
208
- </style>
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import { untrack } from 'svelte';
3
+ import { enhance } from '$app/forms';
4
+ import Field from '../Field.svelte';
5
+ import Button from '../../form/Button.svelte';
6
+ import Header from '../../common/Header.svelte';
7
+ import { getIconSet } from '../../../icons/context.js';
8
+ import { defaultIconSet } from '../../../icons/sets/default.js';
9
+ import { validateAll } from '../utils/validation.js';
10
+ import { groupFields } from '../utils/grouping.js';
11
+ import { applyDuplicateOmit, seedRecord } from '../utils/embedded.js';
12
+ import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
13
+ import { getStrings } from '../../../i18n/context.js';
14
+
15
+ const strings = getStrings();
16
+
17
+ let {
18
+ labelOne = '',
19
+ labelMany = '',
20
+ icon,
21
+ idKey = '_id',
22
+ fields = [] as FieldDefinition<T>[],
23
+ instance = {} as T,
24
+ update = {} as ActionConfiguration<T>,
25
+ serverError = '',
26
+ onCancel,
27
+ onSuccess,
28
+ onContinue,
29
+ onDuplicate,
30
+ }: {
31
+ labelOne?: string;
32
+ labelMany?: string;
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ icon?: any;
35
+ idKey?: string;
36
+ fields?: FieldDefinition<T>[];
37
+ instance?: T;
38
+ update?: ActionConfiguration<T>;
39
+ serverError?: string;
40
+ onCancel?: () => void;
41
+ onSuccess?: () => void;
42
+ onContinue?: () => void;
43
+ onDuplicate?: (record: Record<string, unknown>) => void;
44
+ } = $props();
45
+
46
+ const icons = $derived(getIconSet() ?? defaultIconSet);
47
+ const entityIcon = $derived(icon ?? icons.folder);
48
+
49
+ let fieldErrors = $state<Record<string, string>>({});
50
+ let internalError = $state('');
51
+ let continuing = $state(false);
52
+ let duplicating = $state(false);
53
+
54
+ let record = $state<Record<string, unknown>>(
55
+ untrack(() => seedRecord(fields, instance as Record<string, unknown>))
56
+ );
57
+
58
+ $effect(() => {
59
+ const id = (instance as Record<string, unknown>)[idKey];
60
+ if (!id) return;
61
+ untrack(() => { record = seedRecord(fields, instance as Record<string, unknown>); });
62
+ });
63
+
64
+ const groups = $derived(groupFields(fields));
65
+ const hasFileField = $derived(fields.some((f) => f.type === 'file'));
66
+
67
+ const continueEnabled = $derived(update.continue?.enabled ?? false);
68
+ const continueLabel = $derived(update.continue?.label ?? strings.saveAndContinue);
69
+ const continueClass = $derived(update.continue?.class ?? '');
70
+
71
+ const duplicationEnabled = $derived(update.duplication?.enabled ?? false);
72
+ const duplicationLabel = $derived(update.duplication?.label ?? strings.duplicate);
73
+ const duplicationClass = $derived(update.duplication?.class ?? '');
74
+
75
+ const errorEntries = $derived([
76
+ ...((serverError || internalError) ? [['_global', internalError || serverError] as [string, string]] : []),
77
+ ...Object.entries(fieldErrors),
78
+ ]);
79
+ </script>
80
+
81
+ <div class="flex flex-col gap-6">
82
+
83
+ <Header
84
+ title={labelMany}
85
+ breadcrumbs={[
86
+ { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
87
+ { label: update.label ?? labelOne, icon: icons.edit },
88
+ ]}
89
+ />
90
+
91
+ {#if errorEntries.length > 0}
92
+ <div role="alert" class="alert alert-error">
93
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
94
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
95
+ </svg>
96
+ <ul class="list-disc list-inside text-sm">
97
+ {#each errorEntries as [key, msg] (key)}
98
+ <li>{msg}</li>
99
+ {/each}
100
+ </ul>
101
+ </div>
102
+ {/if}
103
+
104
+ <form
105
+ method="POST"
106
+ action={update.endpoint ?? '?/update'}
107
+ enctype={hasFileField ? 'multipart/form-data' : undefined}
108
+ class="mx-auto flex w-full flex-col gap-4 px-4"
109
+ use:enhance={({ formData, cancel }) => {
110
+ fieldErrors = {};
111
+ internalError = '';
112
+ const errs = validateAll(fields, formData, strings);
113
+ if (Object.keys(errs).length > 0) {
114
+ fieldErrors = errs;
115
+ cancel();
116
+ return;
117
+ }
118
+ return async ({ result, update: updateForm }) => {
119
+ if (result.type === 'success' || result.type === 'redirect') {
120
+ await updateForm({ reset: false });
121
+ if (continuing) {
122
+ continuing = false;
123
+ onContinue?.();
124
+ } else if (duplicating) {
125
+ duplicating = false;
126
+ onDuplicate?.(applyDuplicateOmit(fields, record, update.duplication?.omit));
127
+ } else {
128
+ onSuccess?.();
129
+ }
130
+ } else if (result.type === 'error') {
131
+ internalError = result.error?.message ?? strings.serverError;
132
+ } else {
133
+ await updateForm({ reset: false });
134
+ }
135
+ };
136
+ }}
137
+ >
138
+ <input type="hidden" name="id" value={String(record[idKey] ?? '')} />
139
+
140
+ {#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
141
+ {#if group.title}
142
+ <fieldset class="fieldset border border-base-300 rounded-box p-4">
143
+ <legend class="fieldset-legend px-2">{group.title}</legend>
144
+ <div class="flex flex-col gap-4">
145
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
146
+ {#if row.length > 1}
147
+ <div class="flex flex-col gap-4 md:flex-row">
148
+ {#each row as field (field.attribute)}
149
+ <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
150
+ {/each}
151
+ </div>
152
+ {:else}
153
+ <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
154
+ {/if}
155
+ {/each}
156
+ </div>
157
+ </fieldset>
158
+ {:else}
159
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
160
+ {#if row.length > 1}
161
+ <div class="flex flex-col gap-4 md:flex-row">
162
+ {#each row as field (field.attribute)}
163
+ <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
164
+ {/each}
165
+ </div>
166
+ {:else}
167
+ <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
168
+ {/if}
169
+ {/each}
170
+ {/if}
171
+ {/each}
172
+
173
+ <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
174
+ <Button variant="ghost" onclick={() => onCancel?.()}>
175
+ {strings.cancel}
176
+ </Button>
177
+ {#if duplicationEnabled}
178
+ <Button
179
+ type="submit"
180
+ variant="warning"
181
+ class={duplicationClass}
182
+ onclick={() => { continuing = false; duplicating = true; }}
183
+ >
184
+ {duplicationLabel}
185
+ </Button>
186
+ {/if}
187
+ {#if continueEnabled}
188
+ <Button
189
+ type="submit"
190
+ variant="secondary"
191
+ class={continueClass}
192
+ onclick={() => { continuing = true; duplicating = false; }}
193
+ >
194
+ {continueLabel}
195
+ </Button>
196
+ {/if}
197
+ <Button type="submit" variant="primary" onclick={() => { continuing = false; duplicating = false; }}>
198
+ {strings.save}
199
+ </Button>
200
+ </div>
201
+ </form>
202
+ </div>
203
+
204
+ <style>
205
+ form {
206
+ max-width: var(--runeforge-form-max-width, 32rem);
207
+ }
208
+ </style>