@svadmin/ui 0.38.7 → 0.40.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 (61) hide show
  1. package/dist/app.css +0 -4
  2. package/dist/app.theme.css +0 -4
  3. package/dist/assets/nft/nft-cover-01.jpg +0 -0
  4. package/dist/assets/nft/nft-cover-02.jpg +0 -0
  5. package/dist/assets/nft/nft-cover-03.jpg +0 -0
  6. package/dist/assets/nft/nft-cover-04.jpg +0 -0
  7. package/dist/assets/nft/nft-cover-05.jpg +0 -0
  8. package/dist/assets/nft/nft-cover-06.jpg +0 -0
  9. package/dist/assets/nft/nft-cover-07.jpg +0 -0
  10. package/dist/assets/nft/nft-cover-08.jpg +0 -0
  11. package/dist/assets/nft/nft-cover-09.jpg +0 -0
  12. package/dist/components/AdminApp.svelte +29 -12
  13. package/dist/components/ApiSettings.svelte +86 -1
  14. package/dist/components/AutoTable.svelte +40 -13
  15. package/dist/components/Breadcrumbs.svelte +39 -6
  16. package/dist/components/Breadcrumbs.svelte.d.ts +5 -1
  17. package/dist/components/ChatDialog.svelte +4 -2
  18. package/dist/components/ChatDialog.svelte.d.ts +2 -0
  19. package/dist/components/DevTools.svelte +3 -1
  20. package/dist/components/DevTools.svelte.d.ts +4 -1
  21. package/dist/components/ForgotPasswordPage.svelte +5 -8
  22. package/dist/components/Header.svelte +5 -3
  23. package/dist/components/Header.svelte.d.ts +2 -0
  24. package/dist/components/Layout.svelte +10 -1
  25. package/dist/components/LoginPage.svelte +5 -8
  26. package/dist/components/RegisterPage.svelte +5 -8
  27. package/dist/components/SettingsPage.svelte +14 -2
  28. package/dist/components/Sidebar.svelte +10 -4
  29. package/dist/components/SvadminLogo.svelte +16 -0
  30. package/dist/components/SvadminLogo.svelte.d.ts +7 -0
  31. package/dist/components/SystemPageShell.svelte +34 -0
  32. package/dist/components/SystemPageShell.svelte.d.ts +8 -0
  33. package/dist/components/UpdatePasswordPage.svelte +5 -8
  34. package/dist/components/account/CompanyProfilePage.svelte +56 -1
  35. package/dist/components/account/ImportMembersPage.svelte +31 -0
  36. package/dist/components/account/SettingsEnterprisePage.svelte +172 -19
  37. package/dist/components/account/SettingsPlainPage.svelte +121 -17
  38. package/dist/components/account/SettingsSidebarPage.svelte +282 -70
  39. package/dist/components/account/TeamMembersPage.svelte +95 -4
  40. package/dist/components/account/UserProfilePage.svelte +206 -0
  41. package/dist/components/account/UserProfilePage.svelte.d.ts +4 -0
  42. package/dist/components/network/TeamCrewTablePage.svelte +37 -5
  43. package/dist/components/network/UserCardsNFTPage.svelte +26 -16
  44. package/dist/components/profile/ActivityTimeline.svelte +24 -2
  45. package/dist/components/profile/ActivityTimeline.svelte.d.ts +1 -0
  46. package/dist/components/profile/ProfileVariantSections.svelte +452 -0
  47. package/dist/components/profile/ProfileVariantSections.svelte.d.ts +8 -0
  48. package/dist/components/profile/ProjectsGrid.svelte +38 -8
  49. package/dist/components/profile/ProjectsGrid.svelte.d.ts +1 -0
  50. package/dist/components/profile/PublicProfilePage.svelte +47 -26
  51. package/dist/components/profile/PublicProfilePage.svelte.d.ts +1 -0
  52. package/dist/components/profile/TeamsShowcase.svelte +31 -12
  53. package/dist/components/profile/TeamsShowcase.svelte.d.ts +1 -0
  54. package/dist/components/ui/alert/alert.svelte.d.ts +14 -14
  55. package/dist/components/ui/avatar/avatar.svelte.d.ts +14 -14
  56. package/dist/components/ui/badge/badge.svelte.d.ts +20 -20
  57. package/dist/components/ui/button/button.svelte.d.ts +44 -44
  58. package/dist/index.d.ts +3 -0
  59. package/dist/index.js +3 -0
  60. package/dist/router-state.svelte.js +4 -0
  61. package/package.json +15 -15
package/dist/app.css CHANGED
@@ -1363,10 +1363,6 @@ main {
1363
1363
  padding-right: 1rem !important;
1364
1364
  }
1365
1365
 
1366
- .layout-clean-flat [data-svadmin-content-page] [class*="grid-cols-3"] {
1367
- grid-template-columns: minmax(0, 1fr) !important;
1368
- }
1369
-
1370
1366
  .layout-clean-flat [data-svadmin-content-page="auth-2fa"] [data-slot="card-content"],
1371
1367
  .layout-clean-flat [data-svadmin-content-page="error"] [data-slot="card-content"] {
1372
1368
  padding-left: 1rem !important;
@@ -1362,10 +1362,6 @@ main {
1362
1362
  padding-right: 1rem !important;
1363
1363
  }
1364
1364
 
1365
- .layout-clean-flat [data-svadmin-content-page] [class*="grid-cols-3"] {
1366
- grid-template-columns: minmax(0, 1fr) !important;
1367
- }
1368
-
1369
1365
  .layout-clean-flat [data-svadmin-content-page="auth-2fa"] [data-slot="card-content"],
1370
1366
  .layout-clean-flat [data-svadmin-content-page="error"] [data-slot="card-content"] {
1371
1367
  padding-left: 1rem !important;
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -1,6 +1,7 @@
1
1
  <script module lang="ts">
2
2
  const loadPublicProfilePage = () => import('./profile/PublicProfilePage.svelte');
3
3
  const loadGetStartedPage = () => import('./account/GetStartedPage.svelte');
4
+ const loadUserProfilePage = () => import('./account/UserProfilePage.svelte');
4
5
  const loadCompanyProfilePage = () => import('./account/CompanyProfilePage.svelte');
5
6
  const loadSettingsPlainPage = () => import('./account/SettingsPlainPage.svelte');
6
7
  const loadSettingsSidebarPage = () => import('./account/SettingsSidebarPage.svelte');
@@ -42,12 +43,12 @@
42
43
  import ForgotPasswordPage from './ForgotPasswordPage.svelte';
43
44
  import UpdatePasswordPage from './UpdatePasswordPage.svelte';
44
45
  import ConfigErrorScreen from './ConfigErrorScreen.svelte';
45
- import DevTools from './DevTools.svelte';
46
46
  import Sidebar from './Sidebar.svelte';
47
47
  import Header from './Header.svelte';
48
48
  import TaskQueueDrawer from './TaskQueueDrawer.svelte';
49
49
  import SettingsPage from './SettingsPage.svelte';
50
50
  import ErrorPage from './ErrorPage.svelte';
51
+ import SystemPageShell from './SystemPageShell.svelte';
51
52
  import LazyPage from './LazyPage.svelte';
52
53
  import { Button } from './ui/button/index.js';
53
54
  import { Input } from './ui/input/index.js';
@@ -235,6 +236,12 @@
235
236
  const resourceNames = $derived(new Set(resources.map((resource) => resource.name)));
236
237
  const hasRouteResource = $derived(!params.resource || resourceNames.has(params.resource));
237
238
  const currentResourcePages = $derived(params.resource ? resourcePages?.[params.resource] : undefined);
239
+ const isStandaloneTwoFactor = $derived(route === '/2fa' || route === '/authentication/branded/2fa');
240
+ const standaloneErrorStatus = $derived.by((): '404' | '500' | undefined => {
241
+ if (route === '/authentication/error-404' || route === '/404' || (route === '/:resource' && params.resource === '404')) return '404';
242
+ if (route === '/authentication/error-500' || route === '/500' || (route === '/:resource' && params.resource === '500')) return '500';
243
+ return undefined;
244
+ });
238
245
 
239
246
  // Auth check
240
247
  let isAuthenticated = $state(untrack(() => !authProvider));
@@ -244,6 +251,13 @@
244
251
  await adminContext.navigate(path);
245
252
  }
246
253
 
254
+ function isPublicSystemRoute(currentRoute: string): boolean {
255
+ return currentRoute === '/authentication/error-404'
256
+ || currentRoute === '/authentication/error-500'
257
+ || currentRoute === '/404'
258
+ || currentRoute === '/500';
259
+ }
260
+
247
261
  $effect(() => {
248
262
  let cancelled = false;
249
263
  // Explicitly track route so auth is re-checked on navigation
@@ -261,7 +275,7 @@
261
275
  if (cancelled) return;
262
276
  isAuthenticated = result.authenticated;
263
277
  authChecked = true;
264
- if (!result.authenticated && _route !== '/login' && _route !== '/register' && _route !== '/forgot-password' && _route !== '/update-password') {
278
+ if (!result.authenticated && _route !== '/login' && _route !== '/register' && _route !== '/forgot-password' && _route !== '/update-password' && !isPublicSystemRoute(_route)) {
265
279
  void navigateWithinApp(result.redirectTo ?? '/login');
266
280
  }
267
281
  }).catch(err => {
@@ -269,7 +283,7 @@
269
283
  console.warn('Auth check failed:', err);
270
284
  isAuthenticated = false;
271
285
  authChecked = true;
272
- if (_route !== '/login' && _route !== '/register' && _route !== '/forgot-password' && _route !== '/update-password') {
286
+ if (_route !== '/login' && _route !== '/register' && _route !== '/forgot-password' && _route !== '/update-password' && !isPublicSystemRoute(_route)) {
273
287
  void navigateWithinApp('/login');
274
288
  }
275
289
  });
@@ -304,13 +318,20 @@
304
318
  <UpdatePasswordPage {title} />
305
319
  {:else if route === '/login' || route === '/register' || route === '/forgot-password' || route === '/update-password'}
306
320
  <ConfigErrorScreen title="{title} — {translation.t('common.configRequired')}" />
321
+ {:else if (isAuthenticated || !authProvider) && isStandaloneTwoFactor}
322
+ <SystemPageShell {title}>
323
+ <LazyPage loader={loadTwoFactorAuthPage} props={{}} />
324
+ </SystemPageShell>
325
+ {:else if standaloneErrorStatus}
326
+ <SystemPageShell {title}>
327
+ {@const ErrorComp = mergedComponents.ErrorPage || ErrorPage}
328
+ <ErrorComp status={standaloneErrorStatus} />
329
+ </SystemPageShell>
307
330
  {:else if isAuthenticated || !authProvider}
308
331
  <Layout {title} {menu} {siteUrl} routeMode={resolvedRouteMode}>
309
332
  {#key route + (params.resource ?? '') + (params.id ?? '') + (params.variant ?? '') + (params.columns ?? '')}
310
333
  <div class="svadmin-page-enter">
311
- {#if route === '/2fa' || route === '/authentication/branded/2fa'}
312
- <LazyPage loader={loadTwoFactorAuthPage} props={{}} />
313
- {:else if route === '/public-profile'}
334
+ {#if route === '/public-profile'}
314
335
  <LazyPage loader={loadPublicProfilePage} props={{ variant: 'default', initialTab: 'projects' }} />
315
336
  {:else if route === '/public-profile/projects/:columns'}
316
337
  <LazyPage loader={loadPublicProfilePage} props={{ variant: 'default', initialTab: 'projects', columns: params.columns?.includes('3') ? 3 : 2 }} />
@@ -319,11 +340,11 @@
319
340
  {:else if route === '/public-profile/teams'}
320
341
  <LazyPage loader={loadPublicProfilePage} props={{ variant: 'default', initialTab: 'teams' }} />
321
342
  {:else if route === '/public-profile/profiles/:variant'}
322
- <LazyPage loader={loadPublicProfilePage} props={{ variant: (params.variant === 'company' || params.variant === 'gamer' || params.variant === 'default' ? params.variant : 'default') as 'company' | 'gamer' | 'default' }} />
343
+ <LazyPage loader={loadPublicProfilePage} props={{ variant: (params.variant === 'company' || params.variant === 'gamer' || params.variant === 'default' ? params.variant : 'default') as 'company' | 'gamer' | 'default', showSections: true }} />
323
344
  {:else if route === '/account/get-started' || route === '/account/home/get-started'}
324
345
  <LazyPage loader={loadGetStartedPage} props={{}} />
325
346
  {:else if route === '/account/home/user-profile'}
326
- <SettingsPage />
347
+ <LazyPage loader={loadUserProfilePage} props={{}} />
327
348
  {:else if route === '/account/company-profile' || route === '/account/home/company-profile'}
328
349
  <LazyPage loader={loadCompanyProfilePage} props={{}} />
329
350
  {:else if route === '/account/settings-plain' || route === '/account/home/settings-plain'}
@@ -348,9 +369,6 @@
348
369
  <LazyPage loader={loadTeamCrewTablePage} props={{}} />
349
370
  {:else if route.startsWith('/settings')}
350
371
  <SettingsPage />
351
- {:else if route === '/500' || (route === '/:resource' && params.resource === '500')}
352
- {@const ErrorComp = mergedComponents.ErrorPage || ErrorPage}
353
- <ErrorComp status="500" />
354
372
  {:else if route === '/' || route === '' || route === '/inventory-dashboard'}
355
373
  {#if dashboard}
356
374
  {@render dashboard()}
@@ -398,5 +416,4 @@
398
416
  </div>
399
417
  {/if}
400
418
  <Toast />
401
- <DevTools />
402
419
  </QueryClientProvider>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { useTranslation } from '@svadmin/core/i18n';
3
3
 
4
- import { AlertCircle, Calendar, Check, Copy, Key, Plus, ShieldCheck, Trash2 } from '@lucide/svelte';
4
+ import { AlertCircle, Calendar, Check, Copy, Key, Plus, ShieldCheck, Trash2, Webhook } from '@lucide/svelte';
5
5
  import * as Card from './ui/card/index.js';
6
6
  import { Badge } from './ui/badge/index.js';
7
7
  import { Button } from './ui/button/index.js';
@@ -30,6 +30,35 @@
30
30
  let showCreatedToken = $state(false);
31
31
  let copiedKeyId = $state<string | null>(null);
32
32
 
33
+ interface WebhookEntry {
34
+ id: string;
35
+ name: string;
36
+ url: string;
37
+ eventType: string;
38
+ }
39
+
40
+ let webhooks = $state<WebhookEntry[]>([
41
+ { id: 'wh-1', name: 'Order Events', url: 'https://api.acme.com/hooks/orders', eventType: 'resource.updated' },
42
+ ]);
43
+ let newWebhookName = $state('');
44
+ let newWebhookUrl = $state('');
45
+ let newWebhookEvent = $state('resource.created');
46
+
47
+ function handleAddWebhook(event: SubmitEvent) {
48
+ event.preventDefault();
49
+ if (!newWebhookName.trim() || !newWebhookUrl.trim()) return;
50
+ webhooks = [
51
+ ...webhooks,
52
+ { id: Date.now().toString(), name: newWebhookName.trim(), url: newWebhookUrl.trim(), eventType: newWebhookEvent },
53
+ ];
54
+ newWebhookName = '';
55
+ newWebhookUrl = '';
56
+ }
57
+
58
+ function deleteWebhook(id: string) {
59
+ webhooks = webhooks.filter(w => w.id !== id);
60
+ }
61
+
33
62
  function generateDemoToken() {
34
63
  const randomHex = Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
35
64
  return `sv_demo_${randomHex}`;
@@ -190,5 +219,61 @@
190
219
  </div>
191
220
  </Card.CardContent>
192
221
  </Card.Card>
222
+
223
+ <!-- Webhooks -->
224
+ <Card.Card>
225
+ <Card.CardHeader>
226
+ <Card.CardTitle class="flex items-center gap-2">
227
+ <Webhook class="h-4 w-4 text-muted-foreground" />
228
+ {i18n.t('api.webhooks')}
229
+ </Card.CardTitle>
230
+ <Card.CardDescription>{i18n.t('api.webhooksDesc')}</Card.CardDescription>
231
+ </Card.CardHeader>
232
+ <Card.CardContent class="space-y-4">
233
+ <form class="grid gap-3 sm:grid-cols-[1fr_1fr_180px_auto]" onsubmit={handleAddWebhook}>
234
+ <div class="space-y-1.5">
235
+ <Label for="webhook-name">{i18n.t('api.webhookName')}</Label>
236
+ <Input id="webhook-name" bind:value={newWebhookName} placeholder="Order Events" />
237
+ </div>
238
+ <div class="space-y-1.5">
239
+ <Label for="webhook-url">{i18n.t('api.webhookUrl')}</Label>
240
+ <Input id="webhook-url" type="url" bind:value={newWebhookUrl} placeholder="https://api.example.com/hooks" />
241
+ </div>
242
+ <div class="space-y-1.5">
243
+ <Label for="webhook-event">{i18n.t('api.eventType')}</Label>
244
+ <select id="webhook-event" bind:value={newWebhookEvent} class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
245
+ <option value="resource.created">resource.created</option>
246
+ <option value="resource.updated">resource.updated</option>
247
+ <option value="resource.deleted">resource.deleted</option>
248
+ <option value="auth.login">auth.login</option>
249
+ </select>
250
+ </div>
251
+ <div class="flex items-end">
252
+ <Button type="submit" size="sm" disabled={!newWebhookName.trim() || !newWebhookUrl.trim()}>
253
+ <Plus class="h-3.5 w-3.5 mr-1" />{i18n.t('api.addWebhook')}
254
+ </Button>
255
+ </div>
256
+ </form>
257
+
258
+ <div class="divide-y border-t">
259
+ {#each webhooks as hook (hook.id)}
260
+ <div class="flex items-center justify-between gap-4 py-3">
261
+ <div class="min-w-0">
262
+ <p class="truncate text-sm font-medium text-foreground">{hook.name}</p>
263
+ <p class="truncate font-mono text-xs text-muted-foreground">{hook.url}</p>
264
+ </div>
265
+ <div class="flex shrink-0 items-center gap-2">
266
+ <Badge variant="secondary" class="px-2 py-0 text-[10px]">{hook.eventType}</Badge>
267
+ <Button variant="ghost" size="icon-sm" onclick={() => deleteWebhook(hook.id)} title={i18n.t('common.delete')}>
268
+ <Trash2 class="h-4 w-4" />
269
+ </Button>
270
+ </div>
271
+ </div>
272
+ {:else}
273
+ <p class="py-4 text-center text-sm text-muted-foreground">{i18n.t('api.noWebhooks')}</p>
274
+ {/each}
275
+ </div>
276
+ </Card.CardContent>
277
+ </Card.Card>
193
278
  </div>
194
279
  </div>
@@ -3,7 +3,13 @@
3
3
  import { cn } from '../utils.js';
4
4
  import {
5
5
  createTable,
6
- createCoreRowModel,
6
+ tableFeatures,
7
+ columnOrderingFeature,
8
+ columnSizingFeature,
9
+ columnVisibilityFeature,
10
+ rowExpandingFeature,
11
+ rowSelectionFeature,
12
+ rowSortingFeature,
7
13
  type ColumnDef,
8
14
  type SortingState,
9
15
  type RowSelectionState,
@@ -25,7 +31,6 @@
25
31
  row_getIsSelected,
26
32
  row_toggleSelected,
27
33
  row_getVisibleCells,
28
- row_toggleExpanded,
29
34
  cell_getValue,
30
35
  } from '@tanstack/table-core/static-functions';
31
36
 
@@ -289,6 +294,14 @@
289
294
  const rowSelectionAtom = createAtom({} as RowSelectionState);
290
295
  const expandedAtom = createAtom({} as ExpandedState);
291
296
  const columnOrderAtom = createAtom([] as string[]);
297
+ const features = tableFeatures({
298
+ columnOrderingFeature,
299
+ columnSizingFeature,
300
+ columnVisibilityFeature,
301
+ rowExpandingFeature,
302
+ rowSelectionFeature,
303
+ rowSortingFeature,
304
+ });
292
305
 
293
306
  const tableSorting = useSelector(sortingAtom);
294
307
  const tableColumnVisibility = useSelector(columnVisibilityAtom);
@@ -388,12 +401,11 @@
388
401
  });
389
402
 
390
403
  // ─── Create TanStack Table ────────────────────────────────────
391
- const tbl = createTable(
404
+ const tbl = createTable<TableFeatures, BaseRecord>(
392
405
  {
406
+ features,
393
407
  get data() { return query.data?.data ?? []; },
394
408
  get columns() { return orderedColumns; },
395
- getCoreRowModel: createCoreRowModel(),
396
- manualPagination: true,
397
409
  manualSorting: true,
398
410
  getRowId: (row: BaseRecord) => String(row[primaryKey]),
399
411
  atoms: {
@@ -402,16 +414,11 @@
402
414
  rowSelection: rowSelectionAtom,
403
415
  expanded: expandedAtom,
404
416
  },
405
- onSortingChange: sortingAtom.set,
406
- onColumnVisibilityChange: columnVisibilityAtom.set,
407
- onRowSelectionChange: rowSelectionAtom.set,
408
- onExpandedChange: expandedAtom.set,
409
- get enableRowSelection() { return selectable && (canDelete || batchActions); },
417
+ autoResetExpanded: false,
418
+ get enableRowSelection() { return selectable && (canDelete || !!batchActions); },
410
419
  get enableExpanding() { return !!expandedRowRender; },
411
420
  getRowCanExpand: () => !!expandedRowRender,
412
421
  },
413
- // AutoTable reads focused atoms directly; selecting the full state triggers Svelte proxy equality warnings.
414
- () => undefined,
415
422
  );
416
423
 
417
424
  $effect.pre(() => {
@@ -433,6 +440,26 @@
433
440
  rows: table_getRowModel(tbl).rows,
434
441
  };
435
442
  });
443
+
444
+ function toggleRowExpanded(rowId: string): void {
445
+ const current = tableExpanded.current;
446
+ if (current === true) {
447
+ expandedAtom.set(Object.fromEntries(
448
+ table_getRowModel(tbl).flatRows
449
+ .filter((row) => row.id !== rowId)
450
+ .map((row) => [row.id, true])
451
+ ));
452
+ return;
453
+ }
454
+ if (current[rowId]) {
455
+ expandedAtom.set(Object.fromEntries(
456
+ Object.entries(current).filter(([id, isExpanded]) => id !== rowId && isExpanded)
457
+ ));
458
+ return;
459
+ }
460
+ expandedAtom.set({ ...current, [rowId]: true });
461
+ }
462
+
436
463
  const totalPages = $derived(Math.ceil((query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
437
464
 
438
465
  // ─── Pagination helpers ───────────────────────────────────────
@@ -729,7 +756,7 @@
729
756
  onCheckedChange={() => row_toggleSelected(row)}
730
757
  />
731
758
  {:else if cell.column.id === '_expand'}
732
- <TooltipButton tooltip={rowIsExpanded(row.id) ? i18n.t('common.collapse') : i18n.t('common.expand')} variant="ghost" size="icon" class="h-7 w-7" onclick={() => row_toggleExpanded(row)}>
759
+ <TooltipButton tooltip={rowIsExpanded(row.id) ? i18n.t('common.collapse') : i18n.t('common.expand')} variant="ghost" size="icon" class="h-7 w-7" onclick={() => toggleRowExpanded(row.id)}>
733
760
  {#if rowIsExpanded(row.id)}
734
761
  <ChevronUp class="h-4 w-4" />
735
762
  {:else}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, getResources } from '@svadmin/core';
2
+ import { captureAdminContext, getResources, type MenuItem } from '@svadmin/core';
3
3
  import { getPath } from '../router-state.svelte.js';
4
4
  import { useTranslation } from '@svadmin/core/i18n';
5
5
 
@@ -10,12 +10,43 @@
10
10
  const adminContext = captureAdminContext();
11
11
  const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
12
12
 
13
- interface Crumb { label: string; href: string; }
13
+ let { menu }: { menu?: MenuItem[] } = $props();
14
+
15
+ interface Crumb { label: string; href?: string; }
16
+
17
+ function normalizePath(path: string): string {
18
+ const normalized = path.replace(/^#/, '').split(/[?#]/)[0].replace(/\/$/, '');
19
+ return normalized || '/';
20
+ }
21
+
22
+ function findMenuTrail(items: MenuItem[], path: string, ancestors: MenuItem[] = []): MenuItem[] | undefined {
23
+ for (const item of items) {
24
+ const trail = [...ancestors, item];
25
+ if (item.href && normalizePath(item.href) === path) return trail;
26
+ if (item.children) {
27
+ const match = findMenuTrail(item.children, path, trail);
28
+ if (match) return match;
29
+ }
30
+ }
31
+ }
14
32
 
15
33
  const crumbs = $derived.by(() => {
16
34
  const result: Crumb[] = [{ label: i18n.t('common.home') ?? 'Home', href: adminContext.formatLink('/') }];
17
- const path = getPath().replace(/^\//, '');
18
- if (path === '') return result;
35
+ const currentPathname = normalizePath(getPath());
36
+ if (currentPathname === '/') return result;
37
+
38
+ const menuTrail = menu ? findMenuTrail(menu, currentPathname) : undefined;
39
+ if (menuTrail) {
40
+ for (const item of menuTrail) {
41
+ result.push({
42
+ label: item.label ?? item.name,
43
+ href: item.href ? adminContext.formatLink(normalizePath(item.href)) : undefined,
44
+ });
45
+ }
46
+ return result;
47
+ }
48
+
49
+ const path = currentPathname.replace(/^\//, '');
19
50
 
20
51
  const segments = path.split('/').filter(Boolean);
21
52
  const resourceNames = resources.map(r => r.name);
@@ -54,7 +85,7 @@
54
85
  {#if crumbs.length > 1}
55
86
  <Breadcrumb.Root class="mb-4">
56
87
  <Breadcrumb.List>
57
- {#each crumbs as crumb, i (crumb.href)}
88
+ {#each crumbs as crumb, i (`${crumb.label}-${i}`)}
58
89
  {#if i > 0}
59
90
  <Breadcrumb.Separator />
60
91
  {/if}
@@ -62,8 +93,10 @@
62
93
  <span class="inline-flex svadmin-page-enter" style="animation-duration: 0.2s;">
63
94
  {#if i === crumbs.length - 1}
64
95
  <Breadcrumb.Page>{crumb.label}</Breadcrumb.Page>
65
- {:else}
96
+ {:else if crumb.href}
66
97
  <Breadcrumb.Link href={crumb.href}>{crumb.label}</Breadcrumb.Link>
98
+ {:else}
99
+ <span class="text-muted-foreground">{crumb.label}</span>
67
100
  {/if}
68
101
  </span>
69
102
  </Breadcrumb.Item>
@@ -1,3 +1,7 @@
1
- declare const Breadcrumbs: import("svelte").Component<Record<string, never>, {}, "">;
1
+ import { type MenuItem } from '@svadmin/core';
2
+ type $$ComponentProps = {
3
+ menu?: MenuItem[];
4
+ };
5
+ declare const Breadcrumbs: import("svelte").Component<$$ComponentProps, {}, "">;
2
6
  type Breadcrumbs = ReturnType<typeof Breadcrumbs>;
3
7
  export default Breadcrumbs;
@@ -13,6 +13,8 @@
13
13
  const i18n = useTranslation();
14
14
 
15
15
  interface Props {
16
+ /** Render the launcher inside a layout toolbar instead of as a floating action button. */
17
+ docked?: boolean;
16
18
  /** localStorage key for chat history persistence. Set to '' to disable. */
17
19
  persistKey?: string;
18
20
  /** Custom persistence callback — called when messages change */
@@ -24,6 +26,7 @@
24
26
  }
25
27
 
26
28
  const {
29
+ docked = false,
27
30
  persistKey = 'svadmin-chat',
28
31
  onPersist,
29
32
  onRestore,
@@ -420,7 +423,7 @@
420
423
  tooltip={i18n.t('chat.title') || 'AI Assistant'}
421
424
  variant="default"
422
425
  size="icon"
423
- class="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
426
+ class="{docked ? 'relative h-9 w-9 rounded-md shadow-sm' : 'fixed bottom-4 right-4 h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 sm:bottom-6 sm:right-6'} z-[9998] transition-all bg-primary text-primary-foreground"
424
427
  onclick={() => { open = true; minimized = false; }}
425
428
  >
426
429
  <MessageCircle class="h-5 w-5" />
@@ -608,4 +611,3 @@
608
611
  {/if}
609
612
  {/if}
610
613
 
611
-
@@ -1,5 +1,7 @@
1
1
  import type { ChatMessage, ChatAction } from '@svadmin/core';
2
2
  interface Props {
3
+ /** Render the launcher inside a layout toolbar instead of as a floating action button. */
4
+ docked?: boolean;
3
5
  /** localStorage key for chat history persistence. Set to '' to disable. */
4
6
  persistKey?: string;
5
7
  /** Custom persistence callback — called when messages change */
@@ -14,6 +14,8 @@
14
14
 
15
15
  const i18n = useTranslation();
16
16
 
17
+ let { docked = false }: { docked?: boolean } = $props();
18
+
17
19
  const adminContext = captureAdminContext();
18
20
  let visible = $state(false);
19
21
  let collapsed = $state(false);
@@ -138,7 +140,7 @@
138
140
  tooltip={i18n.t('devtools.title')}
139
141
  variant="default"
140
142
  size="icon"
141
- class="fixed bottom-4 right-4 z-[9999] h-9 w-9 rounded-full shadow-lg opacity-60 hover:opacity-100 hover:scale-110 transition-all"
143
+ class="{docked ? 'relative rounded-md shadow-sm' : 'fixed bottom-5 right-[4.75rem] rounded-full shadow-lg sm:bottom-7 sm:right-[5.5rem]'} z-[9999] h-9 w-9 opacity-60 hover:opacity-100 hover:scale-110 transition-all"
142
144
  onclick={toggle}
143
145
  >
144
146
  <Bug class="h-4 w-4" />
@@ -1,3 +1,6 @@
1
- declare const DevTools: import("svelte").Component<Record<string, never>, {}, "">;
1
+ type $$ComponentProps = {
2
+ docked?: boolean;
3
+ };
4
+ declare const DevTools: import("svelte").Component<$$ComponentProps, {}, "">;
2
5
  type DevTools = ReturnType<typeof DevTools>;
3
6
  export default DevTools;
@@ -6,7 +6,8 @@
6
6
  import { Input } from './ui/input/index.js';
7
7
  import { Label } from './ui/label/index.js';
8
8
  import * as Alert from './ui/alert/index.js';
9
- import { User, ArrowLeft, CheckCircle, Loader2, AlertCircle, Shield } from '@lucide/svelte';
9
+ import SvadminLogo from './SvadminLogo.svelte';
10
+ import { User, ArrowLeft, CheckCircle, Loader2, AlertCircle } from '@lucide/svelte';
10
11
 
11
12
  const i18n = useTranslation();
12
13
 
@@ -49,9 +50,7 @@
49
50
 
50
51
  <div class="relative z-10 flex flex-col justify-between p-10 xl:p-12 w-full">
51
52
  <div class="flex items-center gap-3">
52
- <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/15 backdrop-blur-sm">
53
- <Shield class="h-5 w-5 text-white" />
54
- </div>
53
+ <SvadminLogo size={36} onDark />
55
54
  <span class="text-lg font-semibold text-white tracking-tight">{title}</span>
56
55
  </div>
57
56
 
@@ -75,10 +74,8 @@
75
74
  <!-- Right form panel -->
76
75
  <div class="flex-1 flex items-center justify-center bg-background p-6 sm:p-8 lg:p-12">
77
76
  <div class="w-full max-w-[400px]">
78
- <div class="flex items-center gap-3 mb-8 lg:hidden">
79
- <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
80
- <Shield class="h-5 w-5" />
81
- </div>
77
+ <div class="mb-8 flex items-center gap-3 lg:hidden">
78
+ <SvadminLogo size={36} />
82
79
  <span class="text-lg font-semibold tracking-tight">{title}</span>
83
80
  </div>
84
81
 
@@ -3,7 +3,7 @@
3
3
  import { Moon, Sun, Search, MonitorUp } from '@lucide/svelte';
4
4
  import { Button } from './ui/button';
5
5
  import TooltipButton from './TooltipButton.svelte';
6
- import { getResolvedTheme, toggleTheme } from '@svadmin/core';
6
+ import { getResolvedTheme, toggleTheme, type MenuItem } from '@svadmin/core';
7
7
  import Breadcrumbs from './Breadcrumbs.svelte';
8
8
  import { useTranslation } from '@svadmin/core/i18n';
9
9
 
@@ -20,6 +20,7 @@
20
20
  /** Slot for additional actions on the right side of the header */
21
21
  rightActions,
22
22
  siteUrl,
23
+ menu,
23
24
  } = $props<{
24
25
  showThemeToggle?: boolean;
25
26
  showBreadcrumbs?: boolean;
@@ -28,6 +29,7 @@
28
29
  children?: import('svelte').Snippet;
29
30
  rightActions?: import('svelte').Snippet;
30
31
  siteUrl?: string;
32
+ menu?: MenuItem[];
31
33
  }>();
32
34
 
33
35
  // Retrieve optional component overrides from registry
@@ -47,7 +49,7 @@
47
49
  {#if CustomBreadcrumbs}
48
50
  <CustomBreadcrumbs />
49
51
  {:else}
50
- <Breadcrumbs />
52
+ <Breadcrumbs {menu} />
51
53
  {/if}
52
54
  {/if}
53
55
  </div>
@@ -69,7 +71,7 @@
69
71
  {#if showSearch && onSearchClick}
70
72
  <Button variant="outline" size="sm" onclick={onSearchClick} class="gap-2 text-muted-foreground h-9 px-3 rounded-lg border-border/60">
71
73
  <Search class="h-3.5 w-3.5" />
72
- <span class="hidden sm:inline text-xs">{i18n.t('common.search')}...</span>
74
+ <span class="hidden sm:inline text-xs">{i18n.t('common.search')}</span>
73
75
  <kbd class="hidden sm:inline-flex items-center gap-0.5 rounded border bg-muted/60 px-1.5 py-0.5 text-[10px] font-medium font-mono text-muted-foreground">
74
76
  <span class="text-xs">⌘</span>K
75
77
  </kbd>
@@ -1,3 +1,4 @@
1
+ import { type MenuItem } from '@svadmin/core';
1
2
  type $$ComponentProps = {
2
3
  showThemeToggle?: boolean;
3
4
  showBreadcrumbs?: boolean;
@@ -6,6 +7,7 @@ type $$ComponentProps = {
6
7
  children?: import('svelte').Snippet;
7
8
  rightActions?: import('svelte').Snippet;
8
9
  siteUrl?: string;
10
+ menu?: MenuItem[];
9
11
  };
10
12
  declare const Header: import("svelte").Component<$$ComponentProps, {}, "">;
11
13
  type Header = ReturnType<typeof Header>;