sveltekit-admin 0.2.0 → 0.5.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.
- package/README.md +80 -2
- package/dist/index.d.ts +2 -7
- package/dist/index.js +2 -13
- package/dist/server/auth.d.ts +7 -0
- package/dist/server/auth.js +19 -0
- package/dist/server/data.d.ts +38 -0
- package/dist/server/data.js +116 -0
- package/dist/server/handler.d.ts +123 -4
- package/dist/server/handler.js +590 -819
- package/dist/server/introspection/parser.d.ts +19 -12
- package/dist/server/introspection/parser.js +71 -61
- package/dist/server/introspection/relations.d.ts +49 -0
- package/dist/server/introspection/relations.js +128 -0
- package/dist/server/query/filterDetection.d.ts +71 -0
- package/dist/server/query/filterDetection.js +153 -0
- package/dist/server/query/listQuery.d.ts +89 -0
- package/dist/server/query/listQuery.js +428 -0
- package/dist/server/query/urls.d.ts +33 -0
- package/dist/server/query/urls.js +58 -0
- package/dist/server/router.d.ts +6 -0
- package/dist/server/router.js +27 -0
- package/dist/server/views/Dashboard.svelte +29 -0
- package/dist/server/views/Dashboard.svelte.d.ts +15 -0
- package/dist/server/views/FieldInput.svelte +63 -0
- package/dist/server/views/FieldInput.svelte.d.ts +9 -0
- package/dist/server/views/Form.svelte +127 -0
- package/dist/server/views/Form.svelte.d.ts +12 -0
- package/dist/server/views/Layout.svelte +78 -0
- package/dist/server/views/Layout.svelte.d.ts +13 -0
- package/dist/server/views/List.svelte +240 -0
- package/dist/server/views/List.svelte.d.ts +27 -0
- package/dist/server/views/ListFilters.svelte +257 -0
- package/dist/server/views/ListFilters.svelte.d.ts +18 -0
- package/dist/server/views/ModelCard.svelte +11 -0
- package/dist/server/views/ModelCard.svelte.d.ts +8 -0
- package/dist/server/views/NotFound.svelte +7 -0
- package/dist/server/views/NotFound.svelte.d.ts +7 -0
- package/dist/server/views/RelatedBlock.svelte +74 -0
- package/dist/server/views/RelatedBlock.svelte.d.ts +11 -0
- package/dist/server/views/RelationCheckboxes.svelte +53 -0
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +53 -0
- package/dist/server/views/RelationSelect.svelte.d.ts +12 -0
- package/dist/server/views/StatCard.svelte +18 -0
- package/dist/server/views/StatCard.svelte.d.ts +8 -0
- package/dist/server/views/html.d.ts +5 -0
- package/dist/server/views/html.js +41 -0
- package/dist/server/views/theme.d.ts +1 -0
- package/dist/server/views/theme.js +484 -0
- package/dist/server/views/types.d.ts +57 -0
- package/dist/server/views/types.js +1 -0
- package/package.json +24 -26
- package/dist/admin.d.ts +0 -227
- package/dist/admin.js +0 -369
- package/dist/components/AdminForm.svelte +0 -423
- package/dist/components/AdminForm.svelte.d.ts +0 -30
- package/dist/components/AdminLayout.svelte +0 -328
- package/dist/components/AdminLayout.svelte.d.ts +0 -20
- package/dist/components/DataTable.svelte +0 -573
- package/dist/components/DataTable.svelte.d.ts +0 -25
- package/dist/components/index.d.ts +0 -3
- package/dist/components/index.js +0 -3
- package/dist/server/auth/guard.d.ts +0 -36
- package/dist/server/auth/guard.js +0 -38
- package/dist/server/auth/index.d.ts +0 -1
- package/dist/server/auth/index.js +0 -1
- package/dist/server/crud/index.d.ts +0 -1
- package/dist/server/crud/index.js +0 -1
- package/dist/server/crud/operations.d.ts +0 -87
- package/dist/server/crud/operations.js +0 -276
- package/dist/server/introspection/index.d.ts +0 -1
- package/dist/server/introspection/index.js +0 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ResolvedFilterField } from '../query/filterDetection.js';
|
|
3
|
+
import type { FkFilterMeta } from './types.js';
|
|
4
|
+
import { buildListUrl, hiddenParams } from '../query/urls.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sidebar de filtres façon Django admin. Boolean/enum/datetime rendus en
|
|
8
|
+
* liens (§3.1 : zéro JS, un clic = un état). Les plages numériques et les
|
|
9
|
+
* FK à forte cardinalité utilisent un `<form method="GET">` avec bouton
|
|
10
|
+
* explicite — la seule exception documentée au rendu en liens, qui reste
|
|
11
|
+
* fonctionnelle sans JavaScript (§3.2).
|
|
12
|
+
*/
|
|
13
|
+
let {
|
|
14
|
+
filters,
|
|
15
|
+
activeValues,
|
|
16
|
+
activeRangeValues,
|
|
17
|
+
fkFilterMeta,
|
|
18
|
+
currentUrl
|
|
19
|
+
}: {
|
|
20
|
+
filters: ResolvedFilterField[];
|
|
21
|
+
/** Valeur brute active par champ (Boolean/enum/datetime/FK), telle qu'apparue dans l'URL. */
|
|
22
|
+
activeValues: Map<string, string>;
|
|
23
|
+
/** Bornes actives par champ range, ex. { views: { gte: '10', lte: '100' } }. */
|
|
24
|
+
activeRangeValues: Map<string, { gte?: string; lte?: string }>;
|
|
25
|
+
/** Résolution async, scopée, des FK configurées. */
|
|
26
|
+
fkFilterMeta: Map<string, FkFilterMeta>;
|
|
27
|
+
currentUrl: URL;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
interface Option {
|
|
31
|
+
label: string;
|
|
32
|
+
href: string;
|
|
33
|
+
active: boolean;
|
|
34
|
+
/** Valeur brute envoyée dans `f.<field>` ; null représente l'option All. */
|
|
35
|
+
value: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface LinkGroup {
|
|
39
|
+
kind: 'links';
|
|
40
|
+
field: string;
|
|
41
|
+
label: string;
|
|
42
|
+
options: Option[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RangeGroup {
|
|
46
|
+
kind: 'range';
|
|
47
|
+
field: string;
|
|
48
|
+
label: string;
|
|
49
|
+
gteParam: string;
|
|
50
|
+
lteParam: string;
|
|
51
|
+
gteValue: string;
|
|
52
|
+
lteValue: string;
|
|
53
|
+
hidden: { name: string; value: string }[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface FkGroup {
|
|
57
|
+
kind: 'fk';
|
|
58
|
+
field: string;
|
|
59
|
+
label: string;
|
|
60
|
+
mode: 'links' | 'select' | 'raw-id';
|
|
61
|
+
options: Option[];
|
|
62
|
+
inputValue: string;
|
|
63
|
+
hidden: { name: string; value: string }[];
|
|
64
|
+
activeLabel?: string;
|
|
65
|
+
activeHref?: string;
|
|
66
|
+
clearHref: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PRESET_LABELS: Record<string, string> = {
|
|
70
|
+
today: 'Today',
|
|
71
|
+
'7d': 'Last 7 days',
|
|
72
|
+
month: 'This month',
|
|
73
|
+
year: 'This year'
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const groups = $derived.by((): (LinkGroup | RangeGroup | FkGroup)[] => {
|
|
77
|
+
return filters.flatMap((f): (LinkGroup | RangeGroup | FkGroup)[] => {
|
|
78
|
+
const paramName = `f.${f.field}`;
|
|
79
|
+
|
|
80
|
+
if (f.kind === 'fk') {
|
|
81
|
+
const meta = fkFilterMeta.get(f.field);
|
|
82
|
+
// Résolution async indisponible (client Prisma incomplet, modèle
|
|
83
|
+
// cible absent...) : ne pas afficher un filtre mort. Le filtre URL
|
|
84
|
+
// reste accepté par parseListQuery, mais l'UI ne l'invente pas.
|
|
85
|
+
if (!meta) return [];
|
|
86
|
+
const current = activeValues.get(f.field);
|
|
87
|
+
const allOption: Option = {
|
|
88
|
+
label: 'All',
|
|
89
|
+
href: buildListUrl(currentUrl, { [paramName]: null }),
|
|
90
|
+
active: current === undefined,
|
|
91
|
+
value: null
|
|
92
|
+
};
|
|
93
|
+
const options = meta.options.map((o) => ({
|
|
94
|
+
label: o.label,
|
|
95
|
+
href: buildListUrl(currentUrl, { [paramName]: String(o.id) }),
|
|
96
|
+
active: current === String(o.id),
|
|
97
|
+
value: String(o.id)
|
|
98
|
+
}));
|
|
99
|
+
return [{
|
|
100
|
+
kind: 'fk',
|
|
101
|
+
field: f.field,
|
|
102
|
+
label: f.label,
|
|
103
|
+
mode: meta.mode,
|
|
104
|
+
options: [allOption, ...options],
|
|
105
|
+
inputValue: current ?? '',
|
|
106
|
+
hidden: hiddenParams(currentUrl, [paramName]),
|
|
107
|
+
// Une valeur forgée hors scope a activeLabel undefined : afficher
|
|
108
|
+
// l'ID brut dans le chip, jamais un label qui fuit un autre tenant.
|
|
109
|
+
activeLabel: current === undefined ? undefined : meta.activeLabel ?? current,
|
|
110
|
+
activeHref: meta.activeHref,
|
|
111
|
+
clearHref: buildListUrl(currentUrl, { [paramName]: null })
|
|
112
|
+
}];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (f.kind === 'range') {
|
|
116
|
+
const gteParam = `f.${f.field}__gte`;
|
|
117
|
+
const lteParam = `f.${f.field}__lte`;
|
|
118
|
+
const active = activeRangeValues.get(f.field);
|
|
119
|
+
return [{
|
|
120
|
+
kind: 'range',
|
|
121
|
+
field: f.field,
|
|
122
|
+
label: f.label,
|
|
123
|
+
gteParam,
|
|
124
|
+
lteParam,
|
|
125
|
+
gteValue: active?.gte ?? '',
|
|
126
|
+
lteValue: active?.lte ?? '',
|
|
127
|
+
hidden: hiddenParams(currentUrl, [gteParam, lteParam])
|
|
128
|
+
}];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const current = activeValues.get(f.field);
|
|
132
|
+
const allOption: Option = {
|
|
133
|
+
label: 'All',
|
|
134
|
+
href: buildListUrl(currentUrl, { [paramName]: null }),
|
|
135
|
+
active: current === undefined,
|
|
136
|
+
value: null
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let valueOptions: Option[];
|
|
140
|
+
if (f.kind === 'boolean') {
|
|
141
|
+
valueOptions = [
|
|
142
|
+
{ value: 'true', label: 'Yes' },
|
|
143
|
+
{ value: 'false', label: 'No' }
|
|
144
|
+
].map((o) => ({
|
|
145
|
+
label: o.label,
|
|
146
|
+
href: buildListUrl(currentUrl, { [paramName]: o.value }),
|
|
147
|
+
active: current === o.value,
|
|
148
|
+
value: o.value
|
|
149
|
+
}));
|
|
150
|
+
} else if (f.kind === 'datetime') {
|
|
151
|
+
valueOptions = (f.presets ?? []).map((p) => ({
|
|
152
|
+
label: PRESET_LABELS[p] ?? p,
|
|
153
|
+
href: buildListUrl(currentUrl, { [paramName]: p }),
|
|
154
|
+
active: current === p,
|
|
155
|
+
value: p
|
|
156
|
+
}));
|
|
157
|
+
} else {
|
|
158
|
+
valueOptions = (f.enumValues ?? []).map((v) => ({
|
|
159
|
+
label: v,
|
|
160
|
+
href: buildListUrl(currentUrl, { [paramName]: v }),
|
|
161
|
+
active: current === v,
|
|
162
|
+
value: v
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return [{ kind: 'links', field: f.field, label: f.label, options: [allOption, ...valueOptions] }];
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
{#if groups.length > 0}
|
|
172
|
+
<div class="ska-card ska-filters">
|
|
173
|
+
{#each groups as group (group.field)}
|
|
174
|
+
<div class="ska-filters__group">
|
|
175
|
+
<h3 class="ska-filters__title">{group.label}</h3>
|
|
176
|
+
|
|
177
|
+
{#if group.kind === 'range'}
|
|
178
|
+
<form method="GET" class="ska-filters__range">
|
|
179
|
+
{#each group.hidden as p (p.name)}
|
|
180
|
+
<input type="hidden" name={p.name} value={p.value} />
|
|
181
|
+
{/each}
|
|
182
|
+
<input
|
|
183
|
+
type="number"
|
|
184
|
+
name={group.gteParam}
|
|
185
|
+
value={group.gteValue}
|
|
186
|
+
placeholder="Min"
|
|
187
|
+
class="ska-filters__range-input"
|
|
188
|
+
/>
|
|
189
|
+
<input
|
|
190
|
+
type="number"
|
|
191
|
+
name={group.lteParam}
|
|
192
|
+
value={group.lteValue}
|
|
193
|
+
placeholder="Max"
|
|
194
|
+
class="ska-filters__range-input"
|
|
195
|
+
/>
|
|
196
|
+
<button type="submit" class="ska-btn ska-btn--secondary ska-btn--sm">Apply</button>
|
|
197
|
+
</form>
|
|
198
|
+
|
|
199
|
+
{:else if group.kind === 'fk' && group.activeLabel}
|
|
200
|
+
<p class="ska-filters__chip">
|
|
201
|
+
{#if group.activeHref}
|
|
202
|
+
<a href={group.activeHref}>{group.activeLabel}</a>
|
|
203
|
+
{:else}
|
|
204
|
+
<span>{group.activeLabel}</span>
|
|
205
|
+
{/if}
|
|
206
|
+
<a href={group.clearHref} class="ska-filters__chip-clear" aria-label={`Clear ${group.label}`}>×</a>
|
|
207
|
+
</p>
|
|
208
|
+
{/if}
|
|
209
|
+
|
|
210
|
+
{#if group.kind === 'fk' && group.mode === 'select'}
|
|
211
|
+
<form method="GET" class="ska-filters__select">
|
|
212
|
+
{#each group.hidden as p (p.name)}
|
|
213
|
+
<input type="hidden" name={p.name} value={p.value} />
|
|
214
|
+
{/each}
|
|
215
|
+
<select name={`f.${group.field}`} class="ska-filters__select-input">
|
|
216
|
+
{#each group.options as option (option.value ?? 'all')}
|
|
217
|
+
<option value={option.value ?? ''} selected={option.active}>{option.label}</option>
|
|
218
|
+
{/each}
|
|
219
|
+
</select>
|
|
220
|
+
<button type="submit" class="ska-btn ska-btn--secondary ska-btn--sm">Apply</button>
|
|
221
|
+
</form>
|
|
222
|
+
|
|
223
|
+
{:else if group.kind === 'fk' && group.mode === 'raw-id'}
|
|
224
|
+
<form method="GET" class="ska-filters__range">
|
|
225
|
+
{#each group.hidden as p (p.name)}
|
|
226
|
+
<input type="hidden" name={p.name} value={p.value} />
|
|
227
|
+
{/each}
|
|
228
|
+
<input
|
|
229
|
+
type="text"
|
|
230
|
+
name={`f.${group.field}`}
|
|
231
|
+
value={group.inputValue}
|
|
232
|
+
placeholder="ID"
|
|
233
|
+
class="ska-filters__range-input"
|
|
234
|
+
/>
|
|
235
|
+
<button type="submit" class="ska-btn ska-btn--secondary ska-btn--sm">Apply</button>
|
|
236
|
+
</form>
|
|
237
|
+
|
|
238
|
+
{:else if group.kind === 'links' || (group.kind === 'fk' && group.mode === 'links')}
|
|
239
|
+
<ul class="ska-filters__list">
|
|
240
|
+
{#each group.options as option (option.value ?? 'all')}
|
|
241
|
+
<li>
|
|
242
|
+
<a
|
|
243
|
+
href={option.href}
|
|
244
|
+
class="ska-filters__link"
|
|
245
|
+
class:ska-filters__link--active={option.active}
|
|
246
|
+
aria-current={option.active ? 'page' : undefined}
|
|
247
|
+
>
|
|
248
|
+
{option.label}
|
|
249
|
+
</a>
|
|
250
|
+
</li>
|
|
251
|
+
{/each}
|
|
252
|
+
</ul>
|
|
253
|
+
{/if}
|
|
254
|
+
</div>
|
|
255
|
+
{/each}
|
|
256
|
+
</div>
|
|
257
|
+
{/if}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResolvedFilterField } from '../query/filterDetection.js';
|
|
2
|
+
import type { FkFilterMeta } from './types.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
filters: ResolvedFilterField[];
|
|
5
|
+
/** Valeur brute active par champ (Boolean/enum/datetime/FK), telle qu'apparue dans l'URL. */
|
|
6
|
+
activeValues: Map<string, string>;
|
|
7
|
+
/** Bornes actives par champ range, ex. { views: { gte: '10', lte: '100' } }. */
|
|
8
|
+
activeRangeValues: Map<string, {
|
|
9
|
+
gte?: string;
|
|
10
|
+
lte?: string;
|
|
11
|
+
}>;
|
|
12
|
+
/** Résolution async, scopée, des FK configurées. */
|
|
13
|
+
fkFilterMeta: Map<string, FkFilterMeta>;
|
|
14
|
+
currentUrl: URL;
|
|
15
|
+
};
|
|
16
|
+
declare const ListFilters: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type ListFilters = ReturnType<typeof ListFilters>;
|
|
18
|
+
export default ListFilters;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { href, label, count }: { href: string; label: string; count: number } = $props();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<a {href} class="ska-model-card">
|
|
6
|
+
<div>
|
|
7
|
+
<div class="ska-model-card__name">{label}</div>
|
|
8
|
+
<div class="ska-model-card__count">{count} records</div>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="ska-model-card__footer">Manage →</div>
|
|
11
|
+
</a>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { RelationEdge, RelationGraph } from '../introspection/relations.js';
|
|
3
|
+
import { toLabel } from './html.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bloc read-only pour les relations inverses (1-N inverse, 1-1 inverse) :
|
|
7
|
+
* compteur + lien "voir tout" filtré + lien "ajouter" pré-rempli avec la FK.
|
|
8
|
+
* Jamais éditable depuis ce formulaire — éditer une liste d'enfants depuis
|
|
9
|
+
* le parent, c'est un inline formset (non-objectif, voir
|
|
10
|
+
* docs/design/relations.md §2.5/§3.4).
|
|
11
|
+
*/
|
|
12
|
+
let {
|
|
13
|
+
edges,
|
|
14
|
+
graph,
|
|
15
|
+
counts,
|
|
16
|
+
currentId,
|
|
17
|
+
basePath
|
|
18
|
+
}: {
|
|
19
|
+
edges: RelationEdge[];
|
|
20
|
+
graph: RelationGraph;
|
|
21
|
+
counts: Map<string, number>;
|
|
22
|
+
currentId: string | number;
|
|
23
|
+
basePath: string;
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
interface Row {
|
|
27
|
+
field: string;
|
|
28
|
+
label: string;
|
|
29
|
+
count: number;
|
|
30
|
+
viewAllHref: string;
|
|
31
|
+
addHref: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rows = $derived.by((): Row[] => {
|
|
35
|
+
const out: Row[] = [];
|
|
36
|
+
for (const e of edges) {
|
|
37
|
+
// Le champ FK vit sur la contrepartie owning (ex: `User.posts` inverse
|
|
38
|
+
// ↔ `Post.author` owning, qui porte `authorId`), pas sur `e` lui-même.
|
|
39
|
+
const owning = [...graph.edges.values()].find(
|
|
40
|
+
(o) => o.model === e.target && o.kind === 'to-one-owning' && o.relationName === e.relationName
|
|
41
|
+
);
|
|
42
|
+
if (!owning || owning.unsupported) continue;
|
|
43
|
+
|
|
44
|
+
const targetPath = `${basePath}/${e.target.toLowerCase()}`;
|
|
45
|
+
const scalarName = owning.scalarFields[0];
|
|
46
|
+
out.push({
|
|
47
|
+
field: e.field,
|
|
48
|
+
label: toLabel(e.field),
|
|
49
|
+
count: counts.get(`${e.model}.${e.field}`)!,
|
|
50
|
+
// `f.<field>=<value>` — même pipeline sécurisé que la recherche/
|
|
51
|
+
// filtre (whitelist + coercion, docs/design/list-search-filters.md).
|
|
52
|
+
// L'ancien `?filter=field:value` reste supporté en lecture pour la
|
|
53
|
+
// rétrocompatibilité, mais ce composant n'en émet plus.
|
|
54
|
+
viewAllHref: `${targetPath}?f.${encodeURIComponent(scalarName)}=${encodeURIComponent(String(currentId))}`,
|
|
55
|
+
addHref: `${targetPath}/new?${encodeURIComponent(scalarName)}=${encodeURIComponent(String(currentId))}`
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
});
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if rows.length > 0}
|
|
63
|
+
<div class="ska-card ska-related">
|
|
64
|
+
<h2 class="ska-subtitle">Liaisons</h2>
|
|
65
|
+
{#each rows as row (row.field)}
|
|
66
|
+
<div class="ska-related-row">
|
|
67
|
+
<span class="ska-label">{row.label}</span>
|
|
68
|
+
<span class="ska-subtitle">{row.count} record{row.count === 1 ? '' : 's'}</span>
|
|
69
|
+
<a href={row.viewAllHref} class="ska-btn ska-btn--secondary ska-btn--sm">Voir tout</a>
|
|
70
|
+
<a href={row.addHref} class="ska-btn ska-btn--secondary ska-btn--sm">Ajouter</a>
|
|
71
|
+
</div>
|
|
72
|
+
{/each}
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RelationEdge, RelationGraph } from '../introspection/relations.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
edges: RelationEdge[];
|
|
4
|
+
graph: RelationGraph;
|
|
5
|
+
counts: Map<string, number>;
|
|
6
|
+
currentId: string | number;
|
|
7
|
+
basePath: string;
|
|
8
|
+
};
|
|
9
|
+
declare const RelatedBlock: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type RelatedBlock = ReturnType<typeof RelatedBlock>;
|
|
11
|
+
export default RelatedBlock;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { RelationEdge } from '../introspection/relations.js';
|
|
3
|
+
import type { RelationMeta } from './types.js';
|
|
4
|
+
import { toLabel } from './html.js';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
edge,
|
|
8
|
+
meta
|
|
9
|
+
}: {
|
|
10
|
+
edge: RelationEdge;
|
|
11
|
+
meta: RelationMeta;
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
const label = $derived(toLabel(edge.field));
|
|
15
|
+
const inputName = $derived(`__rel__${edge.field}`);
|
|
16
|
+
const selected = $derived(new Set((meta.selectedIds ?? []).map(String)));
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<!--
|
|
20
|
+
Fieldset de checkboxes pour une arête m2m-implicite.
|
|
21
|
+
|
|
22
|
+
Nommage `__rel__<field>` pour les valeurs cochées et un hidden sentinelle
|
|
23
|
+
`__rel_present__<field>` toujours émis : en HTML, zéro checkbox cochée
|
|
24
|
+
retire la clé du POST, donc impossible de distinguer "tout décoché" de
|
|
25
|
+
"widget absent" sans ce sentinelle. Voir docs/design/relations.md §3.1.
|
|
26
|
+
-->
|
|
27
|
+
{#if meta.tooMany}
|
|
28
|
+
<!-- Au-delà du seuil : liste d'IDs texte, jamais des centaines de checkboxes. -->
|
|
29
|
+
<div class="ska-field">
|
|
30
|
+
<label class="ska-label" for={inputName}>{label} (IDs séparés par des virgules)</label>
|
|
31
|
+
<input type="hidden" name="__rel_present__{edge.field}" value="1" />
|
|
32
|
+
<input id={inputName} type="text" name={inputName} value={[...selected].join(',')} class="ska-input" />
|
|
33
|
+
</div>
|
|
34
|
+
{:else}
|
|
35
|
+
<div class="ska-field">
|
|
36
|
+
<label class="ska-label" for={inputName}>{label}</label>
|
|
37
|
+
<input type="hidden" name="__rel_present__{edge.field}" value="1" />
|
|
38
|
+
<fieldset id={inputName} class="ska-checkbox-group">
|
|
39
|
+
{#each meta.options as opt (opt.id)}
|
|
40
|
+
<label class="ska-checkbox-wrap">
|
|
41
|
+
<input
|
|
42
|
+
type="checkbox"
|
|
43
|
+
name={inputName}
|
|
44
|
+
value={opt.id}
|
|
45
|
+
class="ska-checkbox"
|
|
46
|
+
checked={selected.has(String(opt.id))}
|
|
47
|
+
/>
|
|
48
|
+
<span class="ska-label">{opt.label}</span>
|
|
49
|
+
</label>
|
|
50
|
+
{/each}
|
|
51
|
+
</fieldset>
|
|
52
|
+
</div>
|
|
53
|
+
{/if}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RelationEdge } from '../introspection/relations.js';
|
|
2
|
+
import type { RelationMeta } from './types.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
edge: RelationEdge;
|
|
5
|
+
meta: RelationMeta;
|
|
6
|
+
};
|
|
7
|
+
declare const RelationCheckboxes: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type RelationCheckboxes = ReturnType<typeof RelationCheckboxes>;
|
|
9
|
+
export default RelationCheckboxes;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AdminHandlerConfig } from '../handler.js';
|
|
3
|
+
import type { RelationEdge } from '../introspection/relations.js';
|
|
4
|
+
import type { RelationMeta } from './types.js';
|
|
5
|
+
import { toLabel } from './html.js';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
edge,
|
|
9
|
+
meta,
|
|
10
|
+
currentValue,
|
|
11
|
+
config
|
|
12
|
+
}: {
|
|
13
|
+
edge: RelationEdge;
|
|
14
|
+
meta: RelationMeta;
|
|
15
|
+
currentValue: unknown;
|
|
16
|
+
config: AdminHandlerConfig;
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
const relConfig = $derived(config.models?.[edge.model]?.relations?.[edge.field]);
|
|
20
|
+
const label = $derived(toLabel(edge.field));
|
|
21
|
+
const required = $derived(edge.isRequired);
|
|
22
|
+
const nullLabel = $derived(relConfig?.nullLabel ?? '— aucun —');
|
|
23
|
+
// Toujours défini pour une arête to-one-owning : c'est ce qui la distingue
|
|
24
|
+
// des autres kinds dans le graphe (voir buildRelationGraph).
|
|
25
|
+
const scalarName = $derived(edge.scalarFields[0]);
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
{#if meta.tooMany}
|
|
29
|
+
<!-- Au-delà du seuil : raw-id, jamais un select de 10k lignes. -->
|
|
30
|
+
<div class="ska-field">
|
|
31
|
+
<label class="ska-label" for={scalarName}>{label} (ID){required ? ' *' : ''}</label>
|
|
32
|
+
<input
|
|
33
|
+
id={scalarName}
|
|
34
|
+
type="text"
|
|
35
|
+
name={scalarName}
|
|
36
|
+
value={currentValue ?? ''}
|
|
37
|
+
class="ska-input"
|
|
38
|
+
required={required}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
{:else}
|
|
42
|
+
<div class="ska-field">
|
|
43
|
+
<label class="ska-label" for={scalarName}>{label}{required ? ' *' : ''}</label>
|
|
44
|
+
<select id={scalarName} name={scalarName} class="ska-input" required={required}>
|
|
45
|
+
{#if !required}
|
|
46
|
+
<option value="">{nullLabel}</option>
|
|
47
|
+
{/if}
|
|
48
|
+
{#each meta.options as opt (opt.id)}
|
|
49
|
+
<option value={opt.id} selected={String(opt.id) === String(currentValue ?? '')}>{opt.label}</option>
|
|
50
|
+
{/each}
|
|
51
|
+
</select>
|
|
52
|
+
</div>
|
|
53
|
+
{/if}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AdminHandlerConfig } from '../handler.js';
|
|
2
|
+
import type { RelationEdge } from '../introspection/relations.js';
|
|
3
|
+
import type { RelationMeta } from './types.js';
|
|
4
|
+
type $$ComponentProps = {
|
|
5
|
+
edge: RelationEdge;
|
|
6
|
+
meta: RelationMeta;
|
|
7
|
+
currentValue: unknown;
|
|
8
|
+
config: AdminHandlerConfig;
|
|
9
|
+
};
|
|
10
|
+
declare const RelationSelect: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type RelationSelect = ReturnType<typeof RelationSelect>;
|
|
12
|
+
export default RelationSelect;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { value, label, icon }: { value: number; label: string; icon: 'models' | 'records' } =
|
|
3
|
+
$props();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class="ska-stat">
|
|
7
|
+
<div class="ska-stat__icon">
|
|
8
|
+
{#if icon === 'models'}
|
|
9
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/></svg>
|
|
10
|
+
{:else}
|
|
11
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
|
12
|
+
{/if}
|
|
13
|
+
</div>
|
|
14
|
+
<div>
|
|
15
|
+
<div class="ska-stat__value">{value}</div>
|
|
16
|
+
<div class="ska-stat__label">{label}</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Primitives de rendu partagées par toutes les vues. */
|
|
2
|
+
export declare function escapeHtml(str: string): string;
|
|
3
|
+
export declare function toLabel(name: string): string;
|
|
4
|
+
export declare function adjustColor(hex: string, percent: number): string;
|
|
5
|
+
export declare function formatValue(value: any, type: string): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Primitives de rendu partagées par toutes les vues. */
|
|
2
|
+
export function escapeHtml(str) {
|
|
3
|
+
return str
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''');
|
|
9
|
+
}
|
|
10
|
+
export function toLabel(name) {
|
|
11
|
+
return name.replace(/([A-Z])/g, ' $1').trim();
|
|
12
|
+
}
|
|
13
|
+
/** Couleur de repli, alignée sur le `primaryColor` par défaut du layout. */
|
|
14
|
+
const DEFAULT_PRIMARY = '#6366f1';
|
|
15
|
+
export function adjustColor(hex, percent) {
|
|
16
|
+
// Sans ce garde-fou, un hex invalide part en `parseInt(…, 16)` → NaN, que les
|
|
17
|
+
// décalages de bits ramènent à 0 : la fonction rendait '#000000' sans rien signaler.
|
|
18
|
+
if (!/^#?[0-9a-f]{6}$/i.test(hex))
|
|
19
|
+
return DEFAULT_PRIMARY;
|
|
20
|
+
const num = parseInt(hex.replace('#', ''), 16);
|
|
21
|
+
const amt = Math.round(2.55 * percent);
|
|
22
|
+
const R = Math.max(0, Math.min(255, (num >> 16) + amt));
|
|
23
|
+
const G = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amt));
|
|
24
|
+
const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt));
|
|
25
|
+
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
|
|
26
|
+
}
|
|
27
|
+
export function formatValue(value, type) {
|
|
28
|
+
if (value === null || value === undefined)
|
|
29
|
+
return '<span style="color:#94a3b8">—</span>';
|
|
30
|
+
if (type === 'DateTime') {
|
|
31
|
+
return new Date(value).toLocaleString();
|
|
32
|
+
}
|
|
33
|
+
if (type === 'Boolean') {
|
|
34
|
+
return value ? '✓' : '✗';
|
|
35
|
+
}
|
|
36
|
+
const str = String(value);
|
|
37
|
+
if (str.length > 50) {
|
|
38
|
+
return escapeHtml(str.slice(0, 50)) + '...';
|
|
39
|
+
}
|
|
40
|
+
return escapeHtml(str);
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function styles(primaryColor: string): string;
|