sveltekit-admin 0.5.0 → 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.
- package/LICENSE +21 -0
- package/dist/server/handler.js +61 -51
- package/dist/server/query/listQuery.d.ts +4 -1
- package/dist/server/query/listQuery.js +16 -2
- package/dist/server/views/theme.js +72 -19
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dotNacer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/server/handler.js
CHANGED
|
@@ -141,17 +141,22 @@ export function createAdminHandler(config) {
|
|
|
141
141
|
* charger 10k lignes pour découvrir qu'il y en a 10k.
|
|
142
142
|
*/
|
|
143
143
|
const loadRelationOptions = async (model, ctx, currentId) => {
|
|
144
|
-
const
|
|
145
|
-
for (const edge of relationGraph.edges.values()) {
|
|
144
|
+
const edges = [...relationGraph.edges.values()].filter((edge) => {
|
|
146
145
|
if (edge.model !== model.name)
|
|
147
|
-
|
|
146
|
+
return false;
|
|
148
147
|
if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m-implicit')
|
|
149
|
-
|
|
148
|
+
return false;
|
|
150
149
|
if (edge.unsupported)
|
|
151
|
-
|
|
150
|
+
return false;
|
|
151
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
152
|
+
return relConfig?.widget !== 'hidden';
|
|
153
|
+
});
|
|
154
|
+
// Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
|
|
155
|
+
// qu'en série (un modèle avec N relations ne doit pas payer N
|
|
156
|
+
// aller-retours DB empilés pour afficher un seul formulaire).
|
|
157
|
+
const entries = await Promise.all(edges.map(async (edge) => {
|
|
158
|
+
const key = `${edge.model}.${edge.field}`;
|
|
152
159
|
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
153
|
-
if (relConfig?.widget === 'hidden')
|
|
154
|
-
continue;
|
|
155
160
|
const targetModel = schema.models.find((m) => m.name === edge.target);
|
|
156
161
|
const where = relConfig?.where ? relConfig.where(ctx) : undefined;
|
|
157
162
|
const prismaKey = toPrismaModel(edge.target);
|
|
@@ -161,8 +166,7 @@ export function createAdminHandler(config) {
|
|
|
161
166
|
const selectedIds = edge.kind === 'm2m-implicit' && currentId
|
|
162
167
|
? await loadSelectedIds(model, edge, currentId, targetModel)
|
|
163
168
|
: undefined;
|
|
164
|
-
|
|
165
|
-
continue;
|
|
169
|
+
return [key, { tooMany: true, options: [], selectedIds }];
|
|
166
170
|
}
|
|
167
171
|
const rows = await prisma[prismaKey].findMany({
|
|
168
172
|
where,
|
|
@@ -175,15 +179,15 @@ export function createAdminHandler(config) {
|
|
|
175
179
|
const selectedIds = edge.kind === 'm2m-implicit' && currentId
|
|
176
180
|
? await loadSelectedIds(model, edge, currentId, targetModel)
|
|
177
181
|
: undefined;
|
|
178
|
-
|
|
182
|
+
return [key, { tooMany: false, options, selectedIds }];
|
|
179
183
|
}
|
|
180
184
|
catch {
|
|
181
185
|
// Cible absente de la base ou client incomplet : repli raw-id pour
|
|
182
186
|
// garder le champ éditable plutôt que de faire échouer tout le form.
|
|
183
|
-
|
|
187
|
+
return [key, { tooMany: true, options: [] }];
|
|
184
188
|
}
|
|
185
|
-
}
|
|
186
|
-
return
|
|
189
|
+
}));
|
|
190
|
+
return new Map(entries);
|
|
187
191
|
};
|
|
188
192
|
/**
|
|
189
193
|
* Options d'un filtre FK : charge et scope les valeurs possibles pour la
|
|
@@ -206,43 +210,48 @@ export function createAdminHandler(config) {
|
|
|
206
210
|
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
207
211
|
const scope = relConfig?.where ? relConfig.where(ctx) : undefined;
|
|
208
212
|
const prismaKey = toPrismaModel(edge.target);
|
|
209
|
-
// Options de la sidebar
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
// Options de la sidebar (comptées puis chargées si sous le seuil) et
|
|
214
|
+
// label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
|
|
215
|
+
// ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
|
|
216
|
+
// série.
|
|
217
|
+
const loadOptions = async () => {
|
|
218
|
+
try {
|
|
219
|
+
const total = await prisma[prismaKey].count({ where: scope });
|
|
220
|
+
if (total > selectThreshold) {
|
|
221
|
+
return { options: [], tooMany: true };
|
|
222
|
+
}
|
|
218
223
|
const rows = await prisma[prismaKey].findMany({
|
|
219
224
|
where: scope,
|
|
220
225
|
orderBy: relConfig?.orderBy
|
|
221
226
|
});
|
|
222
|
-
options = rows.map((row) => ({
|
|
227
|
+
const options = rows.map((row) => ({
|
|
223
228
|
id: row[primaryKeyOf(targetModel)],
|
|
224
229
|
label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
|
|
225
230
|
}));
|
|
231
|
+
return { options, tooMany: false };
|
|
226
232
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
catch {
|
|
234
|
+
return { options: [], tooMany: true };
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
// Un ID hors scope retourne null ici → le composant affiche l'ID brut,
|
|
238
|
+
// jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
|
|
239
|
+
// d'un autre tenant).
|
|
240
|
+
const loadActiveLabel = async () => {
|
|
241
|
+
if (activeRawValue === undefined)
|
|
242
|
+
return undefined;
|
|
235
243
|
const activeId = coerceId(activeRawValue, targetModel);
|
|
236
244
|
try {
|
|
237
245
|
const row = await prisma[prismaKey].findFirst({
|
|
238
246
|
where: scope ? { AND: [{ [primaryKeyOf(targetModel)]: activeId }, scope] } : { [primaryKeyOf(targetModel)]: activeId }
|
|
239
247
|
});
|
|
240
|
-
|
|
248
|
+
return row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
|
|
241
249
|
}
|
|
242
250
|
catch {
|
|
243
|
-
|
|
251
|
+
return undefined;
|
|
244
252
|
}
|
|
245
|
-
}
|
|
253
|
+
};
|
|
254
|
+
const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
|
|
246
255
|
return {
|
|
247
256
|
field: fkFieldName,
|
|
248
257
|
label,
|
|
@@ -280,27 +289,26 @@ export function createAdminHandler(config) {
|
|
|
280
289
|
* le rendu du formulaire.
|
|
281
290
|
*/
|
|
282
291
|
const loadRelatedCounts = async (model, currentId) => {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (edge.kind !== 'to-many-inverse' && edge.kind !== 'to-one-inverse')
|
|
288
|
-
continue;
|
|
292
|
+
const edges = [...relationGraph.edges.values()].filter((edge) => edge.model === model.name && (edge.kind === 'to-many-inverse' || edge.kind === 'to-one-inverse'));
|
|
293
|
+
// Un count par relation inverse, indépendants entre eux : en parallèle
|
|
294
|
+
// plutôt qu'empilés un par un (même raisonnement que loadRelationOptions).
|
|
295
|
+
const entries = await Promise.all(edges.map(async (edge) => {
|
|
289
296
|
const owning = [...relationGraph.edges.values()].find((o) => o.model === edge.target && o.kind === 'to-one-owning' && o.relationName === edge.relationName);
|
|
290
297
|
if (!owning || owning.unsupported)
|
|
291
|
-
|
|
298
|
+
return undefined;
|
|
292
299
|
const scalarName = owning.scalarFields[0];
|
|
300
|
+
const key = `${edge.model}.${edge.field}`;
|
|
293
301
|
try {
|
|
294
302
|
const count = await prisma[toPrismaModel(edge.target)].count({
|
|
295
303
|
where: { [scalarName]: coerceId(currentId, model) }
|
|
296
304
|
});
|
|
297
|
-
|
|
305
|
+
return [key, count];
|
|
298
306
|
}
|
|
299
307
|
catch {
|
|
300
|
-
|
|
308
|
+
return [key, 0];
|
|
301
309
|
}
|
|
302
|
-
}
|
|
303
|
-
return
|
|
310
|
+
}));
|
|
311
|
+
return new Map(entries.filter((e) => e !== undefined));
|
|
304
312
|
};
|
|
305
313
|
/**
|
|
306
314
|
* Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
|
|
@@ -588,14 +596,16 @@ export function createAdminHandler(config) {
|
|
|
588
596
|
const where = buildWhere(listQuery, listScope, caseInsensitiveSearch, model);
|
|
589
597
|
const { items, total } = await listRecords(prisma, model, page, PER_PAGE, where);
|
|
590
598
|
const listFilters = resolveListFilters(model, schema.enums, modelsConfig[model.name]?.listFilter, toLabel, relationGraph, hiddenFieldsOf(model), config.listFilterDefaults?.autoDetect ?? true);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
599
|
+
// Un filtre FK ne dépend pas de l'autre : résolus en parallèle
|
|
600
|
+
// plutôt qu'un par un (même raisonnement que loadRelationOptions).
|
|
601
|
+
const fkFilterEntries = await Promise.all(listFilters
|
|
602
|
+
.filter((filter) => filter.kind === 'fk')
|
|
603
|
+
.map(async (filter) => {
|
|
595
604
|
const activeRawValue = listQuery.filters.find((f) => f.field === filter.field && f.op === 'equals')?.raw;
|
|
596
605
|
const meta = await resolveFkFilterOptions(model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
|
|
597
|
-
|
|
598
|
-
}
|
|
606
|
+
return [filter.field, meta];
|
|
607
|
+
}));
|
|
608
|
+
const fkFilterMeta = new Map(fkFilterEntries);
|
|
599
609
|
content = render(List, {
|
|
600
610
|
props: {
|
|
601
611
|
model: viewModel(model),
|
|
@@ -32,7 +32,10 @@ export interface ListQuery {
|
|
|
32
32
|
filters: ActiveFilter[];
|
|
33
33
|
ignored: IgnoredFilter[];
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Field-name candidates for the default search heuristic (§2.1). Relation-label
|
|
37
|
+
* resolution (handler.ts) keeps its own separate list — the two are not shared.
|
|
38
|
+
*/
|
|
36
39
|
export declare const DEFAULT_LABEL_FIELDS: string[];
|
|
37
40
|
/**
|
|
38
41
|
* Fields eligible for the free-text search box.
|
|
@@ -14,8 +14,22 @@
|
|
|
14
14
|
import { isSensitiveFieldName } from '../introspection/parser.js';
|
|
15
15
|
/** Max length accepted for the free-text search term. Longer input is truncated. */
|
|
16
16
|
const MAX_SEARCH_LENGTH = 200;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Field-name candidates for the default search heuristic (§2.1). Relation-label
|
|
19
|
+
* resolution (handler.ts) keeps its own separate list — the two are not shared.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_LABEL_FIELDS = [
|
|
22
|
+
'name',
|
|
23
|
+
'title',
|
|
24
|
+
'label',
|
|
25
|
+
'email',
|
|
26
|
+
'username',
|
|
27
|
+
'slug',
|
|
28
|
+
'description',
|
|
29
|
+
'content',
|
|
30
|
+
'body',
|
|
31
|
+
'text'
|
|
32
|
+
];
|
|
19
33
|
/** Types eligible for the free-text search heuristic (String only, see §2.1). */
|
|
20
34
|
function isSearchableByHeuristic(field) {
|
|
21
35
|
return (field.type === 'String' &&
|
|
@@ -392,61 +392,114 @@ export function styles(primaryColor) {
|
|
|
392
392
|
.ska-filters {
|
|
393
393
|
margin-bottom: 1.5rem;
|
|
394
394
|
display: flex;
|
|
395
|
-
flex-
|
|
396
|
-
gap:
|
|
395
|
+
flex-direction: column;
|
|
396
|
+
gap: 0.75rem;
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
.ska-filters__group {
|
|
400
|
-
|
|
400
|
+
display: flex;
|
|
401
|
+
align-items: center;
|
|
402
|
+
flex-wrap: wrap;
|
|
403
|
+
gap: 0.625rem;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.ska-filters__group + .ska-filters__group {
|
|
407
|
+
padding-top: 0.75rem;
|
|
408
|
+
border-top: 1px solid #f1f5f9;
|
|
401
409
|
}
|
|
402
410
|
|
|
403
411
|
.ska-filters__title {
|
|
404
|
-
font-size: 0.
|
|
412
|
+
font-size: 0.6875rem;
|
|
405
413
|
font-weight: 600;
|
|
406
414
|
text-transform: uppercase;
|
|
407
415
|
letter-spacing: 0.05em;
|
|
408
|
-
color: #
|
|
409
|
-
|
|
416
|
+
color: #94a3b8;
|
|
417
|
+
min-width: 88px;
|
|
418
|
+
flex-shrink: 0;
|
|
410
419
|
}
|
|
411
420
|
|
|
412
421
|
.ska-filters__list {
|
|
413
422
|
list-style: none;
|
|
414
423
|
display: flex;
|
|
415
|
-
flex-
|
|
416
|
-
gap: 0.
|
|
424
|
+
flex-wrap: wrap;
|
|
425
|
+
gap: 0.375rem;
|
|
417
426
|
}
|
|
418
427
|
|
|
419
428
|
.ska-filters__link {
|
|
420
|
-
display:
|
|
421
|
-
|
|
422
|
-
|
|
429
|
+
display: inline-flex;
|
|
430
|
+
align-items: center;
|
|
431
|
+
padding: 0.25rem 0.625rem;
|
|
432
|
+
border-radius: 999px;
|
|
433
|
+
border: 1px solid #e2e8f0;
|
|
423
434
|
color: #475569;
|
|
424
435
|
text-decoration: none;
|
|
425
|
-
font-size: 0.
|
|
436
|
+
font-size: 0.8125rem;
|
|
437
|
+
line-height: 1.25rem;
|
|
438
|
+
white-space: nowrap;
|
|
439
|
+
transition: all 0.15s;
|
|
426
440
|
}
|
|
427
441
|
|
|
428
442
|
.ska-filters__link:hover {
|
|
429
443
|
background: #f1f5f9;
|
|
444
|
+
border-color: #cbd5e1;
|
|
430
445
|
}
|
|
431
446
|
|
|
432
447
|
.ska-filters__link--active {
|
|
433
|
-
background:
|
|
434
|
-
color: var(--ska-primary);
|
|
448
|
+
background: var(--ska-primary);
|
|
449
|
+
border-color: var(--ska-primary);
|
|
450
|
+
color: white;
|
|
435
451
|
font-weight: 500;
|
|
436
452
|
}
|
|
437
453
|
|
|
438
|
-
.ska-filters__range
|
|
454
|
+
.ska-filters__range,
|
|
455
|
+
.ska-filters__select {
|
|
439
456
|
display: flex;
|
|
440
|
-
|
|
441
|
-
gap: 0.
|
|
457
|
+
align-items: center;
|
|
458
|
+
gap: 0.5rem;
|
|
442
459
|
}
|
|
443
460
|
|
|
444
461
|
.ska-filters__range-input {
|
|
445
|
-
padding: 0.
|
|
462
|
+
padding: 0.25rem 0.5rem;
|
|
446
463
|
border: 1px solid #e2e8f0;
|
|
447
464
|
border-radius: 0.25rem;
|
|
448
465
|
font-size: 0.8125rem;
|
|
449
|
-
width:
|
|
466
|
+
width: 6.5rem;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.ska-filters__select-input {
|
|
470
|
+
padding: 0.25rem 0.625rem;
|
|
471
|
+
border: 1px solid #e2e8f0;
|
|
472
|
+
border-radius: 0.25rem;
|
|
473
|
+
font-size: 0.8125rem;
|
|
474
|
+
color: #475569;
|
|
475
|
+
background: white;
|
|
476
|
+
max-width: 220px;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.ska-filters__chip {
|
|
480
|
+
display: inline-flex;
|
|
481
|
+
align-items: center;
|
|
482
|
+
gap: 0.375rem;
|
|
483
|
+
padding: 0.25rem 0.5rem 0.25rem 0.625rem;
|
|
484
|
+
border-radius: 999px;
|
|
485
|
+
background: #eef2ff;
|
|
486
|
+
color: var(--ska-primary);
|
|
487
|
+
font-size: 0.8125rem;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.ska-filters__chip a {
|
|
491
|
+
color: inherit;
|
|
492
|
+
text-decoration: none;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.ska-filters__chip-clear {
|
|
496
|
+
color: #64748b;
|
|
497
|
+
text-decoration: none;
|
|
498
|
+
line-height: 1;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.ska-filters__chip-clear:hover {
|
|
502
|
+
color: #dc2626;
|
|
450
503
|
}
|
|
451
504
|
|
|
452
505
|
/* Back link */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sveltekit-admin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Django-like admin panel for SvelteKit + Prisma",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"svelte": "./dist/index.js",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"license": "MIT",
|
|
72
72
|
"repository": {
|
|
73
73
|
"type": "git",
|
|
74
|
-
"url": "https://github.com/dotNacer/sveltekit-admin.git"
|
|
74
|
+
"url": "git+https://github.com/dotNacer/sveltekit-admin.git"
|
|
75
75
|
},
|
|
76
76
|
"homepage": "https://github.com/dotNacer/sveltekit-admin#readme",
|
|
77
77
|
"bugs": {
|