@svadmin/ui 0.34.10 → 0.35.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/dist/app.css +372 -0
- package/dist/component-registry.svelte.d.ts +1 -0
- package/dist/components/AdminApp.svelte +94 -15
- package/dist/components/AdminApp.svelte.d.ts +32 -2
- package/dist/components/ApiSettings.svelte +191 -0
- package/dist/components/ApiSettings.svelte.d.ts +3 -0
- package/dist/components/AutoForm.svelte +48 -43
- package/dist/components/AutoTable.svelte +27 -22
- package/dist/components/ErrorPage.svelte +58 -0
- package/dist/components/ErrorPage.svelte.d.ts +8 -0
- package/dist/components/FieldRenderer.svelte +2 -2
- package/dist/components/ForgotPasswordPage.svelte +111 -68
- package/dist/components/Header.svelte +6 -7
- package/dist/components/IntegrationsSettings.svelte +70 -0
- package/dist/components/IntegrationsSettings.svelte.d.ts +4 -0
- package/dist/components/Layout.svelte +8 -8
- package/dist/components/LoginPage.svelte +207 -105
- package/dist/components/LoginPage.svelte.d.ts +3 -15
- package/dist/components/NotificationsSettings.svelte +115 -0
- package/dist/components/NotificationsSettings.svelte.d.ts +3 -0
- package/dist/components/PageHeader.svelte +9 -7
- package/dist/components/RegisterPage.svelte +103 -65
- package/dist/components/ResourceOperationsPage.svelte +153 -0
- package/dist/components/ResourceOperationsPage.svelte.d.ts +37 -0
- package/dist/components/SecuritySettings.svelte +179 -0
- package/dist/components/SecuritySettings.svelte.d.ts +4 -0
- package/dist/components/SettingsPage.svelte +121 -54
- package/dist/components/SettingsPage.svelte.d.ts +25 -1
- package/dist/components/ShowPage.svelte +9 -9
- package/dist/components/Sidebar.svelte +96 -48
- package/dist/components/SidebarItem.svelte +37 -12
- package/dist/components/TwoFactorAuthPage.svelte +247 -0
- package/dist/components/TwoFactorAuthPage.svelte.d.ts +3 -0
- package/dist/components/UpdatePasswordPage.svelte +86 -48
- package/dist/components/account/CompanyProfilePage.svelte +99 -0
- package/dist/components/account/CompanyProfilePage.svelte.d.ts +3 -0
- package/dist/components/account/GetStartedPage.svelte +123 -0
- package/dist/components/account/GetStartedPage.svelte.d.ts +4 -0
- package/dist/components/account/ImportMembersPage.svelte +143 -0
- package/dist/components/account/ImportMembersPage.svelte.d.ts +3 -0
- package/dist/components/account/MembersStarterPage.svelte +104 -0
- package/dist/components/account/MembersStarterPage.svelte.d.ts +3 -0
- package/dist/components/account/SecurityLogPage.svelte +108 -0
- package/dist/components/account/SecurityLogPage.svelte.d.ts +3 -0
- package/dist/components/account/SettingsEnterprisePage.svelte +131 -0
- package/dist/components/account/SettingsEnterprisePage.svelte.d.ts +3 -0
- package/dist/components/account/SettingsPlainPage.svelte +81 -0
- package/dist/components/account/SettingsPlainPage.svelte.d.ts +3 -0
- package/dist/components/account/SettingsSidebarPage.svelte +98 -0
- package/dist/components/account/SettingsSidebarPage.svelte.d.ts +3 -0
- package/dist/components/account/TeamMembersPage.svelte +98 -0
- package/dist/components/account/TeamMembersPage.svelte.d.ts +3 -0
- package/dist/components/network/TeamCrewTablePage.svelte +122 -0
- package/dist/components/network/TeamCrewTablePage.svelte.d.ts +3 -0
- package/dist/components/network/UserCardsNFTPage.svelte +134 -0
- package/dist/components/network/UserCardsNFTPage.svelte.d.ts +3 -0
- package/dist/components/profile/ActivityTimeline.svelte +77 -0
- package/dist/components/profile/ActivityTimeline.svelte.d.ts +16 -0
- package/dist/components/profile/ProfileCard.svelte +216 -0
- package/dist/components/profile/ProfileCard.svelte.d.ts +27 -0
- package/dist/components/profile/ProjectsGrid.svelte +91 -0
- package/dist/components/profile/ProjectsGrid.svelte.d.ts +17 -0
- package/dist/components/profile/PublicProfilePage.svelte +179 -0
- package/dist/components/profile/PublicProfilePage.svelte.d.ts +10 -0
- package/dist/components/profile/TeamsShowcase.svelte +98 -0
- package/dist/components/profile/TeamsShowcase.svelte.d.ts +19 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +27 -0
- package/dist/router-state.svelte.js +17 -0
- package/package.json +2 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { AlertCircle, Calendar, Check, Copy, Key, Plus, ShieldCheck, Trash2 } from '@lucide/svelte';
|
|
4
|
+
import * as Card from './ui/card/index.js';
|
|
5
|
+
import { Badge } from './ui/badge/index.js';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import { Checkbox } from './ui/checkbox/index.js';
|
|
8
|
+
import { Input } from './ui/input/index.js';
|
|
9
|
+
import { Label } from './ui/label/index.js';
|
|
10
|
+
|
|
11
|
+
interface ApiKey {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
maskedToken: string;
|
|
15
|
+
created: string;
|
|
16
|
+
permissions: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let keys = $state<ApiKey[]>([
|
|
20
|
+
{ id: 'prod-sync', name: 'Production Sync Service', maskedToken: 'sv_demo_prod_••••_4d3e', created: '2026-03-10', permissions: ['Read', 'Write'] },
|
|
21
|
+
{ id: 'reporting', name: 'Internal Reporting Tool', maskedToken: 'sv_demo_report_••••_9e8f', created: '2026-05-18', permissions: ['Read'] },
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
let newKeyName = $state('');
|
|
25
|
+
let newKeyPermissions = $state({ Read: true, Write: false, Delete: false, Admin: false });
|
|
26
|
+
let generatedToken = $state('');
|
|
27
|
+
let showCreatedToken = $state(false);
|
|
28
|
+
let copiedKeyId = $state<string | null>(null);
|
|
29
|
+
|
|
30
|
+
function generateDemoToken() {
|
|
31
|
+
const randomHex = Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
32
|
+
return `sv_demo_${randomHex}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function handleGenerateKey(event: SubmitEvent) {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
if (!newKeyName.trim()) return;
|
|
38
|
+
|
|
39
|
+
const token = generateDemoToken();
|
|
40
|
+
const permissions = Object.entries(newKeyPermissions)
|
|
41
|
+
.filter(([, enabled]) => enabled)
|
|
42
|
+
.map(([name]) => name);
|
|
43
|
+
|
|
44
|
+
keys = [
|
|
45
|
+
...keys,
|
|
46
|
+
{
|
|
47
|
+
id: Date.now().toString(),
|
|
48
|
+
name: newKeyName.trim(),
|
|
49
|
+
maskedToken: `${token.slice(0, 14)}••••${token.slice(-4)}`,
|
|
50
|
+
created: new Date().toISOString().split('T')[0],
|
|
51
|
+
permissions,
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
generatedToken = token;
|
|
55
|
+
showCreatedToken = true;
|
|
56
|
+
newKeyName = '';
|
|
57
|
+
newKeyPermissions = { Read: true, Write: false, Delete: false, Admin: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deleteKey(id: string) {
|
|
61
|
+
keys = keys.filter((key) => key.id !== id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function copyToken(token: string, id: string) {
|
|
65
|
+
try {
|
|
66
|
+
await navigator.clipboard.writeText(token);
|
|
67
|
+
copiedKeyId = id;
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
if (copiedKeyId === id) copiedKeyId = null;
|
|
70
|
+
}, 2000);
|
|
71
|
+
} catch {
|
|
72
|
+
copiedKeyId = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<div class="space-y-6">
|
|
78
|
+
<div>
|
|
79
|
+
<h2 class="text-xl font-semibold text-foreground">{t('settings.api')}</h2>
|
|
80
|
+
<p class="mt-1 text-sm text-muted-foreground">{t('settings.apiDescription')}</p>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="grid gap-6 lg:grid-cols-3">
|
|
84
|
+
<Card.Card class="lg:col-span-1">
|
|
85
|
+
<Card.CardHeader>
|
|
86
|
+
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
87
|
+
<Plus class="h-4 w-4" />
|
|
88
|
+
{t('api.generateTitle')}
|
|
89
|
+
</Card.CardTitle>
|
|
90
|
+
<Card.CardDescription>{t('api.generateDesc')}</Card.CardDescription>
|
|
91
|
+
</Card.CardHeader>
|
|
92
|
+
<Card.CardContent>
|
|
93
|
+
<form onsubmit={handleGenerateKey} class="space-y-4">
|
|
94
|
+
<div class="space-y-2">
|
|
95
|
+
<Label for="api-key-name">{t('api.keyName')}</Label>
|
|
96
|
+
<Input id="api-key-name" placeholder="CI deployment token" bind:value={newKeyName} required />
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="space-y-3">
|
|
100
|
+
<Label>{t('api.permissions')}</Label>
|
|
101
|
+
{#each Object.keys(newKeyPermissions) as permission (permission)}
|
|
102
|
+
<div class="flex items-center gap-2">
|
|
103
|
+
<Checkbox id={`perm-${permission}`} bind:checked={newKeyPermissions[permission as keyof typeof newKeyPermissions]} />
|
|
104
|
+
<Label for={`perm-${permission}`} class="cursor-pointer text-sm font-normal">{permission}</Label>
|
|
105
|
+
</div>
|
|
106
|
+
{/each}
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<Button type="submit" class="w-full">{t('api.generateButton')}</Button>
|
|
110
|
+
</form>
|
|
111
|
+
</Card.CardContent>
|
|
112
|
+
</Card.Card>
|
|
113
|
+
|
|
114
|
+
<Card.Card class="lg:col-span-2">
|
|
115
|
+
<Card.CardHeader>
|
|
116
|
+
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
117
|
+
<Key class="h-4 w-4 text-muted-foreground" />
|
|
118
|
+
{t('api.activeKeysTitle')}
|
|
119
|
+
</Card.CardTitle>
|
|
120
|
+
<Card.CardDescription>{t('api.activeKeysDesc')}</Card.CardDescription>
|
|
121
|
+
</Card.CardHeader>
|
|
122
|
+
<Card.CardContent>
|
|
123
|
+
{#if showCreatedToken}
|
|
124
|
+
<div class="mb-6 space-y-3 rounded-lg border border-green-500/20 bg-green-500/5 p-4">
|
|
125
|
+
<div class="flex items-start gap-2 text-sm text-green-700 dark:text-green-300">
|
|
126
|
+
<ShieldCheck class="mt-0.5 h-5 w-5 shrink-0" />
|
|
127
|
+
<div>
|
|
128
|
+
<h4 class="font-semibold">{t('api.successTitle')}</h4>
|
|
129
|
+
<p class="mt-0.5 text-xs">{t('api.successHint')}</p>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="flex items-center gap-2">
|
|
133
|
+
<Input readonly value={generatedToken} class="h-9 font-mono text-xs" />
|
|
134
|
+
<Button size="sm" onclick={() => copyToken(generatedToken, 'new-token')}>
|
|
135
|
+
{#if copiedKeyId === 'new-token'}
|
|
136
|
+
<Check class="h-4 w-4" />
|
|
137
|
+
{:else}
|
|
138
|
+
<Copy class="h-4 w-4" />
|
|
139
|
+
{/if}
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
142
|
+
<div class="flex justify-end">
|
|
143
|
+
<Button variant="ghost" size="sm" onclick={() => { showCreatedToken = false; generatedToken = ''; }}>{t('common.close')}</Button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
{/if}
|
|
147
|
+
|
|
148
|
+
<div class="divide-y">
|
|
149
|
+
{#each keys as key (key.id)}
|
|
150
|
+
<div class="space-y-3 py-4 first:pt-0 last:pb-0">
|
|
151
|
+
<div class="flex items-start justify-between gap-4">
|
|
152
|
+
<div class="min-w-0 space-y-1">
|
|
153
|
+
<h4 class="text-sm font-semibold text-foreground">{key.name}</h4>
|
|
154
|
+
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
155
|
+
<span class="font-mono">{key.maskedToken}</span>
|
|
156
|
+
<span class="flex items-center gap-1">
|
|
157
|
+
<Calendar class="h-3 w-3" />
|
|
158
|
+
{key.created}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
163
|
+
<Button variant="ghost" size="icon-sm" onclick={() => copyToken(key.maskedToken, key.id)} title={t('common.copy')}>
|
|
164
|
+
{#if copiedKeyId === key.id}
|
|
165
|
+
<Check class="h-4 w-4 text-green-500" />
|
|
166
|
+
{:else}
|
|
167
|
+
<Copy class="h-4 w-4" />
|
|
168
|
+
{/if}
|
|
169
|
+
</Button>
|
|
170
|
+
<Button variant="ghost" size="icon-sm" onclick={() => deleteKey(key.id)} title={t('common.delete')}>
|
|
171
|
+
<Trash2 class="h-4 w-4" />
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="flex flex-wrap gap-1">
|
|
176
|
+
{#each key.permissions as permission (permission)}
|
|
177
|
+
<Badge variant="secondary" class="px-2 py-0 text-[10px]">{permission}</Badge>
|
|
178
|
+
{/each}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
{:else}
|
|
182
|
+
<div class="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted-foreground">
|
|
183
|
+
<AlertCircle class="h-8 w-8 opacity-60" />
|
|
184
|
+
<span>{t('api.noKeys')}</span>
|
|
185
|
+
</div>
|
|
186
|
+
{/each}
|
|
187
|
+
</div>
|
|
188
|
+
</Card.CardContent>
|
|
189
|
+
</Card.Card>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
3
2
|
import { useForm, getResource, deriveValidator } from '@svadmin/core';
|
|
4
3
|
import { slide } from 'svelte/transition';
|
|
5
4
|
import type { FieldDefinition } from '@svadmin/core';
|
|
@@ -45,14 +44,17 @@
|
|
|
45
44
|
const hasGroups = $derived(formFields.some(f => f.group));
|
|
46
45
|
const groups = $derived((() => {
|
|
47
46
|
if (!hasGroups) return [];
|
|
48
|
-
const
|
|
49
|
-
const map = new Map<string, FieldDefinition[]>();
|
|
47
|
+
const result: Array<{ name: string; fields: FieldDefinition[] }> = [];
|
|
50
48
|
for (const f of formFields) {
|
|
51
49
|
const g = f.group ?? '';
|
|
52
|
-
|
|
53
|
-
(
|
|
50
|
+
let group = result.find(item => item.name === g);
|
|
51
|
+
if (!group) {
|
|
52
|
+
group = { name: g, fields: [] };
|
|
53
|
+
result.push(group);
|
|
54
|
+
}
|
|
55
|
+
group.fields.push(f);
|
|
54
56
|
}
|
|
55
|
-
return
|
|
57
|
+
return result;
|
|
56
58
|
})());
|
|
57
59
|
|
|
58
60
|
// ─── Default values from field metadata ───────────────────────────
|
|
@@ -76,18 +78,6 @@
|
|
|
76
78
|
|
|
77
79
|
const validator = $derived(deriveValidator(formFields));
|
|
78
80
|
|
|
79
|
-
$effect(() => {
|
|
80
|
-
if (mode !== 'create') return;
|
|
81
|
-
if (!form.isTainted()) {
|
|
82
|
-
for (const key in defaults) {
|
|
83
|
-
if (form.values[key] !== defaults[key]) {
|
|
84
|
-
form.setFieldValue(key, defaults[key]);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
form.untaint();
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
81
|
// ─── useForm: single source of truth for values, errors, tainted ──
|
|
92
82
|
const form = useForm({
|
|
93
83
|
get resource() { return resourceName; },
|
|
@@ -145,23 +135,37 @@
|
|
|
145
135
|
}
|
|
146
136
|
</script>
|
|
147
137
|
|
|
148
|
-
<div class="space-y-6">
|
|
149
|
-
<div class="
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
|
|
138
|
+
<div class="space-y-6" data-svadmin-form-shell>
|
|
139
|
+
<div class="rounded-3xl border border-border/70 bg-card/90 p-5 shadow-sm shadow-slate-900/[0.03] backdrop-blur" data-svadmin-form-header>
|
|
140
|
+
<div class="flex flex-wrap items-center gap-4">
|
|
141
|
+
<TooltipButton
|
|
142
|
+
tooltip={t('common.back')}
|
|
143
|
+
onclick={() => guardNavigate(() => navigate(`/${resourceName}`))}
|
|
144
|
+
class="h-10 w-10 rounded-xl border border-border/70 bg-background/80"
|
|
145
|
+
>
|
|
146
|
+
<ArrowLeft class="h-5 w-5" />
|
|
147
|
+
</TooltipButton>
|
|
148
|
+
<div class="min-w-0 flex-1">
|
|
149
|
+
<h1 class="truncate text-2xl font-semibold tracking-tight text-foreground">{pageTitle}</h1>
|
|
150
|
+
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
151
|
+
<Badge variant="secondary" class="rounded-full px-2.5 py-0.5 text-[0.6875rem] font-semibold">
|
|
152
|
+
{mode === 'create' ? t('common.create') : mode === 'show' ? t('common.detail') : t('common.edit')}
|
|
153
|
+
</Badge>
|
|
154
|
+
<p class="text-sm text-muted-foreground">{resource.label}</p>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
{#if headerContent}
|
|
158
|
+
{@render headerContent()}
|
|
159
|
+
{/if}
|
|
160
|
+
{#if form.isTainted()}
|
|
161
|
+
<Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{t('common.unsaved')}</Badge>
|
|
162
|
+
{/if}
|
|
163
|
+
</div>
|
|
160
164
|
</div>
|
|
161
165
|
|
|
162
166
|
{#if form.loading}
|
|
163
|
-
<div class="max-w-
|
|
164
|
-
<div class="
|
|
167
|
+
<div class="max-w-4xl space-y-6">
|
|
168
|
+
<div class="space-y-5 rounded-3xl border border-border/70 bg-card/90 p-6 shadow-sm shadow-slate-900/[0.03]">
|
|
165
169
|
{#each Array(4) as _, _i (_i)}
|
|
166
170
|
<div class="space-y-2">
|
|
167
171
|
<Skeleton class="h-4 w-24" />
|
|
@@ -171,7 +175,7 @@
|
|
|
171
175
|
</div>
|
|
172
176
|
</div>
|
|
173
177
|
{:else}
|
|
174
|
-
<form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-
|
|
178
|
+
<form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-5xl space-y-6" data-svadmin-form-settings>
|
|
175
179
|
{#if submitError}
|
|
176
180
|
<div transition:slide={{ duration: 300, axis: 'y' }} class="svadmin-shake">
|
|
177
181
|
<Alert.Root variant="destructive">
|
|
@@ -183,15 +187,15 @@
|
|
|
183
187
|
|
|
184
188
|
{#if hasGroups}
|
|
185
189
|
{#each groups as group, _i (_i)}
|
|
186
|
-
<Card.Root class="border-border/
|
|
190
|
+
<Card.Root class="overflow-hidden rounded-3xl border-border/70 bg-card/95 shadow-sm shadow-slate-900/[0.03]" data-svadmin-form-card>
|
|
187
191
|
{#if group.name}
|
|
188
|
-
<Card.Header>
|
|
189
|
-
<Card.Title class="text-
|
|
192
|
+
<Card.Header class="border-b border-border/60 bg-muted/25 px-5 py-4 sm:px-6">
|
|
193
|
+
<Card.Title class="text-base font-semibold">{group.name}</Card.Title>
|
|
190
194
|
</Card.Header>
|
|
191
195
|
{/if}
|
|
192
|
-
<Card.Content class="
|
|
196
|
+
<Card.Content class="px-4 py-0 sm:px-6" data-svadmin-form-content>
|
|
193
197
|
{#each group.fields as field (field.key)}
|
|
194
|
-
<div class:border-destructive={!!form.errors[field.key]}>
|
|
198
|
+
<div data-svadmin-form-row class:border-destructive={!!form.errors[field.key]}>
|
|
195
199
|
{#if fieldRenderer}
|
|
196
200
|
{@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
|
|
197
201
|
{:else}
|
|
@@ -211,10 +215,10 @@
|
|
|
211
215
|
</Card.Root>
|
|
212
216
|
{/each}
|
|
213
217
|
{:else}
|
|
214
|
-
<Card.Root class="border-border/
|
|
215
|
-
<Card.Content class="
|
|
218
|
+
<Card.Root class="rounded-3xl border-border/70 bg-card/95 shadow-sm shadow-slate-900/[0.03]" data-svadmin-form-card>
|
|
219
|
+
<Card.Content class="px-4 py-0 sm:px-6" data-svadmin-form-content>
|
|
216
220
|
{#each formFields as field (field.key)}
|
|
217
|
-
<div class:border-destructive={!!form.errors[field.key]}>
|
|
221
|
+
<div data-svadmin-form-row class:border-destructive={!!form.errors[field.key]}>
|
|
218
222
|
{#if fieldRenderer}
|
|
219
223
|
{@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
|
|
220
224
|
{:else}
|
|
@@ -234,11 +238,11 @@
|
|
|
234
238
|
</Card.Root>
|
|
235
239
|
{/if}
|
|
236
240
|
|
|
237
|
-
<div class="flex items-center gap-3">
|
|
241
|
+
<div class="sticky bottom-4 z-10 flex items-center gap-3 rounded-3xl border border-border/70 bg-card/90 p-3 shadow-lg shadow-slate-900/[0.05] backdrop-blur" data-svadmin-form-actions>
|
|
238
242
|
{#if formActions}
|
|
239
243
|
{@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
|
|
240
244
|
{:else if !isReadonly}
|
|
241
|
-
<Button type="submit" disabled={form.submitting}>
|
|
245
|
+
<Button type="submit" disabled={form.submitting} class="h-10 rounded-xl px-5 shadow-sm shadow-primary/15">
|
|
242
246
|
{#if form.submitting}
|
|
243
247
|
<Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
|
|
244
248
|
{:else}
|
|
@@ -249,6 +253,7 @@
|
|
|
249
253
|
<Button
|
|
250
254
|
type="button"
|
|
251
255
|
variant="outline"
|
|
256
|
+
class="h-10 rounded-xl"
|
|
252
257
|
onclick={() => guardNavigate(() => navigate(`/${resourceName}`))}
|
|
253
258
|
>
|
|
254
259
|
{t('common.cancel')}
|
|
@@ -484,13 +484,17 @@
|
|
|
484
484
|
}
|
|
485
485
|
</script>
|
|
486
486
|
|
|
487
|
-
<div class="space-y-
|
|
487
|
+
<div class="space-y-5" data-svadmin-list>
|
|
488
488
|
<!-- Header -->
|
|
489
|
-
<div class="
|
|
490
|
-
<
|
|
491
|
-
|
|
489
|
+
<div class="rounded-3xl border border-border/70 bg-card/90 p-5 shadow-sm shadow-slate-900/[0.03] backdrop-blur" data-svadmin-list-header>
|
|
490
|
+
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
491
|
+
<div class="min-w-0">
|
|
492
|
+
<h1 class="truncate text-2xl font-semibold tracking-tight text-foreground">{resource.label}</h1>
|
|
493
|
+
<p class="mt-1 text-sm text-muted-foreground">{t('common.list')}</p>
|
|
494
|
+
</div>
|
|
495
|
+
<div class="flex flex-wrap items-center gap-2" data-svadmin-list-actions>
|
|
492
496
|
{#if canExport}
|
|
493
|
-
<Button variant="outline" size="sm" onclick={exportCSV}>
|
|
497
|
+
<Button variant="outline" size="sm" class="h-10 rounded-xl" onclick={exportCSV}>
|
|
494
498
|
<Download class="h-4 w-4" data-icon="inline-start" /> {t('common.export')}
|
|
495
499
|
</Button>
|
|
496
500
|
{/if}
|
|
@@ -513,8 +517,8 @@
|
|
|
513
517
|
<DropdownMenu.Root>
|
|
514
518
|
<DropdownMenu.Trigger>
|
|
515
519
|
{#snippet child({ props })}
|
|
516
|
-
<Button variant="outline" size="sm" {...props}>
|
|
517
|
-
<SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {t('common.columns')
|
|
520
|
+
<Button variant="outline" size="sm" class="h-10 rounded-xl" {...props}>
|
|
521
|
+
<SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {t('common.columns')}
|
|
518
522
|
</Button>
|
|
519
523
|
{/snippet}
|
|
520
524
|
</DropdownMenu.Trigger>
|
|
@@ -533,15 +537,16 @@
|
|
|
533
537
|
{@render headerActions()}
|
|
534
538
|
{/if}
|
|
535
539
|
{#if canCreate}
|
|
536
|
-
<Button onclick={() => navigate(`/${resourceName}/create`)}>
|
|
540
|
+
<Button class="h-10 rounded-xl px-4 shadow-sm shadow-primary/15" onclick={() => navigate(`/${resourceName}/create`)}>
|
|
537
541
|
<Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
|
|
538
542
|
</Button>
|
|
539
543
|
{/if}
|
|
544
|
+
</div>
|
|
540
545
|
</div>
|
|
541
546
|
</div>
|
|
542
547
|
|
|
543
548
|
<!-- Search and Advanced Filters -->
|
|
544
|
-
<div class="flex flex-wrap items-center gap-
|
|
549
|
+
<div class="flex flex-wrap items-center gap-3 rounded-3xl border border-border/70 bg-card/80 p-3 shadow-sm shadow-slate-900/[0.02] backdrop-blur" data-svadmin-table-toolbar>
|
|
545
550
|
{#if searchableFields.length > 0}
|
|
546
551
|
<div class="relative max-w-sm flex-1 sm:min-w-[250px]">
|
|
547
552
|
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
@@ -550,7 +555,7 @@
|
|
|
550
555
|
bind:value={searchText}
|
|
551
556
|
oninput={() => { pagination = { ...pagination, current: 1 }; }}
|
|
552
557
|
placeholder={t('common.search')}
|
|
553
|
-
class="
|
|
558
|
+
class="h-10 rounded-xl border-border/70 bg-background/85 pl-10"
|
|
554
559
|
/>
|
|
555
560
|
</div>
|
|
556
561
|
{/if}
|
|
@@ -559,7 +564,7 @@
|
|
|
559
564
|
<Popover.Root>
|
|
560
565
|
<Popover.Trigger>
|
|
561
566
|
{#snippet child({ props })}
|
|
562
|
-
<Button variant="outline" size="sm" class="h-
|
|
567
|
+
<Button variant="outline" size="sm" class="h-10 rounded-xl px-3" {...props}>
|
|
563
568
|
<FilterIcon class="h-4 w-4" data-icon="inline-start" />
|
|
564
569
|
{t('common.filter')}
|
|
565
570
|
{#if activeFilterCount > 0}
|
|
@@ -577,7 +582,7 @@
|
|
|
577
582
|
{#if field.type === 'select' && field.options}
|
|
578
583
|
<select
|
|
579
584
|
id="filter-{field.key}"
|
|
580
|
-
class="h-
|
|
585
|
+
class="flex h-10 w-full items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm font-normal ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
581
586
|
value={filterValues[field.key] ?? ''}
|
|
582
587
|
onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
|
|
583
588
|
>
|
|
@@ -593,7 +598,7 @@
|
|
|
593
598
|
value={filterValues[field.key] ?? ''}
|
|
594
599
|
oninput={(e) => filterValues[field.key] = (e.currentTarget as HTMLInputElement).value}
|
|
595
600
|
placeholder={field.label}
|
|
596
|
-
class="h-
|
|
601
|
+
class="h-10 rounded-xl text-sm"
|
|
597
602
|
/>
|
|
598
603
|
{/if}
|
|
599
604
|
</div>
|
|
@@ -616,7 +621,7 @@
|
|
|
616
621
|
</div>
|
|
617
622
|
|
|
618
623
|
<!-- Table (TanStack-powered) -->
|
|
619
|
-
<div class="rounded-
|
|
624
|
+
<div class="overflow-hidden rounded-3xl border border-border/70 bg-card/95 shadow-sm shadow-slate-900/[0.03] backdrop-blur" data-svadmin-table-card role="region" aria-label="{resource.label} {t('common.list')}">
|
|
620
625
|
{#if query.isLoading}
|
|
621
626
|
<div class="p-4 space-y-3">
|
|
622
627
|
<div class="flex gap-4 mb-2">
|
|
@@ -640,8 +645,8 @@
|
|
|
640
645
|
<div in:fade={{ duration: 150 }}>
|
|
641
646
|
<!-- Desktop Table (hidden on mobile) -->
|
|
642
647
|
<div class="hidden md:block">
|
|
643
|
-
<Table.Root>
|
|
644
|
-
<Table.Header>
|
|
648
|
+
<Table.Root class="svadmin-data-table" data-svadmin-datatable>
|
|
649
|
+
<Table.Header data-svadmin-table-head>
|
|
645
650
|
{#each table_getHeaderGroups(tbl) as headerGroup, _i (_i)}
|
|
646
651
|
<DraggableHeader
|
|
647
652
|
columns={headerGroup.headers.map((h: any) => ({ id: h.column.id, header: h }))}
|
|
@@ -652,7 +657,7 @@
|
|
|
652
657
|
{@const header = col.header as typeof headerGroup.headers[0]}
|
|
653
658
|
<Table.Head
|
|
654
659
|
{...dragProps}
|
|
655
|
-
class={cn("
|
|
660
|
+
class={cn("border-b border-border/50 bg-muted/35 px-4 py-4 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/75 hover:bg-muted/45", dragProps.class)}
|
|
656
661
|
style={header_getSize(header) != null && header_getSize(header) !== 150 ? `width:${header_getSize(header)}px` : undefined}
|
|
657
662
|
>
|
|
658
663
|
{#if header.id === '_select'}
|
|
@@ -694,9 +699,9 @@
|
|
|
694
699
|
<ContextMenu.Root>
|
|
695
700
|
<ContextMenu.Trigger>
|
|
696
701
|
{#snippet child({ props })}
|
|
697
|
-
<Table.Row {...props} class="
|
|
702
|
+
<Table.Row {...props} data-svadmin-table-row class="border-b border-border/30 transition-colors duration-200 {row_getIsSelected(row) ? 'bg-primary/5' : 'hover:bg-muted/25'}">
|
|
698
703
|
{#each row_getVisibleCells(row) as cell, _i (_i)}
|
|
699
|
-
<Table.Cell>
|
|
704
|
+
<Table.Cell class="px-4 py-3.5">
|
|
700
705
|
{#if cell.column.id === '_select'}
|
|
701
706
|
<Checkbox
|
|
702
707
|
checked={row_getIsSelected(row)}
|
|
@@ -803,7 +808,7 @@
|
|
|
803
808
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
804
809
|
</svg>
|
|
805
810
|
<p class="text-sm font-medium text-muted-foreground mb-1">{t('common.noData')}</p>
|
|
806
|
-
<p class="text-xs text-muted-foreground/60 mb-4">{t('common.noDataHint')
|
|
811
|
+
<p class="text-xs text-muted-foreground/60 mb-4">{t('common.noDataHint')}</p>
|
|
807
812
|
{#if canCreate}
|
|
808
813
|
<Button variant="outline" size="sm" class="gap-2" onclick={() => navigate(`/${resourceName}/create`)}>
|
|
809
814
|
<Plus class="h-3.5 w-3.5" />
|
|
@@ -825,7 +830,7 @@
|
|
|
825
830
|
{@const record = row.original}
|
|
826
831
|
{@const id = record[primaryKey] as string | number}
|
|
827
832
|
<div
|
|
828
|
-
class="rounded-
|
|
833
|
+
class="rounded-3xl border border-border/60 bg-card p-5 shadow-sm shadow-slate-900/[0.03] transition-all {row_getIsSelected(row) ? 'border-primary/35 bg-primary/5 ring-2 ring-primary/20' : ''}"
|
|
829
834
|
>
|
|
830
835
|
<!-- Card header: ID + select + actions -->
|
|
831
836
|
<div class="flex items-center justify-between mb-3">
|
|
@@ -906,7 +911,7 @@
|
|
|
906
911
|
|
|
907
912
|
<!-- Pagination (shadcn) -->
|
|
908
913
|
{#if totalPages > 0}
|
|
909
|
-
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
|
|
914
|
+
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground" data-svadmin-pagination>
|
|
910
915
|
<div class="flex items-center gap-2">
|
|
911
916
|
<span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
912
917
|
<select
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import { AlertTriangle, ArrowLeft, FileQuestion, Home } from '@lucide/svelte';
|
|
5
|
+
import { Button } from './ui/button/index.js';
|
|
6
|
+
import * as Card from './ui/card/index.js';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
status?: '404' | '500';
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { status = '404', title, description }: Props = $props();
|
|
15
|
+
|
|
16
|
+
function goBack() {
|
|
17
|
+
if (typeof window !== 'undefined') {
|
|
18
|
+
window.history.back();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isNotFound = $derived(status === '404');
|
|
23
|
+
const resolvedTitle = $derived(title ?? (isNotFound ? t('common.pageNotFound') : t('common.error')));
|
|
24
|
+
const resolvedDescription = $derived(
|
|
25
|
+
description ?? (isNotFound ? t('error.pageNotFoundDescription') : t('error.internalServerErrorDescription')),
|
|
26
|
+
);
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<div class="flex min-h-[70vh] items-center justify-center p-4">
|
|
30
|
+
<Card.Card class="w-full max-w-md border-border/60">
|
|
31
|
+
<Card.CardContent class="space-y-6 px-6 py-10 text-center">
|
|
32
|
+
<div class="mx-auto inline-flex h-16 w-16 items-center justify-center rounded-lg bg-muted/50 text-muted-foreground">
|
|
33
|
+
{#if isNotFound}
|
|
34
|
+
<FileQuestion class="h-8 w-8" />
|
|
35
|
+
{:else}
|
|
36
|
+
<AlertTriangle class="h-8 w-8 text-destructive" />
|
|
37
|
+
{/if}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="space-y-2">
|
|
41
|
+
<p class="text-xs font-semibold uppercase text-muted-foreground">{status}</p>
|
|
42
|
+
<h1 class="text-2xl font-semibold text-foreground">{resolvedTitle}</h1>
|
|
43
|
+
<p class="mx-auto max-w-sm text-sm leading-relaxed text-muted-foreground">{resolvedDescription}</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="flex flex-col justify-center gap-2 sm:flex-row">
|
|
47
|
+
<Button variant="outline" onclick={goBack}>
|
|
48
|
+
<ArrowLeft class="h-4 w-4" />
|
|
49
|
+
{t('common.back')}
|
|
50
|
+
</Button>
|
|
51
|
+
<Button onclick={() => navigate('/')}>
|
|
52
|
+
<Home class="h-4 w-4" />
|
|
53
|
+
{t('common.returnHome')}
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
</Card.CardContent>
|
|
57
|
+
</Card.Card>
|
|
58
|
+
</div>
|
|
@@ -85,8 +85,8 @@
|
|
|
85
85
|
}
|
|
86
86
|
</script>
|
|
87
87
|
|
|
88
|
-
<div class="space-y-1.5">
|
|
89
|
-
<Label for={field.key} id="label-{field.key}">
|
|
88
|
+
<div class="space-y-1.5" data-svadmin-field>
|
|
89
|
+
<Label for={field.key} id="label-{field.key}" data-svadmin-field-label>
|
|
90
90
|
{field.label}
|
|
91
91
|
{#if field.required}
|
|
92
92
|
<span class="text-destructive">*</span>
|