@ternkit/tern 1.14.0 → 1.15.0

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.
@@ -0,0 +1,485 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import { enhance } from '../runtime/forms.js';
4
+ import Alert from './Alert.svelte';
5
+ import AlertDialog from './AlertDialog.svelte';
6
+ import Button from './Button.svelte';
7
+ import Checkbox from './Checkbox.svelte';
8
+ import Empty from './Empty.svelte';
9
+ import Field from './Field.svelte';
10
+ import Form from './Form.svelte';
11
+ import Input from './Input.svelte';
12
+ import Pagination from './Pagination.svelte';
13
+ import Select from './Select.svelte';
14
+ import Textarea from './Textarea.svelte';
15
+ import type { FormField } from '../runtime/validate.js';
16
+ import { listHref, shown, type AdminField, type AdminPage } from './admin.js';
17
+
18
+ type Props = {
19
+ /** The page's data, from admin.Load. */
20
+ data: AdminPage;
21
+ /** The page's form prop, for the form's errors. */
22
+ form?: unknown;
23
+ class?: string;
24
+ };
25
+ let { data, form, class: klass = '' }: Props = $props();
26
+
27
+ type Failure = { errors?: Record<string, string>; values?: Record<string, unknown>; message?: string };
28
+ const failure = $derived((form && typeof form === 'object' && 'errors' in form ? form : null) as Failure | null);
29
+
30
+ const res = $derived(data.resource);
31
+ const list = $derived(data.list);
32
+ const record = $derived(data.record);
33
+ const lower = (s: string) => s.toLowerCase();
34
+ const yes = $derived(tb('tern.admin.yes'));
35
+ const no = $derived(tb('tern.admin.no'));
36
+
37
+ const listed = $derived(res ? res.fields.filter((f) => f.list) : []);
38
+ // Besides the first column, the first text one opens the record: a title is a bigger target than an ID.
39
+ const linkField = $derived(listed.find((f) => f.input === 'text')?.name);
40
+ const filters = $derived(res ? res.fields.filter((f) => f.filter && (f.input === 'checkbox' || f.options)) : []);
41
+ const editing = $derived(!!record?.key);
42
+ // Creating, the fields the database fills in are left out.
43
+ const fields = $derived(res ? res.fields.filter((f) => editing || (f.name === res.key ? f.rules.kind === 'string' : !f.readonly)) : []);
44
+ const editable = $derived(!!res && (editing ? res.edit : res.create));
45
+
46
+ const value = (f: AdminField) => {
47
+ const v = failure?.values?.[f.name];
48
+ return typeof v === 'string' ? v : (record?.values[f.name] ?? '');
49
+ };
50
+ const notice = $derived(data.notice === 'created' || data.notice === 'saved' || data.notice === 'deleted' ? tb(`tern.admin.done.${data.notice}`) : '');
51
+ const from = $derived(list && list.total ? (list.page - 1) * list.perPage + 1 : 0);
52
+
53
+ let confirming = $state(false);
54
+ let deleteForm = $state<HTMLFormElement>();
55
+ const heading = $derived(
56
+ data.view === 'home' ? tb('tern.admin.home') : data.view === 'list' ? res!.label : editing ? tb('tern.admin.edit', { name: lower(res!.singular) }) : tb('tern.admin.new', { name: lower(res!.singular) })
57
+ );
58
+ </script>
59
+
60
+ <svelte:head><title>{heading} · {data.title}</title></svelte:head>
61
+
62
+ <div class="tern-admin {klass}">
63
+ <nav class="tern-admin-nav" aria-label={tb('tern.admin.menu')}>
64
+ <a class="tern-admin-title" href={data.base}>{data.title}</a>
65
+ <ul>
66
+ <li><a href={data.base} aria-current={data.view === 'home' ? 'page' : undefined}>{tb('tern.admin.home')}</a></li>
67
+ {#each data.resources as r (r.name)}
68
+ <li><a href={r.href} aria-current={res?.name === r.name ? (data.view === 'list' ? 'page' : 'true') : undefined}>{r.label}</a></li>
69
+ {/each}
70
+ </ul>
71
+ </nav>
72
+
73
+ <main class="tern-admin-main">
74
+ {#if data.view === 'home'}
75
+ <h1>{heading}</h1>
76
+ <ul class="tern-admin-cards">
77
+ {#each data.resources as r, i (r.name)}
78
+ <li>
79
+ <a href={r.href}>
80
+ <span class="tern-admin-card-label">{r.label}</span>
81
+ <span class="tern-admin-card-count">{tb('tern.admin.count', { count: data.counts[i] ?? 0 })}</span>
82
+ </a>
83
+ </li>
84
+ {/each}
85
+ </ul>
86
+ {:else if data.view === 'list' && res && list}
87
+ <header class="tern-admin-head">
88
+ <h1>{heading}</h1>
89
+ {#if res.create}<Button href="{res.href}/new">{tb('tern.admin.new', { name: lower(res.singular) })}</Button>{/if}
90
+ </header>
91
+ {#if notice}<Alert tone="success" live>{notice}</Alert>{/if}
92
+
93
+ {#if res.search || filters.length}
94
+ <form class="tern-admin-query" method="GET" action={res.href} role="search">
95
+ {#if res.search}
96
+ <Input type="search" name="q" value={list.search} aria-label={tb('tern.admin.searchLabel', { name: lower(res.label) })} placeholder={tb('tern.admin.search')} />
97
+ {/if}
98
+ {#each filters as f (f.name)}
99
+ <Select
100
+ name={f.name}
101
+ aria-label={f.label}
102
+ value={list.filters[f.name] ?? ''}
103
+ options={[
104
+ { value: '', label: `${f.label}: ${tb('tern.admin.all')}` },
105
+ ...(f.input === 'checkbox'
106
+ ? [
107
+ { value: 'true', label: `${f.label}: ${yes}` },
108
+ { value: 'false', label: `${f.label}: ${no}` }
109
+ ]
110
+ : (f.options ?? []).map((o) => ({ value: o, label: `${f.label}: ${o}` })))
111
+ ]}
112
+ />
113
+ {/each}
114
+ {#if list.sort}<input type="hidden" name="sort" value="{list.desc ? '-' : ''}{list.sort}" />{/if}
115
+ <Button type="submit" variant="secondary">{tb('tern.admin.search')}</Button>
116
+ {#if list.search || Object.keys(list.filters).length}
117
+ <Button href={res.href} variant="ghost">{tb('tern.admin.clear')}</Button>
118
+ {/if}
119
+ </form>
120
+ {/if}
121
+
122
+ {#if list.rows.length === 0}
123
+ <Empty
124
+ level={2}
125
+ title={list.search || Object.keys(list.filters).length ? tb('tern.admin.noMatches') : tb('tern.admin.empty', { name: lower(res.label) })}
126
+ />
127
+ {:else}
128
+ <div class="tern-admin-scroll">
129
+ <table class="tern-admin-table">
130
+ <caption class="tern-sr-only">{res.label}</caption>
131
+ <thead>
132
+ <tr>
133
+ {#each listed as f (f.name)}
134
+ {@const active = list.sort === f.name}
135
+ <th scope="col" aria-sort={active ? (list.desc ? 'descending' : 'ascending') : undefined} class:num={f.input === 'number'}>
136
+ <a href={listHref(res.href, list, { sort: active && !list.desc ? `-${f.name}` : f.name })} aria-label={tb('tern.admin.sortBy', { field: f.label })}>
137
+ {f.label}<span class="tern-admin-sort" aria-hidden="true">{active ? (list.desc ? '↓' : '↑') : ''}</span>
138
+ </a>
139
+ </th>
140
+ {/each}
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ {#each list.rows as row (row.key)}
145
+ <tr>
146
+ {#each listed as f, i (f.name)}
147
+ {@const v = row.values[f.name]}
148
+ <td class:num={f.input === 'number'} class:muted={f.input === 'checkbox' && !v}>
149
+ {#if i === 0 || f.name === linkField}
150
+ <a class="tern-admin-open" href="{res.href}/{encodeURIComponent(row.key)}">{shown(f, v, yes, no) || row.key}</a>
151
+ {:else if f.ref && v}
152
+ <a href="{data.base}/{f.ref}/{encodeURIComponent(v)}">{v}</a>
153
+ {:else}
154
+ {shown(f, v, yes, no)}
155
+ {/if}
156
+ </td>
157
+ {/each}
158
+ </tr>
159
+ {/each}
160
+ </tbody>
161
+ </table>
162
+ </div>
163
+ <footer class="tern-admin-foot">
164
+ <span>{tb('tern.admin.showing', { from, to: from + list.rows.length - 1, total: list.total })}</span>
165
+ {#if list.pages > 1}
166
+ <Pagination page={list.page} pages={list.pages} href={(p) => listHref(res.href, list, { page: p })} />
167
+ {/if}
168
+ </footer>
169
+ {/if}
170
+ {:else if data.view === 'form' && res && record}
171
+ <p class="tern-admin-back"><a href={res.href}>← {tb('tern.admin.back', { name: lower(res.label) })}</a></p>
172
+ <header class="tern-admin-head">
173
+ <h1>{heading}</h1>
174
+ {#if editing && res.delete}
175
+ <form method="POST" action="?/delete" bind:this={deleteForm} use:enhance>
176
+ <Button
177
+ type="submit"
178
+ variant="danger"
179
+ onclick={(e: MouseEvent) => {
180
+ e.preventDefault();
181
+ confirming = true;
182
+ }}>{tb('tern.admin.delete')}</Button
183
+ >
184
+ </form>
185
+ {/if}
186
+ </header>
187
+ {#if failure?.message && !failure.errors?.[''] && Object.keys(failure.errors ?? {}).length === 0}
188
+ <Alert tone="danger" live>{failure.message}</Alert>
189
+ {/if}
190
+
191
+ <Form action="?/save" rules={editable ? (fields.filter((f) => !f.readonly).map((f) => f.rules) as FormField[]) : []} {form} reset={false} class="tern-admin-form">
192
+ {#each fields as f (f.name)}
193
+ {@const ro = (f.readonly && !(f.name === res.key && !editing)) || !editable}
194
+ {#if ro}
195
+ <div class="tern-admin-static">
196
+ <span class="tern-admin-static-label">{f.label}</span>
197
+ <span class="tern-admin-static-value" class:muted={!shown(f, value(f), yes, no)}>
198
+ {#if f.ref && value(f)}
199
+ <a href="{data.base}/{f.ref}/{encodeURIComponent(value(f))}">{value(f)}</a>
200
+ {:else}
201
+ {shown(f, value(f), yes, no) || '—'}
202
+ {/if}
203
+ </span>
204
+ </div>
205
+ {:else if f.input === 'checkbox'}
206
+ <Checkbox name={f.name} checked={value(f) !== '' && value(f) !== 'false'} description={f.description || undefined}>{f.label}</Checkbox>
207
+ {:else}
208
+ <Field
209
+ label={f.label}
210
+ name={f.name}
211
+ description={f.input === 'password' && editing ? tb('tern.admin.passwordKept') : f.description || undefined}
212
+ >
213
+ {#if f.input === 'textarea'}
214
+ <Textarea name={f.name} value={value(f)} rows={4} />
215
+ {:else if f.input === 'select'}
216
+ <Select name={f.name} value={value(f)} placeholder={f.rules.required ? undefined : '—'} options={[...(f.rules.required ? [] : [{ value: '', label: '—' }]), ...(f.options ?? []).map((o) => ({ value: o, label: o }))]} />
217
+ {:else}
218
+ <Input
219
+ name={f.name}
220
+ type={f.input}
221
+ value={f.input === 'password' ? '' : value(f)}
222
+ step={f.input === 'number' && f.rules.kind === 'float' ? 'any' : undefined}
223
+ autocomplete={f.input === 'password' ? 'new-password' : undefined}
224
+ />
225
+ {/if}
226
+ </Field>
227
+ {/if}
228
+ {/each}
229
+ {#if editable}
230
+ <div class="tern-admin-actions">
231
+ <Button type="submit">{editing ? tb('tern.admin.save') : tb('tern.admin.create')}</Button>
232
+ <Button href={res.href} variant="ghost">{tb('tern.ui.alertDialog.cancel')}</Button>
233
+ </div>
234
+ {/if}
235
+ </Form>
236
+
237
+ {#if editing && res.delete}
238
+ <AlertDialog
239
+ bind:open={confirming}
240
+ title={tb('tern.admin.deleteTitle', { name: lower(res.singular) })}
241
+ description={tb('tern.admin.deleteDescription')}
242
+ confirmLabel={tb('tern.admin.delete')}
243
+ danger
244
+ onconfirm={() => deleteForm?.requestSubmit()}
245
+ />
246
+ {/if}
247
+ {/if}
248
+ </main>
249
+ </div>
250
+
251
+ <style>
252
+ .tern-admin {
253
+ display: grid;
254
+ grid-template-columns: 13rem minmax(0, 1fr);
255
+ gap: 2rem;
256
+ align-items: start;
257
+ color: var(--tern-fg);
258
+ font-family: var(--tern-font);
259
+ }
260
+ .tern-admin-nav {
261
+ position: sticky;
262
+ top: 1rem;
263
+ display: grid;
264
+ gap: 0.75rem;
265
+ }
266
+ .tern-admin-title {
267
+ font-weight: 650;
268
+ color: inherit;
269
+ text-decoration: none;
270
+ padding: 0 0.625rem;
271
+ }
272
+ .tern-admin-nav ul {
273
+ list-style: none;
274
+ margin: 0;
275
+ padding: 0;
276
+ display: grid;
277
+ gap: 2px;
278
+ }
279
+ .tern-admin-nav li a {
280
+ display: block;
281
+ padding: 0.4rem 0.625rem;
282
+ border-radius: var(--tern-radius-sm);
283
+ color: var(--tern-muted);
284
+ text-decoration: none;
285
+ font-size: 0.875rem;
286
+ }
287
+ .tern-admin-nav li a:hover {
288
+ color: var(--tern-fg);
289
+ background: var(--tern-surface);
290
+ }
291
+ .tern-admin-nav li a[aria-current] {
292
+ color: var(--tern-fg);
293
+ background: var(--tern-accent-soft);
294
+ font-weight: 550;
295
+ }
296
+ .tern-admin-main {
297
+ display: grid;
298
+ gap: 1rem;
299
+ min-width: 0;
300
+ }
301
+ .tern-admin-main h1 {
302
+ margin: 0;
303
+ font-size: 1.5rem;
304
+ line-height: 1.25;
305
+ }
306
+ .tern-admin-head {
307
+ display: flex;
308
+ align-items: center;
309
+ justify-content: space-between;
310
+ gap: 1rem;
311
+ flex-wrap: wrap;
312
+ }
313
+ .tern-admin-head form {
314
+ margin: 0;
315
+ }
316
+ .tern-admin-back {
317
+ margin: 0;
318
+ font-size: 0.875rem;
319
+ }
320
+ .tern-admin-back a {
321
+ color: var(--tern-muted);
322
+ text-decoration: none;
323
+ }
324
+ .tern-admin-back a:hover {
325
+ color: var(--tern-fg);
326
+ }
327
+ .tern-admin-cards {
328
+ list-style: none;
329
+ margin: 0;
330
+ padding: 0;
331
+ display: grid;
332
+ grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
333
+ gap: 0.75rem;
334
+ }
335
+ .tern-admin-cards a {
336
+ display: grid;
337
+ gap: 0.25rem;
338
+ padding: 1rem 1.125rem;
339
+ border: 1px solid var(--tern-border);
340
+ border-radius: var(--tern-radius);
341
+ background: var(--tern-surface);
342
+ color: inherit;
343
+ text-decoration: none;
344
+ transition: border-color var(--tern-duration);
345
+ }
346
+ .tern-admin-cards a:hover {
347
+ border-color: var(--tern-border-strong);
348
+ }
349
+ .tern-admin-card-label {
350
+ font-weight: 600;
351
+ }
352
+ .tern-admin-card-count {
353
+ color: var(--tern-muted);
354
+ font-size: 0.875rem;
355
+ font-variant-numeric: tabular-nums;
356
+ }
357
+ .tern-admin-query {
358
+ display: flex;
359
+ flex-wrap: wrap;
360
+ gap: 0.5rem;
361
+ align-items: center;
362
+ }
363
+ .tern-admin-query :global(input[type='search']) {
364
+ flex: 1 1 14rem;
365
+ width: auto;
366
+ }
367
+ .tern-admin-query :global(select) {
368
+ width: auto;
369
+ }
370
+ .tern-admin-scroll {
371
+ overflow-x: auto;
372
+ border: 1px solid var(--tern-border);
373
+ border-radius: var(--tern-radius);
374
+ }
375
+ .tern-admin-table {
376
+ width: 100%;
377
+ border-collapse: collapse;
378
+ font-size: 0.875rem;
379
+ }
380
+ .tern-admin-table th,
381
+ .tern-admin-table td {
382
+ padding: 0.6rem 0.875rem;
383
+ text-align: start;
384
+ border-bottom: 1px solid var(--tern-border);
385
+ white-space: nowrap;
386
+ max-width: 22rem;
387
+ overflow: hidden;
388
+ text-overflow: ellipsis;
389
+ }
390
+ .tern-admin-table tbody tr:last-child td {
391
+ border-bottom: 0;
392
+ }
393
+ .tern-admin-table tbody tr:hover {
394
+ background: var(--tern-surface);
395
+ }
396
+ .tern-admin-table th {
397
+ font-weight: 550;
398
+ color: var(--tern-muted);
399
+ background: var(--tern-surface);
400
+ }
401
+ .tern-admin-table th a {
402
+ color: inherit;
403
+ text-decoration: none;
404
+ }
405
+ .tern-admin-table th a:hover,
406
+ .tern-admin-table th[aria-sort] a {
407
+ color: var(--tern-fg);
408
+ }
409
+ .tern-admin-sort {
410
+ display: inline-block;
411
+ width: 1em;
412
+ text-align: center;
413
+ }
414
+ .tern-admin-table .num {
415
+ text-align: end;
416
+ font-variant-numeric: tabular-nums;
417
+ }
418
+ .tern-admin-table .muted {
419
+ color: var(--tern-muted);
420
+ }
421
+ .tern-admin-table td a {
422
+ color: var(--tern-accent);
423
+ text-decoration: none;
424
+ }
425
+ .tern-admin-table td a:hover {
426
+ text-decoration: underline;
427
+ }
428
+ .tern-admin-open {
429
+ font-weight: 550;
430
+ }
431
+ .tern-admin-foot {
432
+ display: flex;
433
+ align-items: center;
434
+ justify-content: space-between;
435
+ gap: 1rem;
436
+ flex-wrap: wrap;
437
+ color: var(--tern-muted);
438
+ font-size: 0.875rem;
439
+ font-variant-numeric: tabular-nums;
440
+ }
441
+ .tern-admin-static {
442
+ display: grid;
443
+ gap: 0.375rem;
444
+ }
445
+ .tern-admin-static-label {
446
+ font-size: 0.875rem;
447
+ font-weight: 550;
448
+ }
449
+ .tern-admin-static-value {
450
+ font-size: 0.875rem;
451
+ overflow-wrap: anywhere;
452
+ }
453
+ .tern-admin-static-value.muted {
454
+ color: var(--tern-muted);
455
+ }
456
+ .tern-admin-static-value a {
457
+ color: var(--tern-accent);
458
+ }
459
+ :global(.tern-admin-form) {
460
+ display: grid;
461
+ gap: 1rem;
462
+ max-width: 36rem;
463
+ }
464
+ .tern-admin-actions {
465
+ display: flex;
466
+ gap: 0.5rem;
467
+ }
468
+ @media (max-width: 720px) {
469
+ .tern-admin {
470
+ grid-template-columns: minmax(0, 1fr);
471
+ gap: 1rem;
472
+ }
473
+ .tern-admin-nav {
474
+ position: static;
475
+ }
476
+ .tern-admin-nav ul {
477
+ display: flex;
478
+ overflow-x: auto;
479
+ gap: 0.25rem;
480
+ }
481
+ .tern-admin-nav li a {
482
+ white-space: nowrap;
483
+ }
484
+ }
485
+ </style>
@@ -0,0 +1,11 @@
1
+ import { type AdminPage } from './admin.js';
2
+ type Props = {
3
+ /** The page's data, from admin.Load. */
4
+ data: AdminPage;
5
+ /** The page's form prop, for the form's errors. */
6
+ form?: unknown;
7
+ class?: string;
8
+ };
9
+ declare const Admin: import("svelte").Component<Props, {}, "">;
10
+ type Admin = ReturnType<typeof Admin>;
11
+ export default Admin;
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import type { Segment } from './search.js';
3
+
4
+ type Props = {
5
+ /** The text, as the search package's segments, or plain. */
6
+ text: string | Segment[] | null | undefined;
7
+ class?: string;
8
+ };
9
+ let { text, class: klass = '' }: Props = $props();
10
+ const parts = $derived(typeof text === 'string' ? [{ text }] : (text ?? []));
11
+ </script>
12
+
13
+ <span class="tern-highlight {klass}">{#each parts as p, i (i)}{#if p.match}<mark>{p.text}</mark>{:else}{p.text}{/if}{/each}</span>
14
+
15
+ <style>
16
+ mark {
17
+ background: var(--tern-accent-soft);
18
+ color: inherit;
19
+ font-weight: 600;
20
+ border-radius: 2px;
21
+ padding: 0 1px;
22
+ }
23
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { Segment } from './search.js';
2
+ type Props = {
3
+ /** The text, as the search package's segments, or plain. */
4
+ text: string | Segment[] | null | undefined;
5
+ class?: string;
6
+ };
7
+ declare const Highlight: import("svelte").Component<Props, {}, "">;
8
+ type Highlight = ReturnType<typeof Highlight>;
9
+ export default Highlight;