@svadmin/ui 0.34.10 → 0.36.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.
Files changed (71) hide show
  1. package/dist/app.css +746 -22
  2. package/dist/component-registry.svelte.d.ts +1 -0
  3. package/dist/components/AdminApp.svelte +94 -15
  4. package/dist/components/AdminApp.svelte.d.ts +32 -2
  5. package/dist/components/ApiSettings.svelte +191 -0
  6. package/dist/components/ApiSettings.svelte.d.ts +3 -0
  7. package/dist/components/AutoForm.svelte +47 -42
  8. package/dist/components/AutoTable.svelte +29 -24
  9. package/dist/components/ErrorPage.svelte +58 -0
  10. package/dist/components/ErrorPage.svelte.d.ts +8 -0
  11. package/dist/components/FieldRenderer.svelte +2 -2
  12. package/dist/components/ForgotPasswordPage.svelte +111 -68
  13. package/dist/components/Header.svelte +6 -7
  14. package/dist/components/IntegrationsSettings.svelte +70 -0
  15. package/dist/components/IntegrationsSettings.svelte.d.ts +4 -0
  16. package/dist/components/Layout.svelte +7 -7
  17. package/dist/components/LoginPage.svelte +207 -105
  18. package/dist/components/LoginPage.svelte.d.ts +3 -15
  19. package/dist/components/NotificationsSettings.svelte +115 -0
  20. package/dist/components/NotificationsSettings.svelte.d.ts +3 -0
  21. package/dist/components/PageHeader.svelte +9 -7
  22. package/dist/components/RegisterPage.svelte +103 -65
  23. package/dist/components/ResourceOperationsPage.svelte +151 -0
  24. package/dist/components/ResourceOperationsPage.svelte.d.ts +37 -0
  25. package/dist/components/SecuritySettings.svelte +179 -0
  26. package/dist/components/SecuritySettings.svelte.d.ts +4 -0
  27. package/dist/components/SettingsPage.svelte +120 -87
  28. package/dist/components/SettingsPage.svelte.d.ts +25 -1
  29. package/dist/components/ShowPage.svelte +9 -9
  30. package/dist/components/Sidebar.svelte +278 -189
  31. package/dist/components/SidebarItem.svelte +77 -18
  32. package/dist/components/TwoFactorAuthPage.svelte +247 -0
  33. package/dist/components/TwoFactorAuthPage.svelte.d.ts +3 -0
  34. package/dist/components/UpdatePasswordPage.svelte +86 -48
  35. package/dist/components/account/CompanyProfilePage.svelte +99 -0
  36. package/dist/components/account/CompanyProfilePage.svelte.d.ts +3 -0
  37. package/dist/components/account/GetStartedPage.svelte +123 -0
  38. package/dist/components/account/GetStartedPage.svelte.d.ts +4 -0
  39. package/dist/components/account/ImportMembersPage.svelte +143 -0
  40. package/dist/components/account/ImportMembersPage.svelte.d.ts +3 -0
  41. package/dist/components/account/MembersStarterPage.svelte +104 -0
  42. package/dist/components/account/MembersStarterPage.svelte.d.ts +3 -0
  43. package/dist/components/account/SecurityLogPage.svelte +135 -0
  44. package/dist/components/account/SecurityLogPage.svelte.d.ts +3 -0
  45. package/dist/components/account/SettingsEnterprisePage.svelte +131 -0
  46. package/dist/components/account/SettingsEnterprisePage.svelte.d.ts +3 -0
  47. package/dist/components/account/SettingsPlainPage.svelte +81 -0
  48. package/dist/components/account/SettingsPlainPage.svelte.d.ts +3 -0
  49. package/dist/components/account/SettingsSidebarPage.svelte +98 -0
  50. package/dist/components/account/SettingsSidebarPage.svelte.d.ts +3 -0
  51. package/dist/components/account/TeamMembersPage.svelte +98 -0
  52. package/dist/components/account/TeamMembersPage.svelte.d.ts +3 -0
  53. package/dist/components/network/TeamCrewTablePage.svelte +155 -0
  54. package/dist/components/network/TeamCrewTablePage.svelte.d.ts +3 -0
  55. package/dist/components/network/UserCardsNFTPage.svelte +134 -0
  56. package/dist/components/network/UserCardsNFTPage.svelte.d.ts +3 -0
  57. package/dist/components/profile/ActivityTimeline.svelte +77 -0
  58. package/dist/components/profile/ActivityTimeline.svelte.d.ts +16 -0
  59. package/dist/components/profile/ProfileCard.svelte +216 -0
  60. package/dist/components/profile/ProfileCard.svelte.d.ts +27 -0
  61. package/dist/components/profile/ProjectsGrid.svelte +91 -0
  62. package/dist/components/profile/ProjectsGrid.svelte.d.ts +17 -0
  63. package/dist/components/profile/PublicProfilePage.svelte +179 -0
  64. package/dist/components/profile/PublicProfilePage.svelte.d.ts +10 -0
  65. package/dist/components/profile/TeamsShowcase.svelte +98 -0
  66. package/dist/components/profile/TeamsShowcase.svelte.d.ts +19 -0
  67. package/dist/components/ui/pagination/pagination-ellipsis.svelte +1 -1
  68. package/dist/index.d.ts +23 -0
  69. package/dist/index.js +27 -0
  70. package/dist/router-state.svelte.js +17 -0
  71. package/package.json +2 -2
@@ -13,6 +13,7 @@ export interface ComponentRegistry {
13
13
  AutoTable: Component<any>;
14
14
  AutoForm: Component<any>;
15
15
  ShowPage: Component<any>;
16
+ ErrorPage?: Component<any>;
16
17
  Button: Component<any>;
17
18
  Input: Component<any>;
18
19
  Badge: Component<any>;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  // Auto-inject design tokens so consumers don't need to manually import
3
3
  import '../app.css';
4
- import type { Snippet } from 'svelte';
4
+ import type { Component, Snippet } from 'svelte';
5
5
  import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider, ThemeConfig, MenuItem, TaskProvider } from '@svadmin/core';
6
6
  import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, setTaskProvider, createHashRouterProvider, configureTheme } from '@svadmin/core';
7
7
  import { t } from '@svadmin/core/i18n';
@@ -23,6 +23,20 @@
23
23
  import Header from './Header.svelte';
24
24
  import TaskQueueDrawer from './TaskQueueDrawer.svelte';
25
25
  import SettingsPage from './SettingsPage.svelte';
26
+ import ErrorPage from './ErrorPage.svelte';
27
+ import PublicProfilePage from './profile/PublicProfilePage.svelte';
28
+ import GetStartedPage from './account/GetStartedPage.svelte';
29
+ import CompanyProfilePage from './account/CompanyProfilePage.svelte';
30
+ import SettingsPlainPage from './account/SettingsPlainPage.svelte';
31
+ import SettingsSidebarPage from './account/SettingsSidebarPage.svelte';
32
+ import SettingsEnterprisePage from './account/SettingsEnterprisePage.svelte';
33
+ import ImportMembersPage from './account/ImportMembersPage.svelte';
34
+ import MembersStarterPage from './account/MembersStarterPage.svelte';
35
+ import TeamMembersPage from './account/TeamMembersPage.svelte';
36
+ import SecurityLogPage from './account/SecurityLogPage.svelte';
37
+ import UserCardsNFTPage from './network/UserCardsNFTPage.svelte';
38
+ import TeamCrewTablePage from './network/TeamCrewTablePage.svelte';
39
+ import TwoFactorAuthPage from './TwoFactorAuthPage.svelte';
26
40
  import { Button } from './ui/button/index.js';
27
41
  import { Input } from './ui/input/index.js';
28
42
  import { Badge } from './ui/badge/index.js';
@@ -42,8 +56,22 @@
42
56
  themeConfig?: ThemeConfig;
43
57
  dashboard?: Snippet;
44
58
  loginPage?: Snippet;
59
+ /** Prefill and display credentials on the default login page, useful for demos/examples. */
60
+ loginDefaults?: {
61
+ identifier?: string;
62
+ password?: string;
63
+ hint?: string;
64
+ };
45
65
  /** Override default components via DI */
46
66
  components?: Partial<ComponentRegistry>;
67
+ /** Resource-specific page overrides while keeping svadmin defaults as fallback. */
68
+ resourcePages?: Record<string, {
69
+ list?: Component<{ resourceName: string }>;
70
+ create?: Component<{ resourceName: string; mode?: 'create' }>;
71
+ edit?: Component<{ resourceName: string; mode?: 'edit'; id?: string | number }>;
72
+ show?: Component<{ resourceName: string; id?: string | number }>;
73
+ clone?: Component<{ resourceName: string; mode?: 'clone'; id?: string | number }>;
74
+ }>;
47
75
  /** Custom multi-level menu configuration */
48
76
  menu?: MenuItem[];
49
77
  /** External URL to the main application or workspace (renders a shortcut in the header) */
@@ -68,7 +96,9 @@
68
96
  themeConfig: userThemeConfig,
69
97
  dashboard,
70
98
  loginPage,
99
+ loginDefaults,
71
100
  components: userComponents,
101
+ resourcePages,
72
102
  menu,
73
103
  siteUrl,
74
104
  routeMode,
@@ -80,7 +110,7 @@
80
110
  const defaultComponents: ComponentRegistry = {
81
111
  Layout, Sidebar, Header,
82
112
  LoginPage, AutoTable, AutoForm, ShowPage,
83
- TaskQueueDrawer,
113
+ TaskQueueDrawer, ErrorPage,
84
114
  Button: Button as unknown as ComponentRegistry['Button'],
85
115
  Input: Input as unknown as ComponentRegistry['Input'],
86
116
  Badge: Badge as unknown as ComponentRegistry['Badge'],
@@ -134,6 +164,9 @@
134
164
  // Reactive getters for route state
135
165
  const route = $derived(getRoute());
136
166
  const params = $derived(getParams());
167
+ const resourceNames = $derived(new Set(resources.map((resource) => resource.name)));
168
+ const hasRouteResource = $derived(!params.resource || resourceNames.has(params.resource));
169
+ const currentResourcePages = $derived(params.resource ? resourcePages?.[params.resource] : undefined);
137
170
 
138
171
  // Auth check
139
172
  let isAuthenticated = $state(false);
@@ -183,7 +216,13 @@
183
216
  {:else if route === '/login' && loginPage}
184
217
  {@render loginPage()}
185
218
  {:else if route === '/login' && authProvider}
186
- <LoginPage {title} onSuccess={() => { isAuthenticated = true; navigate('/'); }} />
219
+ <LoginPage
220
+ {title}
221
+ defaultIdentifier={loginDefaults?.identifier}
222
+ defaultPassword={loginDefaults?.password}
223
+ loginHint={loginDefaults?.hint}
224
+ onSuccess={() => { isAuthenticated = true; navigate('/'); }}
225
+ />
187
226
  {:else if route === '/register' && authProvider?.register}
188
227
  <RegisterPage {title} />
189
228
  {:else if route === '/forgot-password' && authProvider?.forgotPassword}
@@ -194,10 +233,47 @@
194
233
  <ConfigErrorScreen title="{title} — {t('common.configRequired')}" />
195
234
  {:else if isAuthenticated || !authProvider}
196
235
  <Layout {title} {menu} {siteUrl} routeMode={resolvedRouteMode}>
197
- {#key route + (params.resource ?? '') + (params.id ?? '')}
236
+ {#key route + (params.resource ?? '') + (params.id ?? '') + (params.variant ?? '') + (params.columns ?? '')}
198
237
  <div class="svadmin-page-enter">
199
- {#if route.startsWith('/settings')}
238
+ {#if route === '/2fa'}
239
+ <TwoFactorAuthPage />
240
+ {:else if route === '/public-profile'}
241
+ <PublicProfilePage variant="default" initialTab="projects" />
242
+ {:else if route === '/public-profile/projects/:columns'}
243
+ <PublicProfilePage variant="default" initialTab="projects" columns={params.columns === '3' ? 3 : 2} />
244
+ {:else if route === '/public-profile/activity'}
245
+ <PublicProfilePage variant="default" initialTab="activity" />
246
+ {:else if route === '/public-profile/teams'}
247
+ <PublicProfilePage variant="default" initialTab="teams" />
248
+ {:else if route === '/public-profile/profiles/:variant'}
249
+ <PublicProfilePage variant={(params.variant === 'company' || params.variant === 'gamer' || params.variant === 'default' ? params.variant : 'default') as 'company' | 'gamer' | 'default'} />
250
+ {:else if route === '/account/get-started'}
251
+ <GetStartedPage />
252
+ {:else if route === '/account/company-profile'}
253
+ <CompanyProfilePage />
254
+ {:else if route === '/account/settings-plain'}
255
+ <SettingsPlainPage />
256
+ {:else if route === '/account/settings-sidebar'}
257
+ <SettingsSidebarPage />
258
+ {:else if route === '/account/settings-enterprise'}
259
+ <SettingsEnterprisePage />
260
+ {:else if route === '/account/import-members'}
261
+ <ImportMembersPage />
262
+ {:else if route === '/account/members-starter'}
263
+ <MembersStarterPage />
264
+ {:else if route === '/account/team-members'}
265
+ <TeamMembersPage />
266
+ {:else if route === '/account/security-log'}
267
+ <SecurityLogPage />
268
+ {:else if route === '/network/user-cards'}
269
+ <UserCardsNFTPage />
270
+ {:else if route === '/network/team-crew'}
271
+ <TeamCrewTablePage />
272
+ {:else if route.startsWith('/settings')}
200
273
  <SettingsPage />
274
+ {:else if route === '/500' || (route === '/:resource' && params.resource === '500')}
275
+ {@const ErrorComp = mergedComponents.ErrorPage || ErrorPage}
276
+ <ErrorComp status="500" />
201
277
  {:else if route === '/' || route === ''}
202
278
  {#if dashboard}
203
279
  {@render dashboard()}
@@ -207,31 +283,34 @@
207
283
  <p class="text-muted-foreground">{t('common.dashboardHint')}</p>
208
284
  </div>
209
285
  {/if}
210
- {:else if route === '/:resource' || route === '/:parent/:parentId/:resource'}
286
+ {:else if (route === '/:resource' || route === '/:parent/:parentId/:resource') && hasRouteResource}
211
287
  {#key params.resource}
212
- {@const Comp = mergedComponents.AutoTable}
288
+ {@const Comp = currentResourcePages?.list ?? mergedComponents.AutoTable}
213
289
  <Comp resourceName={params.resource} />
214
290
  {/key}
215
- {:else if route === '/:resource/create' || route === '/:parent/:parentId/:resource/create'}
291
+ {:else if (route === '/:resource/create' || route === '/:parent/:parentId/:resource/create') && hasRouteResource}
216
292
  {#key params.resource}
217
- {@const Comp = mergedComponents.AutoForm}
293
+ {@const Comp = currentResourcePages?.create ?? mergedComponents.AutoForm}
218
294
  <Comp resourceName={params.resource} mode="create" />
219
295
  {/key}
220
- {:else if route === '/:resource/edit/:id' || route === '/:parent/:parentId/:resource/edit/:id'}
296
+ {:else if (route === '/:resource/edit/:id' || route === '/:parent/:parentId/:resource/edit/:id') && hasRouteResource}
221
297
  {#key `${params.resource}-${params.id}`}
222
- {@const Comp = mergedComponents.AutoForm}
298
+ {@const Comp = currentResourcePages?.edit ?? mergedComponents.AutoForm}
223
299
  <Comp resourceName={params.resource} mode="edit" id={params.id} />
224
300
  {/key}
225
- {:else if route === '/:resource/show/:id' || route === '/:parent/:parentId/:resource/show/:id'}
301
+ {:else if (route === '/:resource/show/:id' || route === '/:parent/:parentId/:resource/show/:id') && hasRouteResource}
226
302
  {#key `${params.resource}-${params.id}`}
227
- {@const Comp = mergedComponents.ShowPage}
303
+ {@const Comp = currentResourcePages?.show ?? mergedComponents.ShowPage}
228
304
  <Comp resourceName={params.resource} id={params.id} />
229
305
  {/key}
230
- {:else if route === '/:resource/clone/:id' || route === '/:parent/:parentId/:resource/clone/:id'}
306
+ {:else if (route === '/:resource/clone/:id' || route === '/:parent/:parentId/:resource/clone/:id') && hasRouteResource}
231
307
  {#key `${params.resource}-clone-${params.id}`}
232
- {@const Comp = mergedComponents.AutoForm}
308
+ {@const Comp = currentResourcePages?.clone ?? mergedComponents.AutoForm}
233
309
  <Comp resourceName={params.resource} mode="clone" id={params.id} />
234
310
  {/key}
311
+ {:else}
312
+ {@const ErrorComp = mergedComponents.ErrorPage || ErrorPage}
313
+ <ErrorComp status="404" />
235
314
  {/if}
236
315
  </div>
237
316
  {/key}
@@ -1,5 +1,5 @@
1
1
  import '../app.css';
2
- import type { Snippet } from 'svelte';
2
+ import type { Component, Snippet } from 'svelte';
3
3
  import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider, ThemeConfig, MenuItem, TaskProvider } from '@svadmin/core';
4
4
  import { QueryClient, type DefaultOptions } from '@tanstack/svelte-query';
5
5
  import { type ComponentRegistry } from '../component-registry.svelte.js';
@@ -16,8 +16,38 @@ interface Props {
16
16
  themeConfig?: ThemeConfig;
17
17
  dashboard?: Snippet;
18
18
  loginPage?: Snippet;
19
+ /** Prefill and display credentials on the default login page, useful for demos/examples. */
20
+ loginDefaults?: {
21
+ identifier?: string;
22
+ password?: string;
23
+ hint?: string;
24
+ };
19
25
  /** Override default components via DI */
20
26
  components?: Partial<ComponentRegistry>;
27
+ /** Resource-specific page overrides while keeping svadmin defaults as fallback. */
28
+ resourcePages?: Record<string, {
29
+ list?: Component<{
30
+ resourceName: string;
31
+ }>;
32
+ create?: Component<{
33
+ resourceName: string;
34
+ mode?: 'create';
35
+ }>;
36
+ edit?: Component<{
37
+ resourceName: string;
38
+ mode?: 'edit';
39
+ id?: string | number;
40
+ }>;
41
+ show?: Component<{
42
+ resourceName: string;
43
+ id?: string | number;
44
+ }>;
45
+ clone?: Component<{
46
+ resourceName: string;
47
+ mode?: 'clone';
48
+ id?: string | number;
49
+ }>;
50
+ }>;
21
51
  /** Custom multi-level menu configuration */
22
52
  menu?: MenuItem[];
23
53
  /** External URL to the main application or workspace (renders a shortcut in the header) */
@@ -29,6 +59,6 @@ interface Props {
29
59
  /** Optional TanStack Query default options override when QueryClient is not supplied. */
30
60
  queryClientDefaultOptions?: DefaultOptions;
31
61
  }
32
- declare const AdminApp: import("svelte").Component<Props, {}, "">;
62
+ declare const AdminApp: Component<Props, {}, "">;
33
63
  type AdminApp = ReturnType<typeof AdminApp>;
34
64
  export default AdminApp;
@@ -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>
@@ -0,0 +1,3 @@
1
+ declare const ApiSettings: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ApiSettings = ReturnType<typeof ApiSettings>;
3
+ export default ApiSettings;
@@ -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 order: string[] = [];
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
- if (!map.has(g)) { order.push(g); map.set(g, []); }
53
- (map.get(g) ?? []).push(f);
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 order.map(g => ({ name: g, fields: map.get(g) ?? [] }));
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="flex items-center gap-4">
150
- <TooltipButton
151
- tooltip={t('common.back')}
152
- onclick={() => guardNavigate(() => navigate(`/${resourceName}`))}
153
- >
154
- <ArrowLeft class="h-5 w-5" />
155
- </TooltipButton>
156
- <h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
157
- {#if form.isTainted()}
158
- <Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{t('common.unsaved')}</Badge>
159
- {/if}
138
+ <div class="space-y-6" data-svadmin-form-shell>
139
+ <div class="border-b border-border/60 pb-5" 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"
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-3xl space-y-6">
164
- <div class="rounded-lg shadow-sm ring-1 ring-border/10 p-6 space-y-5">
167
+ <div class="max-w-4xl space-y-6">
168
+ <div class="space-y-5 border-y border-border/60 py-6">
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-3xl space-y-6">
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/40 shadow-sm">
190
+ <Card.Root class="border-border/60 bg-card shadow-none" data-svadmin-form-card>
187
191
  {#if group.name}
188
192
  <Card.Header>
189
- <Card.Title class="text-lg">{group.name}</Card.Title>
193
+ <Card.Title class="text-base font-semibold">{group.name}</Card.Title>
190
194
  </Card.Header>
191
195
  {/if}
192
- <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
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/40 shadow-sm">
215
- <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
218
+ <Card.Root class="border-border/60 bg-card shadow-none" 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 flex-wrap items-center gap-3 border-t border-border/60 bg-background/95 p-3 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 px-5">
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')}