sveltekit-admin 0.2.1 → 0.5.3

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/dist/index.d.ts +2 -7
  4. package/dist/index.js +2 -13
  5. package/dist/server/auth.d.ts +7 -0
  6. package/dist/server/auth.js +19 -0
  7. package/dist/server/data.d.ts +38 -0
  8. package/dist/server/data.js +116 -0
  9. package/dist/server/handler.d.ts +123 -4
  10. package/dist/server/handler.js +600 -819
  11. package/dist/server/introspection/parser.d.ts +19 -12
  12. package/dist/server/introspection/parser.js +71 -61
  13. package/dist/server/introspection/relations.d.ts +49 -0
  14. package/dist/server/introspection/relations.js +128 -0
  15. package/dist/server/query/filterDetection.d.ts +71 -0
  16. package/dist/server/query/filterDetection.js +153 -0
  17. package/dist/server/query/listQuery.d.ts +92 -0
  18. package/dist/server/query/listQuery.js +442 -0
  19. package/dist/server/query/urls.d.ts +33 -0
  20. package/dist/server/query/urls.js +58 -0
  21. package/dist/server/router.d.ts +6 -0
  22. package/dist/server/router.js +27 -0
  23. package/dist/server/views/Dashboard.svelte +29 -0
  24. package/dist/server/views/Dashboard.svelte.d.ts +15 -0
  25. package/dist/server/views/FieldInput.svelte +63 -0
  26. package/dist/server/views/FieldInput.svelte.d.ts +9 -0
  27. package/dist/server/views/Form.svelte +127 -0
  28. package/dist/server/views/Form.svelte.d.ts +12 -0
  29. package/dist/server/views/Layout.svelte +78 -0
  30. package/dist/server/views/Layout.svelte.d.ts +13 -0
  31. package/dist/server/views/List.svelte +240 -0
  32. package/dist/server/views/List.svelte.d.ts +27 -0
  33. package/dist/server/views/ListFilters.svelte +257 -0
  34. package/dist/server/views/ListFilters.svelte.d.ts +18 -0
  35. package/dist/server/views/ModelCard.svelte +11 -0
  36. package/dist/server/views/ModelCard.svelte.d.ts +8 -0
  37. package/dist/server/views/NotFound.svelte +7 -0
  38. package/dist/server/views/NotFound.svelte.d.ts +7 -0
  39. package/dist/server/views/RelatedBlock.svelte +74 -0
  40. package/dist/server/views/RelatedBlock.svelte.d.ts +11 -0
  41. package/dist/server/views/RelationCheckboxes.svelte +53 -0
  42. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  43. package/dist/server/views/RelationSelect.svelte +53 -0
  44. package/dist/server/views/RelationSelect.svelte.d.ts +12 -0
  45. package/dist/server/views/StatCard.svelte +18 -0
  46. package/dist/server/views/StatCard.svelte.d.ts +8 -0
  47. package/dist/server/views/html.d.ts +5 -0
  48. package/dist/server/views/html.js +41 -0
  49. package/dist/server/views/theme.d.ts +1 -0
  50. package/dist/server/views/theme.js +537 -0
  51. package/dist/server/views/types.d.ts +57 -0
  52. package/dist/server/views/types.js +1 -0
  53. package/package.json +24 -26
  54. package/dist/admin.d.ts +0 -227
  55. package/dist/admin.js +0 -369
  56. package/dist/components/AdminForm.svelte +0 -423
  57. package/dist/components/AdminForm.svelte.d.ts +0 -30
  58. package/dist/components/AdminLayout.svelte +0 -328
  59. package/dist/components/AdminLayout.svelte.d.ts +0 -20
  60. package/dist/components/DataTable.svelte +0 -573
  61. package/dist/components/DataTable.svelte.d.ts +0 -25
  62. package/dist/components/index.d.ts +0 -3
  63. package/dist/components/index.js +0 -3
  64. package/dist/server/auth/guard.d.ts +0 -36
  65. package/dist/server/auth/guard.js +0 -38
  66. package/dist/server/auth/index.d.ts +0 -1
  67. package/dist/server/auth/index.js +0 -1
  68. package/dist/server/crud/index.d.ts +0 -1
  69. package/dist/server/crud/index.js +0 -1
  70. package/dist/server/crud/operations.d.ts +0 -87
  71. package/dist/server/crud/operations.js +0 -276
  72. package/dist/server/introspection/index.d.ts +0 -1
  73. package/dist/server/introspection/index.js +0 -1
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import StatCard from './StatCard.svelte';
3
+ import ModelCard from './ModelCard.svelte';
4
+
5
+ let {
6
+ models,
7
+ stats,
8
+ basePath
9
+ }: {
10
+ models: Array<{ name: string; label: string; count: number }>;
11
+ stats: { total: number; models: number };
12
+ basePath: string;
13
+ } = $props();
14
+ </script>
15
+
16
+ <h1>Dashboard</h1>
17
+ <p class="ska-subtitle">Welcome to your admin panel</p>
18
+
19
+ <div class="ska-stats">
20
+ <StatCard icon="models" value={stats.models} label="Models" />
21
+ <StatCard icon="records" value={stats.total} label="Total Records" />
22
+ </div>
23
+
24
+ <h2>Models</h2>
25
+ <div class="ska-models">
26
+ {#each models as m (m.name)}
27
+ <ModelCard href="{basePath}/{m.name.toLowerCase()}" label={m.label} count={m.count} />
28
+ {/each}
29
+ </div>
@@ -0,0 +1,15 @@
1
+ type $$ComponentProps = {
2
+ models: Array<{
3
+ name: string;
4
+ label: string;
5
+ count: number;
6
+ }>;
7
+ stats: {
8
+ total: number;
9
+ models: number;
10
+ };
11
+ basePath: string;
12
+ };
13
+ declare const Dashboard: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type Dashboard = ReturnType<typeof Dashboard>;
15
+ export default Dashboard;
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ import type { PrismaField } from '../introspection/parser.js';
3
+ import { toLabel } from './html.js';
4
+
5
+ let { field, value, isReadonly }: { field: PrismaField; value: any; isReadonly: boolean } =
6
+ $props();
7
+
8
+ const label = $derived(toLabel(field.name));
9
+ const required = $derived(field.isRequired && !field.hasDefault && !isReadonly);
10
+
11
+ const lower = $derived(field.name.toLowerCase());
12
+ const isLongText = $derived(
13
+ field.type === 'String' &&
14
+ ['description', 'content', 'body', 'bio'].some((k) => lower.includes(k))
15
+ );
16
+
17
+ const inputType = $derived.by(() => {
18
+ switch (field.type) {
19
+ case 'Int':
20
+ case 'Float':
21
+ case 'Decimal':
22
+ case 'BigInt':
23
+ return 'number';
24
+ case 'DateTime':
25
+ return 'datetime-local';
26
+ default:
27
+ return 'text';
28
+ }
29
+ });
30
+
31
+ const inputValue = $derived.by(() => {
32
+ if (field.type === 'DateTime' && value) {
33
+ return new Date(value).toISOString().slice(0, 16);
34
+ }
35
+ return value ?? '';
36
+ });
37
+
38
+ const jsonValue = $derived(value ? JSON.stringify(value, null, 2) : '');
39
+ </script>
40
+
41
+ {#if field.type === 'Boolean'}
42
+ <div class="ska-field">
43
+ <label class="ska-checkbox-wrap">
44
+ <input type="checkbox" name={field.name} class="ska-checkbox" checked={!!value} disabled={isReadonly} />
45
+ <span class="ska-label">{label}</span>
46
+ </label>
47
+ </div>
48
+ {:else if field.type === 'Json'}
49
+ <div class="ska-field">
50
+ <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
51
+ <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required}>{jsonValue}</textarea>
52
+ </div>
53
+ {:else if isLongText}
54
+ <div class="ska-field">
55
+ <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
56
+ <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required}>{inputValue}</textarea>
57
+ </div>
58
+ {:else}
59
+ <div class="ska-field">
60
+ <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
61
+ <input id={field.name} type={inputType} name={field.name} value={inputValue} class="ska-input" readonly={isReadonly} required={required} />
62
+ </div>
63
+ {/if}
@@ -0,0 +1,9 @@
1
+ import type { PrismaField } from '../introspection/parser.js';
2
+ type $$ComponentProps = {
3
+ field: PrismaField;
4
+ value: any;
5
+ isReadonly: boolean;
6
+ };
7
+ declare const FieldInput: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type FieldInput = ReturnType<typeof FieldInput>;
9
+ export default FieldInput;
@@ -0,0 +1,127 @@
1
+ <script lang="ts">
2
+ import type { AdminHandlerConfig } from '../handler.js';
3
+ import type { ViewModel } from './types.js';
4
+ import FieldInput from './FieldInput.svelte';
5
+ import RelationSelect from './RelationSelect.svelte';
6
+ import RelationCheckboxes from './RelationCheckboxes.svelte';
7
+ import RelatedBlock from './RelatedBlock.svelte';
8
+
9
+ let {
10
+ mode,
11
+ model,
12
+ basePath,
13
+ config,
14
+ item
15
+ }: {
16
+ mode: 'create' | 'edit';
17
+ model: ViewModel;
18
+ basePath: string;
19
+ config: AdminHandlerConfig;
20
+ item?: any;
21
+ } = $props();
22
+
23
+ const modelConfig = $derived(config.models?.[model.name] || {});
24
+ const hidden = $derived(modelConfig.hidden || []);
25
+ const readonly = $derived(modelConfig.readonly || []);
26
+ const listPath = $derived(`${basePath}/${model.name.toLowerCase()}`);
27
+
28
+ const formFields = $derived(
29
+ mode === 'create'
30
+ ? model.fields.filter(
31
+ (f) =>
32
+ !hidden.includes(f.name) &&
33
+ !f.isId &&
34
+ !f.isCreatedAt &&
35
+ !f.isUpdatedAt &&
36
+ !f.relation &&
37
+ !f.hasDefault &&
38
+ // Masqué : remplacé par le select de sa relation ci-dessous.
39
+ !model.relationGraph?.scalarToRelation.has(f.name)
40
+ )
41
+ : model.fields.filter(
42
+ (f) =>
43
+ !hidden.includes(f.name) &&
44
+ !f.relation &&
45
+ !model.relationGraph?.scalarToRelation.has(f.name)
46
+ )
47
+ );
48
+
49
+ const isFieldReadonly = (f: (typeof formFields)[number]) =>
50
+ mode === 'edit' && (f.isId || f.isCreatedAt || f.isUpdatedAt || readonly.includes(f.name));
51
+
52
+ const relationSelects = $derived(
53
+ model.relationGraph
54
+ ? [...model.relationGraph.edges.values()].filter(
55
+ (e) =>
56
+ e.model === model.name &&
57
+ e.kind === 'to-one-owning' &&
58
+ !e.unsupported &&
59
+ !hidden.includes(e.field) &&
60
+ model.relationOptions?.has(`${e.model}.${e.field}`)
61
+ )
62
+ : []
63
+ );
64
+
65
+ const currentValueOf = (scalarName: string) => (item ? item[scalarName] : null);
66
+
67
+ const relationCheckboxGroups = $derived(
68
+ model.relationGraph
69
+ ? [...model.relationGraph.edges.values()].filter(
70
+ (e) =>
71
+ e.model === model.name &&
72
+ e.kind === 'm2m-implicit' &&
73
+ !e.unsupported &&
74
+ !hidden.includes(e.field) &&
75
+ model.relationOptions?.has(`${e.model}.${e.field}`)
76
+ )
77
+ : []
78
+ );
79
+
80
+ const inverseEdges = $derived(
81
+ model.relationGraph
82
+ ? [...model.relationGraph.edges.values()].filter(
83
+ (e) => e.model === model.name && (e.kind === 'to-many-inverse' || e.kind === 'to-one-inverse')
84
+ )
85
+ : []
86
+ );
87
+ </script>
88
+
89
+ <a href={listPath} class="ska-back">← Back to list</a>
90
+ <h1>{mode === 'create' ? 'Create' : 'Edit'} {model.label}</h1>
91
+ {#if mode === 'edit'}
92
+ <p class="ska-subtitle">ID: {item[model.primaryKey]}</p>
93
+ {/if}
94
+
95
+ <div class="ska-card">
96
+ <form method="POST" class="ska-form">
97
+ <input type="hidden" name="_action" value={mode === 'create' ? 'create' : 'update'} />
98
+ {#each formFields as f (f.name)}
99
+ <FieldInput field={f} value={item ? item[f.name] : null} isReadonly={isFieldReadonly(f)} />
100
+ {/each}
101
+ {#each relationSelects as edge (edge.field)}
102
+ <RelationSelect
103
+ {edge}
104
+ meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!}
105
+ currentValue={currentValueOf(edge.scalarFields[0])}
106
+ {config}
107
+ />
108
+ {/each}
109
+ {#each relationCheckboxGroups as edge (edge.field)}
110
+ <RelationCheckboxes {edge} meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!} />
111
+ {/each}
112
+ <div class="ska-form__actions">
113
+ <button type="submit" class="ska-btn ska-btn--primary">{mode === 'create' ? 'Create' : 'Save Changes'}</button>
114
+ <a href={listPath} class="ska-btn ska-btn--secondary">Cancel</a>
115
+ </div>
116
+ </form>
117
+ </div>
118
+ {#if mode === 'edit' && model.relationGraph && model.relatedCounts}
119
+ <RelatedBlock
120
+ edges={inverseEdges}
121
+ graph={model.relationGraph}
122
+ counts={model.relatedCounts}
123
+ currentId={item[model.primaryKey]}
124
+ {basePath}
125
+ />
126
+ {/if}
127
+
@@ -0,0 +1,12 @@
1
+ import type { AdminHandlerConfig } from '../handler.js';
2
+ import type { ViewModel } from './types.js';
3
+ type $$ComponentProps = {
4
+ mode: 'create' | 'edit';
5
+ model: ViewModel;
6
+ basePath: string;
7
+ config: AdminHandlerConfig;
8
+ item?: any;
9
+ };
10
+ declare const Form: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type Form = ReturnType<typeof Form>;
12
+ export default Form;
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import type { AdminHandlerConfig } from '../handler.js';
3
+ import { styles } from './theme.js';
4
+
5
+ let {
6
+ content,
7
+ config,
8
+ modelList,
9
+ currentModel
10
+ }: {
11
+ content: string;
12
+ config: AdminHandlerConfig;
13
+ modelList: Array<{ name: string; label: string }>;
14
+ currentModel?: string;
15
+ } = $props();
16
+
17
+ const branding = $derived(config.branding ?? {});
18
+ const title = $derived(branding.title || 'Admin');
19
+ const primaryColor = $derived(branding.primaryColor || '#6366f1');
20
+ const basePath = $derived(config.basePath || '/admin');
21
+ // No button at all if `logout` isn't configured — an admin that never
22
+ // opted into this option looks exactly as it did before it existed.
23
+ const showLogout = $derived(Boolean(config.logout));
24
+ </script>
25
+
26
+ <!doctype html>
27
+ <html lang="en">
28
+ <!-- eslint-disable-next-line svelte/no-raw-special-elements -- server-only full-document template, never mounted client-side -->
29
+ <head>
30
+ <meta charset="UTF-8" />
31
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
32
+ <title>{title}</title>
33
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS injected as raw text; a literal <style> block can't take a dynamic value; primaryColor is developer-supplied config, not request/database data, and this raw interpolation is unchanged from the original layout.ts implementation, not a new injection point introduced by this migration -->
34
+ {@html `<style>${styles(primaryColor)}</style>`}
35
+ </head>
36
+ <!-- eslint-disable-next-line svelte/no-raw-special-elements -- server-only full-document template, never mounted client-side -->
37
+ <body>
38
+ <div class="ska-layout">
39
+ <aside class="ska-sidebar">
40
+ <a href={basePath} class="ska-logo">{title}</a>
41
+ <nav>
42
+ <ul class="ska-nav">
43
+ <li class="ska-nav__item">
44
+ <a href={basePath} class="ska-nav__link" class:ska-nav__link--active={!currentModel}>
45
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
46
+ Dashboard
47
+ </a>
48
+ </li>
49
+ {#each modelList as m (m.name)}
50
+ <li class="ska-nav__item">
51
+ <a
52
+ href="{basePath}/{m.name.toLowerCase()}"
53
+ class="ska-nav__link"
54
+ class:ska-nav__link--active={currentModel?.toLowerCase() === m.name.toLowerCase()}
55
+ >
56
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>
57
+ {m.label}
58
+ </a>
59
+ </li>
60
+ {/each}
61
+ </ul>
62
+ </nav>
63
+ {#if showLogout}
64
+ <form method="POST" action="{basePath}/_logout" class="ska-logout">
65
+ <button type="submit" class="ska-logout__btn">
66
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
67
+ Log out
68
+ </button>
69
+ </form>
70
+ {/if}
71
+ </aside>
72
+ <main class="ska-main">
73
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- content is pre-rendered HTML from sibling view components / the handler's own escaped error string -->
74
+ {@html content}
75
+ </main>
76
+ </div>
77
+ </body>
78
+ </html>
@@ -0,0 +1,13 @@
1
+ import type { AdminHandlerConfig } from '../handler.js';
2
+ type $$ComponentProps = {
3
+ content: string;
4
+ config: AdminHandlerConfig;
5
+ modelList: Array<{
6
+ name: string;
7
+ label: string;
8
+ }>;
9
+ currentModel?: string;
10
+ };
11
+ declare const Layout: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type Layout = ReturnType<typeof Layout>;
13
+ export default Layout;
@@ -0,0 +1,240 @@
1
+ <script lang="ts">
2
+ import type { AdminHandlerConfig } from '../handler.js';
3
+ import type { ViewModel } from './types.js';
4
+ import type { ListQuery } from '../query/listQuery.js';
5
+ import type { ResolvedFilterField } from '../query/filterDetection.js';
6
+ import { DATETIME_PRESETS } from '../query/filterDetection.js';
7
+ import type { FkFilterMeta } from './types.js';
8
+ import { getDisplayFields } from '../introspection/parser.js';
9
+ import { buildListUrl, hiddenParams } from '../query/urls.js';
10
+ import { escapeHtml, toLabel, formatValue } from './html.js';
11
+ import ListFilters from './ListFilters.svelte';
12
+
13
+ let {
14
+ model,
15
+ items,
16
+ pagination,
17
+ basePath,
18
+ config,
19
+ query,
20
+ currentUrl,
21
+ listFilters,
22
+ fkFilterMeta
23
+ }: {
24
+ model: ViewModel;
25
+ items: any[];
26
+ pagination: { page: number; perPage: number; total: number };
27
+ basePath: string;
28
+ config: AdminHandlerConfig;
29
+ /** Recherche/filtres actifs, absent quand l'appelant ne les gère pas (rétrocompat des tests directs du composant). */
30
+ query?: ListQuery;
31
+ /** URL de la requête courante — nécessaire pour construire les liens de pagination et le form GET. Absent = pagination legacy `?page=N` isolée. */
32
+ currentUrl?: URL;
33
+ /** Filtres sidebar résolus (Boolean/enum/date/range/FK), absent = pas de sidebar rendue. */
34
+ listFilters?: ResolvedFilterField[];
35
+ /** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
36
+ fkFilterMeta?: Map<string, FkFilterMeta>;
37
+ } = $props();
38
+
39
+ const modelConfig = $derived(config.models?.[model.name] || {});
40
+ const hidden = $derived(modelConfig.hidden || []);
41
+ const listFields = $derived(modelConfig.listFields);
42
+
43
+ const displayFields = $derived.by(() => {
44
+ const explicit = new Set(listFields ?? []);
45
+ const safeNames = new Set(getDisplayFields(model).map((f) => f.name));
46
+
47
+ let fields = model.fields.filter(
48
+ (f) =>
49
+ (explicit.has(f.name) || safeNames.has(f.name)) &&
50
+ !hidden.includes(f.name) &&
51
+ !f.relation &&
52
+ !['Json', 'Bytes'].includes(f.type)
53
+ );
54
+
55
+ if (listFields?.length) {
56
+ fields = fields.filter((f) => listFields.includes(f.name));
57
+ }
58
+
59
+ return fields.slice(0, 6);
60
+ });
61
+
62
+ const listPath = $derived(`${basePath}/${model.name.toLowerCase()}`);
63
+ const totalPages = $derived(Math.ceil(pagination.total / pagination.perPage));
64
+
65
+ // Barre de recherche rendue seulement si des champs sont réellement
66
+ // cherchables pour ce modèle — jamais un input muet qui ne filtre rien
67
+ // (docs/design §2.1).
68
+ const hasSearch = $derived((query?.searchFields.length ?? 0) > 0);
69
+ const hasActiveCriteria = $derived(
70
+ Boolean(query && (query.q || query.filters.length > 0))
71
+ );
72
+
73
+ const pageHref = $derived.by(() => {
74
+ if (!currentUrl) return (n: number) => `?page=${n}`;
75
+ return (n: number) => buildListUrl(currentUrl, { page: String(n) });
76
+ });
77
+ const clearHref = $derived(currentUrl ? currentUrl.pathname : listPath);
78
+ const searchHiddenParams = $derived(currentUrl ? hiddenParams(currentUrl, ['q']) : []);
79
+
80
+ /** Valeur brute active par champ (pour marquer l'option correspondante dans la sidebar). */
81
+ const activeFilterValues = $derived.by(() => {
82
+ // Rendu SSR sans hydratation : la Map est construite une fois par
83
+ // render et jamais mutée après coup, SvelteMap n'a aucun intérêt ici.
84
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
85
+ const map = new Map<string, string>();
86
+ const presetSet = new Set<string>(DATETIME_PRESETS);
87
+ for (const f of query?.filters ?? []) {
88
+ if (f.op === 'equals') {
89
+ map.set(f.field, f.raw);
90
+ } else if (f.op === 'gte' && presetSet.has(f.raw)) {
91
+ // Un raccourci DateTime (§5.5) sort de parseListQuery avec
92
+ // `op: 'gte'` (le range gte/lt est fusionné dans un seul
93
+ // ActiveFilter) mais `raw` reste le nom du preset d'origine
94
+ // ('today'/'7d'/'month'/'year'), jamais la date calculée — c'est
95
+ // ce qui permet de le distinguer d'un `?f.x__gte=<date brute>`
96
+ // manuel, qui lui ne doit JAMAIS marquer une entrée de sidebar
97
+ // active (bug trouvé en review : sans cette distinction, aucun
98
+ // preset actif n'était jamais marqué, régression a11y contre
99
+ // aria-current exigé par §3.4).
100
+ map.set(f.field, f.raw);
101
+ }
102
+ }
103
+ return map;
104
+ });
105
+
106
+ /** Bornes gte/lte actives par champ (pour préremplir les inputs "range" de la sidebar). */
107
+ const activeRangeValues = $derived.by(() => {
108
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
109
+ const map = new Map<string, { gte?: string; lte?: string }>();
110
+ for (const f of query?.filters ?? []) {
111
+ if (f.op !== 'gte' && f.op !== 'lte') continue;
112
+ const entry = map.get(f.field) ?? {};
113
+ entry[f.op] = f.raw;
114
+ map.set(f.field, entry);
115
+ }
116
+ return map;
117
+ });
118
+
119
+ /**
120
+ * Messages « Filtre ignoré : champ "foo" inconnu » rendus pour chaque
121
+ * entrée `query.ignored`. Requis par docs/design §5.4, pour deux
122
+ * raisons : (1) sans ce message l'utilisateur ne comprend pas pourquoi
123
+ * son URL bricolée ne fait rien, (2) ça rend la branche `ignored`
124
+ * observable et testable via le rendu réel plutôt que par un appel
125
+ * unitaire isolé qui contourne le vrai chemin. Le message est le MÊME
126
+ * pour un champ sensible que pour un champ inconnu (§0.a, §5.4) — ne
127
+ * jamais dire "champ interdit", ça confirmerait son existence.
128
+ */
129
+ const ignoredMessages = $derived.by(() => {
130
+ return (query?.ignored ?? []).map((entry) => {
131
+ // `param` est soit `f.<field>` / `f.<field>__<op>` (nouveau format),
132
+ // soit littéralement `filter` (legacy `?filter=field:value` — le nom
133
+ // du champ ciblé n'est pas conservé côté IgnoredFilter pour ce
134
+ // chemin, seul le message générique s'applique).
135
+ if (entry.param === 'filter') {
136
+ return { key: entry.param, text: 'Ignored filter: legacy `filter=` value could not be applied' };
137
+ }
138
+ const withoutPrefix = entry.param.slice(2);
139
+ const sep = withoutPrefix.indexOf('__');
140
+ const fieldName = sep === -1 ? withoutPrefix : withoutPrefix.slice(0, sep);
141
+ return { key: entry.param, text: `Ignored filter: field "${fieldName}" unknown` };
142
+ });
143
+ });
144
+ </script>
145
+
146
+ <div class="ska-header">
147
+ <div>
148
+ <h1>{model.label}</h1>
149
+ <p class="ska-subtitle">{pagination.total} records</p>
150
+ </div>
151
+ <a href="{listPath}/new" class="ska-btn ska-btn--primary">
152
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
153
+ Add {model.label}
154
+ </a>
155
+ </div>
156
+
157
+ {#if listFilters && listFilters.length > 0 && currentUrl}
158
+ <ListFilters
159
+ filters={listFilters}
160
+ activeValues={activeFilterValues}
161
+ {activeRangeValues}
162
+ fkFilterMeta={fkFilterMeta ?? new Map()}
163
+ {currentUrl}
164
+ />
165
+ {/if}
166
+
167
+
168
+ {#if hasSearch}
169
+ <form method="GET" class="ska-search">
170
+ {#each searchHiddenParams as p (p.name)}
171
+ <input type="hidden" name={p.name} value={p.value} />
172
+ {/each}
173
+ <input
174
+ type="search"
175
+ name="q"
176
+ value={query?.q ?? ''}
177
+ placeholder="Search…"
178
+ class="ska-search__input"
179
+ />
180
+ <button type="submit" class="ska-btn ska-btn--secondary">Search</button>
181
+ </form>
182
+ {/if}
183
+
184
+ {#if ignoredMessages.length > 0}
185
+ <div class="ska-alert ska-alert--error">
186
+ {#each ignoredMessages as m (m.key)}
187
+ <p>{m.text}</p>
188
+ {/each}
189
+ </div>
190
+ {/if}
191
+
192
+ {#if hasActiveCriteria}
193
+ <p class="ska-subtitle">
194
+ <a href={clearHref} class="ska-back">Clear all filters</a>
195
+ </p>
196
+ {/if}
197
+
198
+ <div class="ska-card">
199
+ <div class="ska-table-wrap">
200
+ <table class="ska-table">
201
+ <thead>
202
+ <tr>
203
+ {#each displayFields as f (f.name)}<th>{toLabel(f.name)}</th>{/each}
204
+ <th>Actions</th>
205
+ </tr>
206
+ </thead>
207
+ <tbody>
208
+ {#if items.length === 0}
209
+ <tr>
210
+ <td colspan={displayFields.length + 1} style="text-align: center; color: #64748b; padding: 2rem;">
211
+ {hasActiveCriteria ? 'No results for these criteria' : 'No records found'}
212
+ </td>
213
+ </tr>
214
+ {:else}
215
+ {#each items as item (item[model.primaryKey])}
216
+ <tr>
217
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formatValue already escapes string values itself and returns a literal <span> only for null/undefined -->
218
+ {#each displayFields as f (f.name)}<td>{@html formatValue(item[f.name], f.type)}</td>{/each}
219
+ <td class="ska-table__actions">
220
+ <a href="{listPath}/{item[model.primaryKey]}" class="ska-btn ska-btn--secondary ska-btn--sm">Edit</a>
221
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- Svelte 5 rejects a literal onsubmit string as an event attribute; the PK is escaped manually here since it can't go through Svelte's native attribute escaping; the whole form (not just onsubmit) is rendered as raw HTML because there's no native-Svelte way to attach a plain inline onsubmit="..." string attribute at all in Svelte 5 templates, so the whole element had to be raw text to preserve the exact prior confirm-dialog behavior in a page that's never hydrated by a Svelte runtime -->
222
+ {@html `<form method="POST" action="${listPath}/${escapeHtml(String(item[model.primaryKey]))}" style="display:inline" onsubmit="return confirm('Delete this item?')"><input type="hidden" name="_action" value="delete"><button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete</button></form>`}
223
+ </td>
224
+ </tr>
225
+ {/each}
226
+ {/if}
227
+ </tbody>
228
+ </table>
229
+ </div>
230
+
231
+ {#if totalPages > 1}
232
+ <div class="ska-pagination">
233
+ <span class="ska-pagination__info">
234
+ Showing {(pagination.page - 1) * pagination.perPage + 1} to {Math.min(pagination.page * pagination.perPage, pagination.total)} of {pagination.total}
235
+ </span>
236
+ {#if pagination.page > 1}<a href={pageHref(pagination.page - 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Previous</a>{/if}
237
+ {#if pagination.page < totalPages}<a href={pageHref(pagination.page + 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Next</a>{/if}
238
+ </div>
239
+ {/if}
240
+ </div>
@@ -0,0 +1,27 @@
1
+ import type { AdminHandlerConfig } from '../handler.js';
2
+ import type { ViewModel } from './types.js';
3
+ import type { ListQuery } from '../query/listQuery.js';
4
+ import type { ResolvedFilterField } from '../query/filterDetection.js';
5
+ import type { FkFilterMeta } from './types.js';
6
+ type $$ComponentProps = {
7
+ model: ViewModel;
8
+ items: any[];
9
+ pagination: {
10
+ page: number;
11
+ perPage: number;
12
+ total: number;
13
+ };
14
+ basePath: string;
15
+ config: AdminHandlerConfig;
16
+ /** Recherche/filtres actifs, absent quand l'appelant ne les gère pas (rétrocompat des tests directs du composant). */
17
+ query?: ListQuery;
18
+ /** URL de la requête courante — nécessaire pour construire les liens de pagination et le form GET. Absent = pagination legacy `?page=N` isolée. */
19
+ currentUrl?: URL;
20
+ /** Filtres sidebar résolus (Boolean/enum/date/range/FK), absent = pas de sidebar rendue. */
21
+ listFilters?: ResolvedFilterField[];
22
+ /** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
23
+ fkFilterMeta?: Map<string, FkFilterMeta>;
24
+ };
25
+ declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
26
+ type List = ReturnType<typeof List>;
27
+ export default List;