@svadmin/ui 0.39.0 → 0.40.1

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 (34) 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 +26 -10
  13. package/dist/components/AutoTable.svelte +40 -13
  14. package/dist/components/Breadcrumbs.svelte +39 -6
  15. package/dist/components/Breadcrumbs.svelte.d.ts +5 -1
  16. package/dist/components/ChatDialog.svelte +4 -2
  17. package/dist/components/ChatDialog.svelte.d.ts +2 -0
  18. package/dist/components/DevTools.svelte +3 -1
  19. package/dist/components/DevTools.svelte.d.ts +4 -1
  20. package/dist/components/Header.svelte +5 -3
  21. package/dist/components/Header.svelte.d.ts +2 -0
  22. package/dist/components/Layout.svelte +10 -1
  23. package/dist/components/MarkdownRenderer.svelte +72 -29
  24. package/dist/components/SettingsPage.svelte +14 -2
  25. package/dist/components/SystemPageShell.svelte +34 -0
  26. package/dist/components/SystemPageShell.svelte.d.ts +8 -0
  27. package/dist/components/account/TeamMembersPage.svelte +28 -3
  28. package/dist/components/network/TeamCrewTablePage.svelte +20 -4
  29. package/dist/components/network/UserCardsNFTPage.svelte +24 -19
  30. package/dist/components/profile/ProjectsGrid.svelte +22 -6
  31. package/dist/components/profile/ProjectsGrid.svelte.d.ts +1 -0
  32. package/dist/components/profile/PublicProfilePage.svelte +5 -5
  33. package/dist/router-state.svelte.js +4 -0
  34. package/package.json +4 -4
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
@@ -43,12 +43,12 @@
43
43
  import ForgotPasswordPage from './ForgotPasswordPage.svelte';
44
44
  import UpdatePasswordPage from './UpdatePasswordPage.svelte';
45
45
  import ConfigErrorScreen from './ConfigErrorScreen.svelte';
46
- import DevTools from './DevTools.svelte';
47
46
  import Sidebar from './Sidebar.svelte';
48
47
  import Header from './Header.svelte';
49
48
  import TaskQueueDrawer from './TaskQueueDrawer.svelte';
50
49
  import SettingsPage from './SettingsPage.svelte';
51
50
  import ErrorPage from './ErrorPage.svelte';
51
+ import SystemPageShell from './SystemPageShell.svelte';
52
52
  import LazyPage from './LazyPage.svelte';
53
53
  import { Button } from './ui/button/index.js';
54
54
  import { Input } from './ui/input/index.js';
@@ -236,6 +236,12 @@
236
236
  const resourceNames = $derived(new Set(resources.map((resource) => resource.name)));
237
237
  const hasRouteResource = $derived(!params.resource || resourceNames.has(params.resource));
238
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
+ });
239
245
 
240
246
  // Auth check
241
247
  let isAuthenticated = $state(untrack(() => !authProvider));
@@ -245,6 +251,13 @@
245
251
  await adminContext.navigate(path);
246
252
  }
247
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
+
248
261
  $effect(() => {
249
262
  let cancelled = false;
250
263
  // Explicitly track route so auth is re-checked on navigation
@@ -262,7 +275,7 @@
262
275
  if (cancelled) return;
263
276
  isAuthenticated = result.authenticated;
264
277
  authChecked = true;
265
- 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)) {
266
279
  void navigateWithinApp(result.redirectTo ?? '/login');
267
280
  }
268
281
  }).catch(err => {
@@ -270,7 +283,7 @@
270
283
  console.warn('Auth check failed:', err);
271
284
  isAuthenticated = false;
272
285
  authChecked = true;
273
- 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)) {
274
287
  void navigateWithinApp('/login');
275
288
  }
276
289
  });
@@ -305,13 +318,20 @@
305
318
  <UpdatePasswordPage {title} />
306
319
  {:else if route === '/login' || route === '/register' || route === '/forgot-password' || route === '/update-password'}
307
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>
308
330
  {:else if isAuthenticated || !authProvider}
309
331
  <Layout {title} {menu} {siteUrl} routeMode={resolvedRouteMode}>
310
332
  {#key route + (params.resource ?? '') + (params.id ?? '') + (params.variant ?? '') + (params.columns ?? '')}
311
333
  <div class="svadmin-page-enter">
312
- {#if route === '/2fa' || route === '/authentication/branded/2fa'}
313
- <LazyPage loader={loadTwoFactorAuthPage} props={{}} />
314
- {:else if route === '/public-profile'}
334
+ {#if route === '/public-profile'}
315
335
  <LazyPage loader={loadPublicProfilePage} props={{ variant: 'default', initialTab: 'projects' }} />
316
336
  {:else if route === '/public-profile/projects/:columns'}
317
337
  <LazyPage loader={loadPublicProfilePage} props={{ variant: 'default', initialTab: 'projects', columns: params.columns?.includes('3') ? 3 : 2 }} />
@@ -349,9 +369,6 @@
349
369
  <LazyPage loader={loadTeamCrewTablePage} props={{}} />
350
370
  {:else if route.startsWith('/settings')}
351
371
  <SettingsPage />
352
- {:else if route === '/500' || (route === '/:resource' && params.resource === '500')}
353
- {@const ErrorComp = mergedComponents.ErrorPage || ErrorPage}
354
- <ErrorComp status="500" />
355
372
  {:else if route === '/' || route === '' || route === '/inventory-dashboard'}
356
373
  {#if dashboard}
357
374
  {@render dashboard()}
@@ -399,5 +416,4 @@
399
416
  </div>
400
417
  {/if}
401
418
  <Toast />
402
- <DevTools />
403
419
  </QueryClientProvider>
@@ -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;
@@ -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>;
@@ -7,6 +7,7 @@
7
7
  import CommandPalette from './CommandPalette.svelte';
8
8
  import KeyboardShortcuts from './KeyboardShortcuts.svelte';
9
9
  import ChatDialog from './ChatDialog.svelte';
10
+ import DevTools from './DevTools.svelte';
10
11
  import { useTranslation } from '@svadmin/core/i18n';
11
12
 
12
13
  import { captureAdminContext, getAuthProvider, getTaskProvider } from '@svadmin/core';
@@ -121,6 +122,9 @@
121
122
  </div>
122
123
  </div>
123
124
  </div>
125
+ <div class="hidden" aria-hidden="true">
126
+ <DevTools docked />
127
+ </div>
124
128
  {:else}
125
129
  <div class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
126
130
  <!-- Desktop sidebar -->
@@ -145,6 +149,7 @@
145
149
  <!-- Header with mobile hamburger -->
146
150
  <Header
147
151
  {siteUrl}
152
+ {menu}
148
153
  showSearch={true}
149
154
  onSearchClick={() => { commandOpen = true; }}
150
155
  >
@@ -182,6 +187,11 @@
182
187
  {/key}
183
188
  </div>
184
189
  </main>
190
+
191
+ <footer class="flex min-h-14 shrink-0 items-center justify-end gap-2 border-t border-border/60 bg-background px-4 empty:hidden">
192
+ <DevTools docked />
193
+ <ChatDialog docked />
194
+ </footer>
185
195
  </div>
186
196
  </div>
187
197
  <CommandPalette
@@ -193,5 +203,4 @@
193
203
  }}
194
204
  />
195
205
  <KeyboardShortcuts bind:open={shortcutsOpen} />
196
- <ChatDialog />
197
206
  {/if}
@@ -1,19 +1,6 @@
1
1
  <script lang="ts">
2
2
  /* eslint-disable svelte/no-at-html-tags */
3
- import * as markedPkg from "marked";
4
- import * as markedHighlightPkg from "marked-highlight";
5
- import * as hljsPkg from "highlight.js";
6
- import * as DOMPurifyPkg from "isomorphic-dompurify";
7
-
8
- const Marked = markedPkg.Marked;
9
- const markedHighlight = markedHighlightPkg.markedHighlight;
10
- const hljsModule = hljsPkg.default || hljsPkg;
11
- const DOMPurify = 'default' in DOMPurifyPkg ? DOMPurifyPkg.default : DOMPurifyPkg;
12
-
13
- // Only safely import css if hljs exists
14
- if (hljsModule && Object.keys(hljsModule).length > 0) {
15
- import("highlight.js/styles/github-dark.css").catch(() => {});
16
- }
3
+ import { onMount } from "svelte";
17
4
 
18
5
  interface Props {
19
6
  /** The raw markdown string to render */
@@ -26,25 +13,81 @@
26
13
 
27
14
  let { content, streaming = false, class: className = "" }: Props = $props();
28
15
 
29
- const hasMarkdownDeps = typeof Marked === "function" && typeof DOMPurify?.sanitize === "function";
16
+ type MarkedConstructor = typeof import("marked").Marked;
17
+ type MarkedHighlight = typeof import("marked-highlight").markedHighlight;
18
+ type HighlightApi = typeof import("highlight.js").default;
19
+ type Sanitizer = typeof import("isomorphic-dompurify").default;
20
+
21
+ // Lazily loaded optional peer dependencies (marked, marked-highlight,
22
+ // highlight.js, isomorphic-dompurify). They are declared as optional peer
23
+ // deps; statically importing them would crash consumers that have not
24
+ // installed them, even when MarkdownRenderer is never rendered. Load them
25
+ // dynamically so the module graph resolves without them and the component
26
+ // degrades to escaped-text rendering when they are absent.
27
+ let MarkedCtor = $state<MarkedConstructor | null>(null);
28
+ let markedHighlightFn = $state<MarkedHighlight | null>(null);
29
+ let hljs = $state<HighlightApi | null>(null);
30
+ let DOMPurify = $state<Sanitizer | null>(null);
31
+
32
+ onMount(() => {
33
+ let cancelled = false;
34
+ (async () => {
35
+ try {
36
+ const [markedPkg, markedHighlightPkg, hljsPkg, DOMPurifyPkg] = await Promise.all([
37
+ import("marked"),
38
+ import("marked-highlight"),
39
+ import("highlight.js"),
40
+ import("isomorphic-dompurify"),
41
+ ]);
42
+ if (cancelled) return;
43
+ MarkedCtor = markedPkg.Marked;
44
+ markedHighlightFn = markedHighlightPkg.markedHighlight;
45
+ hljs = hljsPkg.default;
46
+ DOMPurify = DOMPurifyPkg.default;
47
+ // Only import the theme css when highlight.js is available
48
+ if (hljs && Object.keys(hljs).length > 0) {
49
+ import("highlight.js/styles/github-dark.css").catch(() => {});
50
+ }
51
+ } catch {
52
+ // Optional deps not installed; fall back to escaped-text rendering.
53
+ if (cancelled) return;
54
+ }
55
+ })();
56
+ return () => { cancelled = true; };
57
+ });
58
+
59
+ const hasMarkdownDeps = $derived(
60
+ typeof MarkedCtor === "function" &&
61
+ typeof DOMPurify?.sanitize === "function" &&
62
+ typeof markedHighlightFn === "function",
63
+ );
30
64
 
31
65
  // Configure marked with syntax highlighting if available
32
- const markedObj = hasMarkdownDeps ? new Marked(
33
- markedHighlight({
34
- langPrefix: "hljs language-",
35
- highlight(code: string, lang: string) {
36
- const language = hljsModule.getLanguage && hljsModule.getLanguage(lang) ? lang : "plaintext";
37
- return hljsModule.highlight ? hljsModule.highlight(code, { language }).value : code;
38
- },
39
- }),
40
- ) : null;
66
+ const markedObj = $derived.by(() => {
67
+ const Constructor = MarkedCtor;
68
+ const highlightExtension = markedHighlightFn;
69
+ if (!Constructor || !highlightExtension) return null;
70
+
71
+ return new Constructor(
72
+ highlightExtension({
73
+ langPrefix: "hljs language-",
74
+ highlight(code: string, lang: string) {
75
+ const language = hljs?.getLanguage && hljs.getLanguage(lang) ? lang : "plaintext";
76
+ return hljs?.highlight ? hljs.highlight(code, { language }).value : code;
77
+ },
78
+ }),
79
+ );
80
+ });
41
81
 
42
82
  // Render HTML safely
43
- const html = $derived(
44
- hasMarkdownDeps
45
- ? DOMPurify.sanitize(markedObj?.parse(content || "") as string)
46
- : `<div style="white-space: pre-wrap">${String(content || "").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>`
47
- );
83
+ const html = $derived.by(() => {
84
+ const purifier = DOMPurify;
85
+ const parser = markedObj;
86
+ if (hasMarkdownDeps && purifier && parser) {
87
+ return purifier.sanitize(parser.parse(content || ""));
88
+ }
89
+ return `<div style="white-space: pre-wrap">${String(content || "").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>`;
90
+ });
48
91
 
49
92
  // Handle copy code blocks
50
93
  let copiedBlock = $state<string | null>(null);
@@ -88,10 +88,22 @@
88
88
  },
89
89
  ];
90
90
 
91
- const resolvedSections = $derived(customSections ?? defaultSections);
91
+ const resolvedSections = $derived.by(() => {
92
+ if (customSections) return customSections;
93
+ const accountPath = getPath().startsWith('/account/');
94
+ if (!accountPath) return defaultSections;
95
+
96
+ return defaultSections.map((section) => ({
97
+ ...section,
98
+ items: section.items.map((item) => ({
99
+ ...item,
100
+ path: `/account/${item.key === 'api' ? 'api-keys' : item.key}`,
101
+ })),
102
+ }));
103
+ });
92
104
  const sectionKeys = $derived(new Set(resolvedSections.flatMap((section) => section.items.map((item) => item.key))));
93
105
 
94
- import { getParams, getRoute } from "../router-state.svelte.js";
106
+ import { getParams, getPath, getRoute } from "../router-state.svelte.js";
95
107
 
96
108
  const i18n = useTranslation();
97
109
 
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { captureAdminContext } from '@svadmin/core';
4
+ import { useTranslation } from '@svadmin/core/i18n';
5
+ import { ArrowLeft } from '@lucide/svelte';
6
+ import SvadminLogo from './SvadminLogo.svelte';
7
+ import { Button } from './ui/button/index.js';
8
+
9
+ let { title = 'svadmin', children }: { title?: string; children: Snippet } = $props();
10
+
11
+ const adminContext = captureAdminContext();
12
+ const i18n = useTranslation();
13
+ </script>
14
+
15
+ <div class="min-h-screen bg-background text-foreground">
16
+ <header class="flex h-[70px] items-center justify-between border-b border-border/60 px-4 sm:px-6">
17
+ <button
18
+ type="button"
19
+ class="inline-flex items-center gap-2.5 text-sm font-semibold"
20
+ onclick={() => adminContext.navigate('/')}
21
+ >
22
+ <SvadminLogo size={30} />
23
+ <span>{title}</span>
24
+ </button>
25
+ <Button variant="ghost" size="sm" onclick={() => adminContext.back()}>
26
+ <ArrowLeft class="h-4 w-4" />
27
+ <span class="hidden sm:inline">{i18n.t('common.back')}</span>
28
+ </Button>
29
+ </header>
30
+
31
+ <main class="min-h-[calc(100vh-70px)] bg-muted/20 px-3 py-5 sm:px-6 sm:py-8">
32
+ {@render children()}
33
+ </main>
34
+ </div>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ title?: string;
4
+ children: Snippet;
5
+ };
6
+ declare const SystemPageShell: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type SystemPageShell = ReturnType<typeof SystemPageShell>;
8
+ export default SystemPageShell;
@@ -23,6 +23,11 @@
23
23
  let inviteEmail = $state('');
24
24
  let inviteRole = $state('Viewer');
25
25
  let linkCopied = $state(false);
26
+ let inviteStatus = $state('');
27
+
28
+ function focusInvite() {
29
+ document.getElementById('invite-email')?.focus();
30
+ }
26
31
 
27
32
  function copyInviteLink() {
28
33
  navigator.clipboard?.writeText('https://acme.com/invite/xK7fQ2').catch(() => {});
@@ -47,6 +52,23 @@
47
52
  { id: '14', name: 'Chen Wei', email: 'chen@acme.com', role: 'Admin', department: 'Engineering', status: 'active', lastActive: '15 min ago' },
48
53
  ]);
49
54
 
55
+ function sendInvite() {
56
+ const email = inviteEmail.trim();
57
+ if (!email) return;
58
+ const name = email.split('@')[0].split(/[._-]/).filter(Boolean).map(part => part[0]?.toUpperCase() + part.slice(1)).join(' ') || email;
59
+ members = [...members, {
60
+ id: `invite-${Date.now()}`,
61
+ name,
62
+ email,
63
+ role: inviteRole as TeamMember['role'],
64
+ department: 'Pending assignment',
65
+ status: 'invited',
66
+ lastActive: 'Pending',
67
+ }];
68
+ inviteStatus = email;
69
+ inviteEmail = '';
70
+ }
71
+
50
72
  const filtered = $derived(
51
73
  searchQuery
52
74
  ? members.filter(m => m.name.toLowerCase().includes(searchQuery.toLowerCase()) || m.email.toLowerCase().includes(searchQuery.toLowerCase()) || m.department.toLowerCase().includes(searchQuery.toLowerCase()))
@@ -80,7 +102,7 @@
80
102
  <h2 class="text-xl font-semibold text-foreground">{i18n.t('account.teamMembersCount', { count: members.length })}</h2>
81
103
  <p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.teamMembersDescription')}</p>
82
104
  </div>
83
- <Button size="sm">
105
+ <Button size="sm" onclick={focusInvite}>
84
106
  <UserPlus class="h-4 w-4 mr-1" />{i18n.t('account.inviteMember')}
85
107
  </Button>
86
108
  </div>
@@ -113,10 +135,13 @@
113
135
  </div>
114
136
  </div>
115
137
  <div class="flex justify-end">
116
- <Button size="sm" disabled={!inviteEmail}>
138
+ <Button size="sm" disabled={!inviteEmail} onclick={sendInvite}>
117
139
  <Send class="h-3.5 w-3.5 mr-1" />{i18n.t('account.sendInvite')}
118
140
  </Button>
119
141
  </div>
142
+ {#if inviteStatus}
143
+ <p class="text-xs text-green-600" role="status">{i18n.t('account.invited')}: {inviteStatus}</p>
144
+ {/if}
120
145
  </Card.CardContent>
121
146
  </Card.Card>
122
147
 
@@ -163,7 +188,7 @@
163
188
  <span class="text-xs text-muted-foreground">{member.lastActive}</span>
164
189
  </div>
165
190
  </div>
166
- <Button variant="ghost" size="icon-sm"><MoreHorizontal class="h-4 w-4" /></Button>
191
+ <Button variant="ghost" size="icon-sm" aria-label={member.name} title={member.name}><MoreHorizontal class="h-4 w-4" /></Button>
167
192
  </div>
168
193
  </Card.CardContent>
169
194
  </Card.Card>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { captureAdminContext } from '@svadmin/core';
2
3
  import { useTranslation } from '@svadmin/core/i18n';
3
4
  import * as Card from '../ui/card/index.js';
4
5
  import { Button } from '../ui/button/index.js';
@@ -6,6 +7,7 @@
6
7
  import { Search, Download, UserPlus, MoreHorizontal, CheckCircle2, Clock, XCircle } from '@lucide/svelte';
7
8
 
8
9
  const i18n = useTranslation();
10
+ const adminContext = captureAdminContext();
9
11
 
10
12
  interface CrewMember {
11
13
  id: string;
@@ -59,6 +61,20 @@
59
61
  };
60
62
 
61
63
  const initials = (name: string) => name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
64
+
65
+ function exportCrew() {
66
+ const rows = [
67
+ ['Name', 'Email', 'Role', 'Department', 'Status', 'Projects', 'Joined'],
68
+ ...filtered.map(member => [member.name, member.email, member.role, member.department, member.status, String(member.projects), member.joinedAt]),
69
+ ];
70
+ const csv = rows.map(row => row.map(value => `"${value.replaceAll('"', '""')}"`).join(',')).join('\n');
71
+ const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
72
+ const link = document.createElement('a');
73
+ link.href = url;
74
+ link.download = 'team-crew.csv';
75
+ link.click();
76
+ URL.revokeObjectURL(url);
77
+ }
62
78
  </script>
63
79
 
64
80
  <div class="space-y-6" data-svadmin-content-page="network">
@@ -68,10 +84,10 @@
68
84
  <p class="mt-1 text-sm text-muted-foreground">{i18n.t('network.teamCrewDescription')}</p>
69
85
  </div>
70
86
  <div class="flex gap-2">
71
- <Button variant="outline" size="sm">
87
+ <Button variant="outline" size="sm" onclick={exportCrew}>
72
88
  <Download class="h-3.5 w-3.5 mr-1" />{i18n.t('common.export')}
73
89
  </Button>
74
- <Button size="sm">
90
+ <Button size="sm" onclick={() => adminContext.navigate('/account/members/team-members')}>
75
91
  <UserPlus class="h-3.5 w-3.5 mr-1" />{i18n.t('account.inviteMember')}
76
92
  </Button>
77
93
  </div>
@@ -126,7 +142,7 @@
126
142
  <td class="px-4 py-3 text-sm text-foreground">{member.projects}</td>
127
143
  <td class="px-4 py-3 text-sm text-muted-foreground whitespace-nowrap">{member.joinedAt}</td>
128
144
  <td class="px-4 py-3">
129
- <Button variant="ghost" size="icon-sm"><MoreHorizontal class="h-4 w-4" /></Button>
145
+ <Button variant="ghost" size="icon-sm" aria-label={member.name} title={member.name}><MoreHorizontal class="h-4 w-4" /></Button>
130
146
  </td>
131
147
  </tr>
132
148
  {/each}
@@ -150,7 +166,7 @@
150
166
  <p class="truncate text-xs text-muted-foreground">{member.email}</p>
151
167
  </div>
152
168
  </div>
153
- <Button variant="ghost" size="icon-sm" class="shrink-0"><MoreHorizontal class="h-4 w-4" /></Button>
169
+ <Button variant="ghost" size="icon-sm" class="shrink-0" aria-label={member.name} title={member.name}><MoreHorizontal class="h-4 w-4" /></Button>
154
170
  </div>
155
171
  <div class="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
156
172
  <div class="min-w-0">
@@ -5,6 +5,15 @@
5
5
  import { Badge } from '../ui/badge/index.js';
6
6
  import { Input } from '../ui/input/index.js';
7
7
  import { Search, ExternalLink, Check, Plus } from '@lucide/svelte';
8
+ import nftCover01 from '../../assets/nft/nft-cover-01.jpg';
9
+ import nftCover02 from '../../assets/nft/nft-cover-02.jpg';
10
+ import nftCover03 from '../../assets/nft/nft-cover-03.jpg';
11
+ import nftCover04 from '../../assets/nft/nft-cover-04.jpg';
12
+ import nftCover05 from '../../assets/nft/nft-cover-05.jpg';
13
+ import nftCover06 from '../../assets/nft/nft-cover-06.jpg';
14
+ import nftCover07 from '../../assets/nft/nft-cover-07.jpg';
15
+ import nftCover08 from '../../assets/nft/nft-cover-08.jpg';
16
+ import nftCover09 from '../../assets/nft/nft-cover-09.jpg';
8
17
 
9
18
  const i18n = useTranslation();
10
19
 
@@ -12,7 +21,7 @@
12
21
  id: string;
13
22
  name: string;
14
23
  username: string;
15
- avatar?: string;
24
+ artwork: string;
16
25
  bio: string;
17
26
  collectionCount: number;
18
27
  floorPrice: string;
@@ -24,15 +33,15 @@
24
33
  let searchQuery = $state('');
25
34
 
26
35
  const users: NFTUser[] = [
27
- { id: '1', name: 'CryptoArt Studio', username: '@cryptoart', bio: 'Digital art collection featuring generative abstracts', collectionCount: 42, floorPrice: '0.5 ETH', volume: '128.3 ETH', verified: true, tags: ['Art', 'Generative'] },
28
- { id: '2', name: 'PixelPunk Collective', username: '@pixelpunk', bio: 'On-chain pixel art characters with unique traits', collectionCount: 18, floorPrice: '1.2 ETH', volume: '342.7 ETH', verified: true, tags: ['PFP', 'Pixel'] },
29
- { id: '3', name: 'MetaVerse Builders', username: '@metabuild', bio: '3D architectural models for virtual worlds', collectionCount: 7, floorPrice: '0.8 ETH', volume: '56.1 ETH', verified: false, tags: ['3D', 'Virtual'] },
30
- { id: '4', name: 'SoundWave NFT', username: '@soundwave', bio: 'Music-backed NFTs with on-chain audio', collectionCount: 23, floorPrice: '0.3 ETH', volume: '89.4 ETH', verified: true, tags: ['Music', 'Audio'] },
31
- { id: '5', name: 'GameAssets DAO', username: '@gameassets', bio: 'In-game items and collectibles for Web3 gaming', collectionCount: 156, floorPrice: '0.05 ETH', volume: '412.8 ETH', verified: true, tags: ['Gaming', 'Items'] },
32
- { id: '6', name: 'PhotoVerse', username: '@photoverse', bio: 'Curated photography NFTs from world artists', collectionCount: 31, floorPrice: '0.15 ETH', volume: '67.2 ETH', verified: false, tags: ['Photo', 'Curated'] },
33
- { id: '7', name: 'CryptoKicks', username: '@cryptokicks', bio: 'Limited-edition digital sneakers and wearables', collectionCount: 64, floorPrice: '0.4 ETH', volume: '156.9 ETH', verified: true, tags: ['Fashion', 'Wearables'] },
34
- { id: '8', name: 'ChainChronicles', username: '@chainchron', bio: 'Story-driven collectible cards with on-chain lore', collectionCount: 88, floorPrice: '0.25 ETH', volume: '203.4 ETH', verified: false, tags: ['Cards', 'Story'] },
35
- { id: '9', name: 'AIArtifacts', username: '@aiartifacts', bio: 'AI-generated sculptures minted as unique tokens', collectionCount: 12, floorPrice: '2.1 ETH', volume: '512.6 ETH', verified: true, tags: ['AI', '3D'] },
36
+ { id: '1', name: 'CryptoArt Studio', username: '@cryptoart', artwork: nftCover01, bio: 'Digital art collection featuring generative abstracts', collectionCount: 42, floorPrice: '0.5 ETH', volume: '128.3 ETH', verified: true, tags: ['Art', 'Generative'] },
37
+ { id: '2', name: 'PixelPunk Collective', username: '@pixelpunk', artwork: nftCover02, bio: 'On-chain pixel art characters with unique traits', collectionCount: 18, floorPrice: '1.2 ETH', volume: '342.7 ETH', verified: true, tags: ['PFP', 'Pixel'] },
38
+ { id: '3', name: 'MetaVerse Builders', username: '@metabuild', artwork: nftCover03, bio: '3D architectural models for virtual worlds', collectionCount: 7, floorPrice: '0.8 ETH', volume: '56.1 ETH', verified: false, tags: ['3D', 'Virtual'] },
39
+ { id: '4', name: 'SoundWave NFT', username: '@soundwave', artwork: nftCover04, bio: 'Music-backed NFTs with on-chain audio', collectionCount: 23, floorPrice: '0.3 ETH', volume: '89.4 ETH', verified: true, tags: ['Music', 'Audio'] },
40
+ { id: '5', name: 'GameAssets DAO', username: '@gameassets', artwork: nftCover05, bio: 'In-game items and collectibles for Web3 gaming', collectionCount: 156, floorPrice: '0.05 ETH', volume: '412.8 ETH', verified: true, tags: ['Gaming', 'Items'] },
41
+ { id: '6', name: 'PhotoVerse', username: '@photoverse', artwork: nftCover06, bio: 'Curated photography NFTs from world artists', collectionCount: 31, floorPrice: '0.15 ETH', volume: '67.2 ETH', verified: false, tags: ['Photo', 'Curated'] },
42
+ { id: '7', name: 'CryptoKicks', username: '@cryptokicks', artwork: nftCover07, bio: 'Limited-edition digital sneakers and wearables', collectionCount: 64, floorPrice: '0.4 ETH', volume: '156.9 ETH', verified: true, tags: ['Fashion', 'Wearables'] },
43
+ { id: '8', name: 'ChainChronicles', username: '@chainchron', artwork: nftCover08, bio: 'Story-driven collectible cards with on-chain lore', collectionCount: 88, floorPrice: '0.25 ETH', volume: '203.4 ETH', verified: false, tags: ['Cards', 'Story'] },
44
+ { id: '9', name: 'AIArtifacts', username: '@aiartifacts', artwork: nftCover09, bio: 'AI-generated sculptures minted as unique tokens', collectionCount: 12, floorPrice: '2.1 ETH', volume: '512.6 ETH', verified: true, tags: ['AI', '3D'] },
36
45
  ];
37
46
 
38
47
  const filtered = $derived(
@@ -41,8 +50,6 @@
41
50
  : users
42
51
  );
43
52
 
44
- const initials = (name: string) => name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
45
-
46
53
  let connectedIds = $state<string[]>([]);
47
54
 
48
55
  function toggleConnect(id: string) {
@@ -71,16 +78,14 @@
71
78
 
72
79
  <div class="grid gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
73
80
  {#each filtered as user (user.id)}
74
- <Card.Card class="overflow-hidden border-border/60 hover:border-primary/30 transition-colors">
75
- <div class="h-20 bg-gradient-to-r from-violet-500/20 via-purple-500/15 to-fuchsia-500/20 relative">
76
- <div class="absolute inset-0 opacity-[0.08]" style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 16px 16px;"></div>
81
+ <Card.Card class="group overflow-hidden border-border/60 hover:border-primary/30 transition-colors">
82
+ <div class="h-28 overflow-hidden bg-muted">
83
+ <img src={user.artwork} alt="" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]" />
77
84
  </div>
78
85
  <Card.CardContent class="relative px-4 pb-4">
79
86
  <div class="-mt-8 mb-2 flex items-end gap-3">
80
87
  <div class="ring-3 ring-card rounded-xl overflow-hidden shrink-0">
81
- <div class="flex h-14 w-14 items-center justify-center bg-primary/10 text-primary font-bold">
82
- {initials(user.name)}
83
- </div>
88
+ <img src={user.artwork} alt="" class="h-14 w-14 object-cover" />
84
89
  </div>
85
90
  <div class="flex-1 min-w-0 pb-0.5">
86
91
  <div class="flex items-center gap-1.5">
@@ -130,7 +135,7 @@
130
135
  <Plus class="h-3.5 w-3.5 mr-1" />{i18n.t('network.connect')}
131
136
  {/if}
132
137
  </Button>
133
- <Button size="sm" variant="ghost">
138
+ <Button size="sm" variant="ghost" aria-label={i18n.t('common.detail')} title={i18n.t('common.detail')}>
134
139
  <ExternalLink class="h-3.5 w-3.5" />
135
140
  </Button>
136
141
  </div>
@@ -4,7 +4,8 @@
4
4
  import { Badge } from '../ui/badge/index.js';
5
5
  import { Button } from '../ui/button/index.js';
6
6
  import { Input } from '../ui/input/index.js';
7
- import { Users, CheckCircle2, ArrowRight, Search, Plus } from '@lucide/svelte';
7
+ import { Users, CheckCircle2, ArrowRight, Search, Plus, FolderKanban } from '@lucide/svelte';
8
+ import Progress from '../ui/progress/progress.svelte';
8
9
 
9
10
  const i18n = useTranslation();
10
11
 
@@ -17,6 +18,7 @@
17
18
  status: 'active' | 'completed' | 'on-hold';
18
19
  tags?: string[];
19
20
  image?: string;
21
+ progress?: number;
20
22
  }
21
23
 
22
24
  interface Props {
@@ -50,10 +52,10 @@
50
52
  <h3 class="text-lg font-semibold text-foreground">
51
53
  {i18n.t('publicProfile.projectsCount', { count: filtered.length })}
52
54
  </h3>
53
- <div class="flex items-center gap-2">
54
- <div class="relative">
55
+ <div class="flex w-full items-center gap-2 sm:w-auto">
56
+ <div class="relative min-w-0 flex-1 sm:flex-none">
55
57
  <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
56
- <Input placeholder={i18n.t('publicProfile.searchProjects')} bind:value={searchQuery} class="w-48 pl-9" />
58
+ <Input placeholder={i18n.t('publicProfile.searchProjects')} bind:value={searchQuery} class="w-full pl-9 sm:w-48" />
57
59
  </div>
58
60
  <Button size="sm"><Plus class="h-3.5 w-3.5 mr-1" />{i18n.t('common.create')}</Button>
59
61
  </div>
@@ -66,6 +68,7 @@
66
68
  {:else}
67
69
  <div class="grid gap-4 sm:grid-cols-1 md:grid-cols-2 {gridCols}">
68
70
  {#each filtered as project (project.id)}
71
+ {@const progress = project.progress ?? (project.status === 'completed' ? 100 : project.status === 'active' ? 72 : 38)}
69
72
  <Card.Card class="group overflow-hidden border-border/60 hover:border-primary/30 transition-colors">
70
73
  {#if project.image}
71
74
  <div class="h-32 overflow-hidden">
@@ -74,7 +77,12 @@
74
77
  {/if}
75
78
  <Card.CardContent class="p-4 space-y-3">
76
79
  <div class="flex items-start justify-between gap-2">
77
- <h4 class="font-semibold text-foreground">{project.name}</h4>
80
+ <div class="flex min-w-0 items-center gap-3">
81
+ <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
82
+ <FolderKanban class="h-4 w-4" />
83
+ </span>
84
+ <h4 class="font-semibold text-foreground">{project.name}</h4>
85
+ </div>
78
86
  <span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold {statusConfig[project.status].color}">
79
87
  {statusConfig[project.status].label}
80
88
  </span>
@@ -90,12 +98,20 @@
90
98
  </div>
91
99
  {/if}
92
100
 
101
+ <div class="space-y-1.5">
102
+ <div class="flex items-center justify-between text-[11px] text-muted-foreground">
103
+ <span>{i18n.t('common.progress')}</span>
104
+ <span class="font-medium text-foreground">{progress}%</span>
105
+ </div>
106
+ <Progress value={progress} class="h-1.5" />
107
+ </div>
108
+
93
109
  <div class="flex items-center justify-between pt-2 border-t">
94
110
  <div class="flex items-center gap-3 text-xs text-muted-foreground">
95
111
  <span class="flex items-center gap-1"><Users class="h-3 w-3" />{i18n.t('publicProfile.projectMembers', { count: project.members })}</span>
96
112
  <span class="flex items-center gap-1"><CheckCircle2 class="h-3 w-3" />{i18n.t('publicProfile.projectTasks', { count: project.tasks })}</span>
97
113
  </div>
98
- <Button variant="ghost" size="icon-sm">
114
+ <Button variant="ghost" size="icon-sm" aria-label={project.name} title={project.name}>
99
115
  <ArrowRight class="h-3.5 w-3.5" />
100
116
  </Button>
101
117
  </div>
@@ -7,6 +7,7 @@ interface Project {
7
7
  status: 'active' | 'completed' | 'on-hold';
8
8
  tags?: string[];
9
9
  image?: string;
10
+ progress?: number;
10
11
  }
11
12
  interface Props {
12
13
  columns?: 2 | 3;
@@ -120,18 +120,18 @@
120
120
  <div class="space-y-6" data-svadmin-content-page="public-profile">
121
121
  <!-- Cover + Profile Header -->
122
122
  <Card.Card class="overflow-hidden border-border/60">
123
- <div class="h-40 bg-gradient-to-r from-primary/25 via-primary/15 to-accent/20 relative">
123
+ <div class="h-28 bg-gradient-to-r from-primary/25 via-primary/15 to-accent/20 relative sm:h-40">
124
124
  <div class="absolute inset-0 opacity-[0.06]" style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 24px 24px;"></div>
125
125
  </div>
126
126
  <Card.CardContent class="relative px-6 pb-5">
127
- <div class="-mt-12 flex flex-col sm:flex-row sm:items-end gap-4">
127
+ <div class="-mt-10 flex flex-col gap-3 sm:-mt-12 sm:flex-row sm:items-end sm:gap-4">
128
128
  <div class="ring-4 ring-card rounded-2xl overflow-hidden shrink-0">
129
- <div class="flex h-24 w-24 items-center justify-center bg-primary/10 text-primary text-3xl font-bold">
129
+ <div class="flex h-20 w-20 items-center justify-center bg-primary/10 text-2xl font-bold text-primary sm:h-24 sm:w-24 sm:text-3xl">
130
130
  {profileData.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
131
131
  </div>
132
132
  </div>
133
133
  <div class="flex-1 min-w-0">
134
- <h2 class="text-2xl font-bold text-foreground">{profileData.name}</h2>
134
+ <h2 class="text-xl font-bold text-foreground sm:text-2xl">{profileData.name}</h2>
135
135
  {#if profileData.tagline}
136
136
  <p class="text-muted-foreground">{profileData.tagline}</p>
137
137
  {/if}
@@ -147,7 +147,7 @@
147
147
  {/if}
148
148
  </div>
149
149
  </div>
150
- <div class="flex gap-2 shrink-0">
150
+ <div class="flex shrink-0 flex-wrap gap-2">
151
151
  <Button size="sm">{i18n.t('publicProfile.editProfile')}</Button>
152
152
  <Button size="sm" variant="outline">{i18n.t('publicProfile.sendMessage')}</Button>
153
153
  <Button size="sm" variant="ghost"><MoreHorizontal class="h-4 w-4" /></Button>
@@ -7,6 +7,10 @@ const ROUTES = [
7
7
  '/update-password',
8
8
  '/2fa',
9
9
  '/authentication/branded/2fa',
10
+ '/authentication/error-404',
11
+ '/authentication/error-500',
12
+ '/404',
13
+ '/500',
10
14
  '/settings',
11
15
  '/settings/:tab',
12
16
  '/',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -39,7 +39,7 @@
39
39
  "./components/*": "./dist/components/*"
40
40
  },
41
41
  "peerDependencies": {
42
- "@svadmin/core": "^0.33.0",
42
+ "@svadmin/core": "^0.34.1",
43
43
  "@tanstack/svelte-query": "^6.1.38",
44
44
  "highlight.js": "^11.11.1",
45
45
  "isomorphic-dompurify": "^3.20.0",
@@ -77,8 +77,8 @@
77
77
  "@floating-ui/dom": "^1.8.0",
78
78
  "@lucide/svelte": "^1.28.0",
79
79
  "@tanstack/svelte-store": "^0.12.0",
80
- "@tanstack/svelte-table": "9.0.0-alpha.53",
81
- "@tanstack/table-core": "9.0.0-alpha.53",
80
+ "@tanstack/svelte-table": "9.0.0",
81
+ "@tanstack/table-core": "9.0.0",
82
82
  "bits-ui": "^2.18.1",
83
83
  "clsx": "^2.1.1",
84
84
  "svelte-sonner": "^1.1.1",