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,53 +1,53 @@
1
- <script lang="ts">
2
- import { page } from '$app/state';
3
- import { goto } from '$app/navigation';
4
- import { getStrings } from '../../i18n/context.js';
5
- import type { SearchConfiguration } from '../../types/crud.js';
6
-
7
- const strings = getStrings();
8
-
9
- let {
10
- config = {} as SearchConfiguration
11
- }: {
12
- config?: SearchConfiguration;
13
- } = $props();
14
-
15
- const param = $derived(config.param ?? 'search');
16
- const debounceMs = $derived(config.debounceMs ?? 300);
17
-
18
- // Intentional one-time hydration of local state from the initial `param`
19
- // value (not a live binding) - svelte-check's state_referenced_locally
20
- // warning is a false positive here, same as PaginatedTable's initialSort
21
- // etc. After mount this is the source of truth for what the user is
22
- // typing, so it doesn't fight with the URL updates this component itself
23
- // triggers on every keystroke.
24
- let value = $state(page.url.searchParams.get(param) ?? '');
25
- let debounceTimer: ReturnType<typeof setTimeout>;
26
-
27
- function onInput() {
28
- clearTimeout(debounceTimer);
29
- debounceTimer = setTimeout(() => {
30
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
31
- const params = new URLSearchParams(page.url.searchParams);
32
- const trimmed = value.trim();
33
- if (trimmed) params.set(param, trimmed);
34
- else params.delete(param);
35
- // A new search term can leave the current page past the end of the
36
- // narrowed result set, and any open create/read/edit view stops
37
- // making sense once the underlying list changes - drop both.
38
- params.delete('page');
39
- params.delete('view');
40
- params.delete('id');
41
- const qs = params.toString();
42
- goto(qs ? `?${qs}` : '?', { keepFocus: true, noScroll: true, replaceState: true });
43
- }, debounceMs);
44
- }
45
- </script>
46
-
47
- <input
48
- type="search"
49
- bind:value
50
- oninput={onInput}
51
- placeholder={config.placeholder ?? strings.searchPlaceholder}
52
- class="input input-bordered w-64 shrink-0"
53
- />
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ import { goto } from '$app/navigation';
4
+ import { getStrings } from '../../i18n/context.js';
5
+ import type { SearchConfiguration } from '../../types/crud.js';
6
+
7
+ const strings = getStrings();
8
+
9
+ let {
10
+ config = {} as SearchConfiguration
11
+ }: {
12
+ config?: SearchConfiguration;
13
+ } = $props();
14
+
15
+ const param = $derived(config.param ?? 'search');
16
+ const debounceMs = $derived(config.debounceMs ?? 300);
17
+
18
+ // Intentional one-time hydration of local state from the initial `param`
19
+ // value (not a live binding) - svelte-check's state_referenced_locally
20
+ // warning is a false positive here, same as PaginatedTable's initialSort
21
+ // etc. After mount this is the source of truth for what the user is
22
+ // typing, so it doesn't fight with the URL updates this component itself
23
+ // triggers on every keystroke.
24
+ let value = $state(page.url.searchParams.get(param) ?? '');
25
+ let debounceTimer: ReturnType<typeof setTimeout>;
26
+
27
+ function onInput() {
28
+ clearTimeout(debounceTimer);
29
+ debounceTimer = setTimeout(() => {
30
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
31
+ const params = new URLSearchParams(page.url.searchParams);
32
+ const trimmed = value.trim();
33
+ if (trimmed) params.set(param, trimmed);
34
+ else params.delete(param);
35
+ // A new search term can leave the current page past the end of the
36
+ // narrowed result set, and any open create/read/edit view stops
37
+ // making sense once the underlying list changes - drop both.
38
+ params.delete('page');
39
+ params.delete('view');
40
+ params.delete('id');
41
+ const qs = params.toString();
42
+ goto(qs ? `?${qs}` : '?', { keepFocus: true, noScroll: true, replaceState: true });
43
+ }, debounceMs);
44
+ }
45
+ </script>
46
+
47
+ <input
48
+ type="search"
49
+ bind:value
50
+ oninput={onInput}
51
+ placeholder={config.placeholder ?? strings.searchPlaceholder}
52
+ class="input input-bordered w-64 shrink-0"
53
+ />
@@ -1,15 +1,15 @@
1
- <script lang="ts">
2
- import AvatarComponent from '../../Avatar.svelte';
3
- import { initials } from '../utils/misc.js';
4
- import type { CellProps } from '../../../types/table.js';
5
-
6
- type AvatarRow = { firstName?: string; lastName?: string; email?: string };
7
-
8
- let { value, row }: CellProps<AvatarRow, string | null | undefined> = $props();
9
- </script>
10
-
11
- <AvatarComponent
12
- src={value ?? null}
13
- text={initials(row.firstName, row.lastName)}
14
- alt={row.email ?? ''}
15
- />
1
+ <script lang="ts">
2
+ import AvatarComponent from '../../Avatar.svelte';
3
+ import { initials } from '../utils/misc.js';
4
+ import type { CellProps } from '../../../types/table.js';
5
+
6
+ type AvatarRow = { firstName?: string; lastName?: string; email?: string };
7
+
8
+ let { value, row }: CellProps<AvatarRow, string | null | undefined> = $props();
9
+ </script>
10
+
11
+ <AvatarComponent
12
+ src={value ?? null}
13
+ text={initials(row.firstName, row.lastName)}
14
+ alt={row.email ?? ''}
15
+ />
@@ -1,8 +1,8 @@
1
- <script lang="ts">
2
- import IconRenderer from '../../IconRenderer.svelte';
3
- import type { CellProps } from '../../../types/table.js';
4
-
5
- let { value }: CellProps<Record<string, unknown>, string> = $props();
6
- </script>
7
-
8
- <IconRenderer name={String(value ?? '')} />
1
+ <script lang="ts">
2
+ import IconRenderer from '../../IconRenderer.svelte';
3
+ import type { CellProps } from '../../../types/table.js';
4
+
5
+ let { value }: CellProps<Record<string, unknown>, string> = $props();
6
+ </script>
7
+
8
+ <IconRenderer name={String(value ?? '')} />
@@ -1,232 +1,232 @@
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, emptyRecord, 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
- fields = [] as FieldDefinition<T>[],
22
- creation = {} as ActionConfiguration<T>,
23
- serverError = '',
24
- seed = undefined as Record<string, unknown> | undefined,
25
- onCancel,
26
- onSuccess,
27
- }: {
28
- labelOne?: string;
29
- labelMany?: string;
30
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
- icon?: any;
32
- fields?: FieldDefinition<T>[];
33
- creation?: ActionConfiguration<T>;
34
- serverError?: string;
35
- /** Pre-fills the form from another instance's values — used when the
36
- * create view is reached via an Update form's "Duplicate" button. */
37
- seed?: Record<string, unknown>;
38
- onCancel?: () => void;
39
- onSuccess?: () => void;
40
- } = $props();
41
-
42
- const icons = $derived(getIconSet() ?? defaultIconSet);
43
- const entityIcon = $derived(icon ?? icons.folder);
44
-
45
- let fieldErrors = $state<Record<string, string>>({});
46
- let internalError = $state('');
47
- let successMessage = $state('');
48
- let successTimeout: ReturnType<typeof setTimeout> | undefined;
49
- let continueCreating = $state(false);
50
- let duplicating = $state(false);
51
-
52
- function flashSuccess() {
53
- successMessage = strings.saveSuccess;
54
- clearTimeout(successTimeout);
55
- successTimeout = setTimeout(() => (successMessage = ''), 4000);
56
- }
57
-
58
- let record = $state<Record<string, unknown>>(
59
- untrack(() => (seed ? seedRecord(fields, seed) : emptyRecord(fields)))
60
- );
61
-
62
- const groups = $derived(groupFields(fields));
63
- const hasFileField = $derived(fields.some((f) => f.type === 'file'));
64
-
65
- const continueEnabled = $derived(creation.continue?.enabled ?? true);
66
- const continueLabel = $derived(creation.continue?.label ?? strings.saveAndContinue);
67
- const continueClass = $derived(creation.continue?.class ?? '');
68
-
69
- const duplicationEnabled = $derived(creation.duplication?.enabled ?? false);
70
- const duplicationLabel = $derived(creation.duplication?.label ?? strings.duplicate);
71
- const duplicationClass = $derived(creation.duplication?.class ?? '');
72
-
73
- const errorEntries = $derived([
74
- ...((serverError || internalError) ? [['_global', internalError || serverError] as [string, string]] : []),
75
- ...Object.entries(fieldErrors),
76
- ]);
77
- </script>
78
-
79
- <div class="flex flex-col gap-6">
80
-
81
- <Header
82
- title={labelMany}
83
- breadcrumbs={[
84
- { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
85
- { label: creation.label ?? labelOne, icon: icons.create },
86
- ]}
87
- />
88
-
89
- {#if successMessage}
90
- <div role="alert" class="alert alert-success">
91
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
93
- </svg>
94
- <span class="text-sm">{successMessage}</span>
95
- </div>
96
- {/if}
97
-
98
- {#if errorEntries.length > 0}
99
- <div role="alert" class="alert alert-error">
100
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
101
- <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" />
102
- </svg>
103
- <ul class="list-disc list-inside text-sm">
104
- {#each errorEntries as [key, msg] (key)}
105
- <li>{msg}</li>
106
- {/each}
107
- </ul>
108
- </div>
109
- {/if}
110
-
111
- <form
112
- method="POST"
113
- action={creation.endpoint ?? '?/create'}
114
- enctype={hasFileField ? 'multipart/form-data' : undefined}
115
- class="mx-auto flex w-full flex-col gap-4 px-4"
116
- use:enhance={({ formData, cancel }) => {
117
- fieldErrors = {};
118
- internalError = '';
119
- successMessage = '';
120
- clearTimeout(successTimeout);
121
- const errs = validateAll(fields, formData, strings);
122
- if (Object.keys(errs).length > 0) {
123
- fieldErrors = errs;
124
- cancel();
125
- return;
126
- }
127
- return async ({ result, update }) => {
128
- if (result.type === 'success' || result.type === 'redirect') {
129
- await update({ reset: false });
130
- if (continueCreating) {
131
- record = emptyRecord(fields);
132
- fieldErrors = {};
133
- internalError = '';
134
- flashSuccess();
135
- } else if (duplicating) {
136
- fieldErrors = {};
137
- internalError = '';
138
- flashSuccess();
139
- record = applyDuplicateOmit(fields, record, creation.duplication?.omit);
140
- } else {
141
- onSuccess?.();
142
- }
143
- } else if (result.type === 'error') {
144
- internalError = result.error?.message ?? strings.serverError;
145
- } else {
146
- await update({ reset: false });
147
- }
148
- };
149
- }}
150
- >
151
- {#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
152
- {#if group.title}
153
- <fieldset class="fieldset border border-base-300 rounded-box p-4">
154
- <legend class="fieldset-legend px-2">{group.title}</legend>
155
- <div class="flex flex-col gap-4">
156
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
157
- {#if row.length > 1}
158
- <div class="flex flex-col gap-4 md:flex-row">
159
- {#each row as field (field.attribute)}
160
- <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
161
- {/each}
162
- </div>
163
- {:else}
164
- <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
165
- {/if}
166
- {/each}
167
- </div>
168
- </fieldset>
169
- {:else}
170
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
171
- {#if row.length > 1}
172
- <div class="flex flex-col gap-4 md:flex-row">
173
- {#each row as field (field.attribute)}
174
- <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
175
- {/each}
176
- </div>
177
- {:else}
178
- <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
179
- {/if}
180
- {/each}
181
- {/if}
182
- {/each}
183
-
184
- <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
185
- <Button variant="ghost" onclick={() => onCancel?.()}>
186
- {strings.cancel}
187
- </Button>
188
- {#if duplicationEnabled}
189
- <Button
190
- type="submit"
191
- variant="warning"
192
- class={duplicationClass}
193
- onclick={() => {
194
- continueCreating = false;
195
- duplicating = true;
196
- }}
197
- >
198
- {duplicationLabel}
199
- </Button>
200
- {/if}
201
- {#if continueEnabled}
202
- <Button
203
- type="submit"
204
- variant="secondary"
205
- class={continueClass}
206
- onclick={() => {
207
- continueCreating = true;
208
- duplicating = false;
209
- }}
210
- >
211
- {continueLabel}
212
- </Button>
213
- {/if}
214
- <Button
215
- type="submit"
216
- variant="primary"
217
- onclick={() => {
218
- continueCreating = false;
219
- duplicating = false;
220
- }}
221
- >
222
- {strings.save}
223
- </Button>
224
- </div>
225
- </form>
226
- </div>
227
-
228
- <style>
229
- form {
230
- max-width: var(--runeforge-form-max-width, 32rem);
231
- }
232
- </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, emptyRecord, 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
+ fields = [] as FieldDefinition<T>[],
22
+ creation = {} as ActionConfiguration<T>,
23
+ serverError = '',
24
+ seed = undefined as Record<string, unknown> | undefined,
25
+ onCancel,
26
+ onSuccess,
27
+ }: {
28
+ labelOne?: string;
29
+ labelMany?: string;
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ icon?: any;
32
+ fields?: FieldDefinition<T>[];
33
+ creation?: ActionConfiguration<T>;
34
+ serverError?: string;
35
+ /** Pre-fills the form from another instance's values — used when the
36
+ * create view is reached via an Update form's "Duplicate" button. */
37
+ seed?: Record<string, unknown>;
38
+ onCancel?: () => void;
39
+ onSuccess?: () => void;
40
+ } = $props();
41
+
42
+ const icons = $derived(getIconSet() ?? defaultIconSet);
43
+ const entityIcon = $derived(icon ?? icons.folder);
44
+
45
+ let fieldErrors = $state<Record<string, string>>({});
46
+ let internalError = $state('');
47
+ let successMessage = $state('');
48
+ let successTimeout: ReturnType<typeof setTimeout> | undefined;
49
+ let continueCreating = $state(false);
50
+ let duplicating = $state(false);
51
+
52
+ function flashSuccess() {
53
+ successMessage = strings.saveSuccess;
54
+ clearTimeout(successTimeout);
55
+ successTimeout = setTimeout(() => (successMessage = ''), 4000);
56
+ }
57
+
58
+ let record = $state<Record<string, unknown>>(
59
+ untrack(() => (seed ? seedRecord(fields, seed) : emptyRecord(fields)))
60
+ );
61
+
62
+ const groups = $derived(groupFields(fields));
63
+ const hasFileField = $derived(fields.some((f) => f.type === 'file'));
64
+
65
+ const continueEnabled = $derived(creation.continue?.enabled ?? true);
66
+ const continueLabel = $derived(creation.continue?.label ?? strings.saveAndContinue);
67
+ const continueClass = $derived(creation.continue?.class ?? '');
68
+
69
+ const duplicationEnabled = $derived(creation.duplication?.enabled ?? false);
70
+ const duplicationLabel = $derived(creation.duplication?.label ?? strings.duplicate);
71
+ const duplicationClass = $derived(creation.duplication?.class ?? '');
72
+
73
+ const errorEntries = $derived([
74
+ ...((serverError || internalError) ? [['_global', internalError || serverError] as [string, string]] : []),
75
+ ...Object.entries(fieldErrors),
76
+ ]);
77
+ </script>
78
+
79
+ <div class="flex flex-col gap-6">
80
+
81
+ <Header
82
+ title={labelMany}
83
+ breadcrumbs={[
84
+ { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
85
+ { label: creation.label ?? labelOne, icon: icons.create },
86
+ ]}
87
+ />
88
+
89
+ {#if successMessage}
90
+ <div role="alert" class="alert alert-success">
91
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
93
+ </svg>
94
+ <span class="text-sm">{successMessage}</span>
95
+ </div>
96
+ {/if}
97
+
98
+ {#if errorEntries.length > 0}
99
+ <div role="alert" class="alert alert-error">
100
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
101
+ <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" />
102
+ </svg>
103
+ <ul class="list-disc list-inside text-sm">
104
+ {#each errorEntries as [key, msg] (key)}
105
+ <li>{msg}</li>
106
+ {/each}
107
+ </ul>
108
+ </div>
109
+ {/if}
110
+
111
+ <form
112
+ method="POST"
113
+ action={creation.endpoint ?? '?/create'}
114
+ enctype={hasFileField ? 'multipart/form-data' : undefined}
115
+ class="mx-auto flex w-full flex-col gap-4 px-4"
116
+ use:enhance={({ formData, cancel }) => {
117
+ fieldErrors = {};
118
+ internalError = '';
119
+ successMessage = '';
120
+ clearTimeout(successTimeout);
121
+ const errs = validateAll(fields, formData, strings);
122
+ if (Object.keys(errs).length > 0) {
123
+ fieldErrors = errs;
124
+ cancel();
125
+ return;
126
+ }
127
+ return async ({ result, update }) => {
128
+ if (result.type === 'success' || result.type === 'redirect') {
129
+ await update({ reset: false });
130
+ if (continueCreating) {
131
+ record = emptyRecord(fields);
132
+ fieldErrors = {};
133
+ internalError = '';
134
+ flashSuccess();
135
+ } else if (duplicating) {
136
+ fieldErrors = {};
137
+ internalError = '';
138
+ flashSuccess();
139
+ record = applyDuplicateOmit(fields, record, creation.duplication?.omit);
140
+ } else {
141
+ onSuccess?.();
142
+ }
143
+ } else if (result.type === 'error') {
144
+ internalError = result.error?.message ?? strings.serverError;
145
+ } else {
146
+ await update({ reset: false });
147
+ }
148
+ };
149
+ }}
150
+ >
151
+ {#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
152
+ {#if group.title}
153
+ <fieldset class="fieldset border border-base-300 rounded-box p-4">
154
+ <legend class="fieldset-legend px-2">{group.title}</legend>
155
+ <div class="flex flex-col gap-4">
156
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
157
+ {#if row.length > 1}
158
+ <div class="flex flex-col gap-4 md:flex-row">
159
+ {#each row as field (field.attribute)}
160
+ <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
161
+ {/each}
162
+ </div>
163
+ {:else}
164
+ <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
165
+ {/if}
166
+ {/each}
167
+ </div>
168
+ </fieldset>
169
+ {:else}
170
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
171
+ {#if row.length > 1}
172
+ <div class="flex flex-col gap-4 md:flex-row">
173
+ {#each row as field (field.attribute)}
174
+ <Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
175
+ {/each}
176
+ </div>
177
+ {:else}
178
+ <Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
179
+ {/if}
180
+ {/each}
181
+ {/if}
182
+ {/each}
183
+
184
+ <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
185
+ <Button variant="ghost" onclick={() => onCancel?.()}>
186
+ {strings.cancel}
187
+ </Button>
188
+ {#if duplicationEnabled}
189
+ <Button
190
+ type="submit"
191
+ variant="warning"
192
+ class={duplicationClass}
193
+ onclick={() => {
194
+ continueCreating = false;
195
+ duplicating = true;
196
+ }}
197
+ >
198
+ {duplicationLabel}
199
+ </Button>
200
+ {/if}
201
+ {#if continueEnabled}
202
+ <Button
203
+ type="submit"
204
+ variant="secondary"
205
+ class={continueClass}
206
+ onclick={() => {
207
+ continueCreating = true;
208
+ duplicating = false;
209
+ }}
210
+ >
211
+ {continueLabel}
212
+ </Button>
213
+ {/if}
214
+ <Button
215
+ type="submit"
216
+ variant="primary"
217
+ onclick={() => {
218
+ continueCreating = false;
219
+ duplicating = false;
220
+ }}
221
+ >
222
+ {strings.save}
223
+ </Button>
224
+ </div>
225
+ </form>
226
+ </div>
227
+
228
+ <style>
229
+ form {
230
+ max-width: var(--runeforge-form-max-width, 32rem);
231
+ }
232
+ </style>