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.
- package/LICENSE +21 -0
- 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 +600 -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 +92 -0
- package/dist/server/query/listQuery.js +442 -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 +537 -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,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;
|