@svadmin/ui 0.0.6 → 0.3.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 (41) hide show
  1. package/package.json +3 -2
  2. package/src/components/AdminApp.svelte +4 -3
  3. package/src/components/Authenticated.svelte +44 -13
  4. package/src/components/AutoForm.svelte +5 -5
  5. package/src/components/AutoSaveIndicator.svelte +30 -0
  6. package/src/components/AutoTable.svelte +81 -10
  7. package/src/components/Breadcrumbs.svelte +2 -0
  8. package/src/components/CatchAllNavigate.svelte +21 -0
  9. package/src/components/ConfirmDialog.svelte +1 -1
  10. package/src/components/CreatePage.svelte +42 -0
  11. package/src/components/EditPage.svelte +52 -0
  12. package/src/components/EmptyState.svelte +17 -31
  13. package/src/components/ErrorComponent.svelte +35 -0
  14. package/src/components/Header.svelte +28 -59
  15. package/src/components/ListPage.svelte +47 -0
  16. package/src/components/LiveIndicator.svelte +115 -0
  17. package/src/components/NavigateToResource.svelte +20 -0
  18. package/src/components/PageSkeleton.svelte +70 -0
  19. package/src/components/ShowPage.svelte +3 -3
  20. package/src/components/Sidebar.svelte +29 -10
  21. package/src/components/ThemedTitle.svelte +32 -0
  22. package/src/components/Toast.svelte +21 -11
  23. package/src/components/buttons/CloneButton.svelte +1 -1
  24. package/src/components/buttons/CreateButton.svelte +1 -1
  25. package/src/components/buttons/DeleteButton.svelte +3 -4
  26. package/src/components/buttons/EditButton.svelte +1 -1
  27. package/src/components/buttons/ImportButton.svelte +29 -7
  28. package/src/components/buttons/RefreshButton.svelte +5 -4
  29. package/src/components/fields/BooleanField.svelte +13 -0
  30. package/src/components/fields/DateField.svelte +6 -0
  31. package/src/components/fields/EmailField.svelte +15 -0
  32. package/src/components/fields/FileField.svelte +19 -0
  33. package/src/components/fields/ImageField.svelte +14 -0
  34. package/src/components/fields/MarkdownField.svelte +19 -0
  35. package/src/components/fields/NumberField.svelte +6 -0
  36. package/src/components/fields/TagField.svelte +17 -0
  37. package/src/components/fields/TextField.svelte +16 -0
  38. package/src/components/fields/UrlField.svelte +20 -0
  39. package/src/components/ui/avatar/avatar.svelte +2 -1
  40. package/src/index.ts +22 -0
  41. package/src/router-state.svelte.ts +16 -9
@@ -1,66 +1,35 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
- import type { Identity } from '@svadmin/core';
4
- import { toggleTheme, getResolvedTheme } from '@svadmin/core';
5
- import { t } from '@svadmin/core/i18n';
6
- import { Button } from './ui/button/index.js';
7
- import { Sun, Moon, Bell, Search } from 'lucide-svelte';
8
-
9
- interface Props {
10
- identity?: Identity | null;
11
- title?: string;
12
- showSearch?: boolean;
13
- actions?: Snippet;
14
- class?: string;
15
- }
2
+ import { Moon, Sun } from 'lucide-svelte';
3
+ import { Button } from './ui/button';
4
+ import { getTheme, toggleTheme } from '@svadmin/core';
16
5
 
17
6
  let {
18
- identity,
19
- title = '',
20
- showSearch = false,
21
- actions,
22
- class: className = '',
23
- }: Props = $props();
7
+ showThemeToggle = true,
8
+ children,
9
+ } = $props<{
10
+ showThemeToggle?: boolean;
11
+ children?: import('svelte').Snippet;
12
+ }>();
24
13
  </script>
25
14
 
26
- <header class="sticky top-0 z-20 flex h-14 items-center gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-[backdrop-filter]:bg-background/60 {className}">
27
- {#if title}
28
- <h2 class="text-lg font-semibold text-foreground">{title}</h2>
29
- {/if}
30
-
31
- <div class="flex-1"></div>
32
-
33
- {#if showSearch}
34
- <div class="relative hidden md:block">
35
- <Search class="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground" />
36
- <input
37
- type="search"
38
- placeholder={t('common.search')}
39
- class="h-8 w-64 rounded-lg border border-input bg-background pl-8 pr-3 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
40
- />
41
- </div>
42
- {/if}
43
-
44
- {#if actions}
45
- {@render actions()}
46
- {/if}
47
-
48
- <!-- Theme toggle -->
49
- <Button variant="ghost" size="icon-sm" onclick={toggleTheme} title={t('common.toggleTheme')}>
50
- {#if getResolvedTheme() === 'dark'}
51
- <Sun class="h-4 w-4" />
52
- {:else}
53
- <Moon class="h-4 w-4" />
15
+ <header class="sticky top-0 z-30 flex h-16 w-full shrink-0 items-center justify-between border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6">
16
+ <div class="flex items-center gap-4">
17
+ <!-- Mobile sidebar toggle can go here -->
18
+ </div>
19
+ <div class="ml-auto flex items-center gap-4">
20
+ {#if children}
21
+ {@render children()}
54
22
  {/if}
55
- </Button>
56
-
57
- <!-- User info -->
58
- {#if identity}
59
- <div class="flex items-center gap-2">
60
- <div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
61
- {identity.name?.charAt(0).toUpperCase() ?? 'U'}
62
- </div>
63
- <span class="hidden text-sm font-medium text-foreground md:inline">{identity.name}</span>
64
- </div>
65
- {/if}
23
+
24
+ {#if showThemeToggle}
25
+ <Button variant="ghost" size="icon" onclick={() => toggleTheme()} class="rounded-full">
26
+ {#if getTheme() === 'dark'}
27
+ <Moon class="h-5 w-5 transition-all" />
28
+ {:else}
29
+ <Sun class="h-5 w-5 transition-all" />
30
+ {/if}
31
+ <span class="sr-only">Toggle theme</span>
32
+ </Button>
33
+ {/if}
34
+ </div>
66
35
  </header>
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import { getResource } from '@svadmin/core';
3
+ import { navigate } from '@svadmin/core/router';
4
+ import { t } from '@svadmin/core/i18n';
5
+ import type { Snippet } from 'svelte';
6
+ import PageHeader from './PageHeader.svelte';
7
+ import AutoTable from './AutoTable.svelte';
8
+ import { Button } from './ui/button/index.js';
9
+ import { Plus } from 'lucide-svelte';
10
+
11
+ interface Props {
12
+ resourceName: string;
13
+ title?: string;
14
+ canCreate?: boolean;
15
+ headerActions?: Snippet;
16
+ class?: string;
17
+ }
18
+
19
+ let {
20
+ resourceName,
21
+ title,
22
+ canCreate,
23
+ headerActions,
24
+ class: className = '',
25
+ }: Props = $props();
26
+
27
+ const resource = getResource(resourceName);
28
+ const pageTitle = title ?? resource.label;
29
+ const showCreate = canCreate ?? resource.canCreate !== false;
30
+ </script>
31
+
32
+ <div class="space-y-6 {className}">
33
+ <PageHeader title={pageTitle}>
34
+ {#snippet actions()}
35
+ {#if showCreate}
36
+ <Button onclick={() => navigate(`/${resourceName}/create`)}>
37
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
38
+ </Button>
39
+ {/if}
40
+ {#if headerActions}
41
+ {@render headerActions()}
42
+ {/if}
43
+ {/snippet}
44
+ </PageHeader>
45
+
46
+ <AutoTable {resourceName} />
47
+ </div>
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ import { Wifi, WifiOff, Loader2 } from 'lucide-svelte';
3
+
4
+ let {
5
+ status = 'disconnected',
6
+ resource = '',
7
+ lastEvent = null,
8
+ showDetails = false,
9
+ } = $props<{
10
+ status: 'connecting' | 'connected' | 'disconnected';
11
+ resource?: string;
12
+ lastEvent?: { type: string; timestamp?: number } | null;
13
+ showDetails?: boolean;
14
+ }>();
15
+
16
+ const statusConfigs = {
17
+ connected: { color: 'var(--live-connected, #22c55e)', label: 'Live', icon: Wifi },
18
+ connecting: { color: 'var(--live-connecting, #f59e0b)', label: 'Connecting...', icon: Loader2 as typeof Wifi },
19
+ disconnected: { color: 'var(--live-disconnected, #ef4444)', label: 'Offline', icon: WifiOff },
20
+ } as const;
21
+ type StatusKey = 'connected' | 'connecting' | 'disconnected';
22
+ const statusConfig = $derived(statusConfigs[status as StatusKey]);
23
+
24
+ const timeSinceEvent = $derived(
25
+ lastEvent?.timestamp
26
+ ? Math.floor((Date.now() - lastEvent.timestamp) / 1000)
27
+ : null
28
+ );
29
+ </script>
30
+
31
+ <div class="live-indicator" title={statusConfig.label}>
32
+ <span class="live-dot" style="--dot-color: {statusConfig.color}">
33
+ {#if status === 'connected'}
34
+ <span class="live-pulse"></span>
35
+ {/if}
36
+ </span>
37
+ <span class="live-label" style="color: {statusConfig.color}">
38
+ {statusConfig.label}
39
+ </span>
40
+
41
+ {#if showDetails && resource}
42
+ <span class="live-resource">{resource}</span>
43
+ {/if}
44
+
45
+ {#if showDetails && lastEvent}
46
+ <span class="live-event">
47
+ {lastEvent.type}
48
+ {#if timeSinceEvent !== null && timeSinceEvent < 60}
49
+ <span class="live-time">{timeSinceEvent}s ago</span>
50
+ {/if}
51
+ </span>
52
+ {/if}
53
+ </div>
54
+
55
+ <style>
56
+ .live-indicator {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 0.375rem;
60
+ font-size: 0.75rem;
61
+ font-weight: 500;
62
+ padding: 0.25rem 0.5rem;
63
+ border-radius: 9999px;
64
+ background: hsl(var(--muted) / 0.3);
65
+ user-select: none;
66
+ }
67
+
68
+ .live-dot {
69
+ position: relative;
70
+ width: 8px;
71
+ height: 8px;
72
+ border-radius: 50%;
73
+ background: var(--dot-color);
74
+ flex-shrink: 0;
75
+ }
76
+
77
+ .live-pulse {
78
+ position: absolute;
79
+ inset: -3px;
80
+ border-radius: 50%;
81
+ background: var(--dot-color);
82
+ opacity: 0.4;
83
+ animation: live-pulse 2s ease-in-out infinite;
84
+ }
85
+
86
+ @keyframes live-pulse {
87
+ 0%, 100% { transform: scale(1); opacity: 0.4; }
88
+ 50% { transform: scale(1.8); opacity: 0; }
89
+ }
90
+
91
+ .live-label {
92
+ font-size: 0.6875rem;
93
+ font-weight: 600;
94
+ text-transform: uppercase;
95
+ letter-spacing: 0.05em;
96
+ }
97
+
98
+ .live-resource {
99
+ font-size: 0.625rem;
100
+ color: hsl(var(--muted-foreground));
101
+ font-family: monospace;
102
+ }
103
+
104
+ .live-event {
105
+ font-size: 0.625rem;
106
+ color: hsl(var(--muted-foreground));
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 0.25rem;
110
+ }
111
+
112
+ .live-time {
113
+ opacity: 0.6;
114
+ }
115
+ </style>
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import { useMenu } from '@svadmin/core';
3
+ import { navigate } from '@svadmin/core/router';
4
+
5
+ const { menuItems } = useMenu();
6
+
7
+ $effect(() => {
8
+ // Automatically navigate to the first defined resource
9
+ if (menuItems.length > 0) {
10
+ navigate(menuItems[0].route);
11
+ }
12
+ });
13
+ </script>
14
+
15
+ <div class="flex h-screen w-screen items-center justify-center p-4 text-muted-foreground">
16
+ <div class="text-center">
17
+ <div class="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"></div>
18
+ <p>Navigating to default resource...</p>
19
+ </div>
20
+ </div>
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import { Skeleton } from './ui/skeleton';
3
+
4
+ let {
5
+ type = 'list',
6
+ rows = 5,
7
+ } = $props<{
8
+ type?: 'list' | 'show' | 'form';
9
+ rows?: number;
10
+ }>();
11
+ </script>
12
+
13
+ <div class="w-full space-y-6">
14
+ <!-- Header Skeleton -->
15
+ <div class="flex items-center justify-between">
16
+ <div class="space-y-2">
17
+ <Skeleton class="h-8 w-[200px]" />
18
+ <Skeleton class="h-4 w-[300px]" />
19
+ </div>
20
+ {#if type === 'list'}
21
+ <Skeleton class="h-10 w-[120px]" />
22
+ {/if}
23
+ </div>
24
+
25
+ <div class="rounded-md border p-4 space-y-4">
26
+ {#if type === 'list'}
27
+ <!-- Table Header -->
28
+ <div class="flex gap-4 border-b pb-4">
29
+ {#each Array(4) as _}
30
+ <Skeleton class="h-6 flex-1" />
31
+ {/each}
32
+ </div>
33
+ <!-- Table Rows -->
34
+ <div class="space-y-4">
35
+ {#each Array(rows) as _}
36
+ <div class="flex gap-4">
37
+ <Skeleton class="h-6 flex-1" />
38
+ <Skeleton class="h-6 flex-1" />
39
+ <Skeleton class="h-6 flex-1" />
40
+ <Skeleton class="h-6 flex-1" />
41
+ </div>
42
+ {/each}
43
+ </div>
44
+ {:else if type === 'show'}
45
+ <!-- Key-Value pairs -->
46
+ <div class="space-y-6">
47
+ {#each Array(rows) as _}
48
+ <div class="space-y-2">
49
+ <Skeleton class="h-4 w-[100px]" />
50
+ <Skeleton class="h-6 w-[250px]" />
51
+ </div>
52
+ {/each}
53
+ </div>
54
+ {:else if type === 'form'}
55
+ <!-- Form Inputs -->
56
+ <div class="space-y-6">
57
+ {#each Array(rows) as _}
58
+ <div class="space-y-2">
59
+ <Skeleton class="h-4 w-[100px]" />
60
+ <Skeleton class="h-10 w-full max-w-md" />
61
+ </div>
62
+ {/each}
63
+ <div class="pt-4 flex gap-2">
64
+ <Skeleton class="h-10 w-[100px]" />
65
+ <Skeleton class="h-10 w-[100px]" />
66
+ </div>
67
+ </div>
68
+ {/if}
69
+ </div>
70
+ </div>
@@ -43,15 +43,15 @@
43
43
  {/if}
44
44
  </div>
45
45
 
46
- {#if query.isLoading}
46
+ {#if query.query.isLoading}
47
47
  <div class="flex h-64 items-center justify-center">
48
48
  <Loader2 class="h-6 w-6 animate-spin text-primary" />
49
49
  </div>
50
- {:else if query.data}
50
+ {:else if query.query.data?.data}
51
51
  <Card.Root>
52
52
  <Card.Content class="divide-y divide-border p-0">
53
53
  {#each showFields as field}
54
- {@const value = (query.data as Record<string, unknown>)[field.key]}
54
+ {@const value = (query.query.data!.data as Record<string, unknown>)[field.key]}
55
55
  <div class="flex px-6 py-4">
56
56
  <div class="w-1/3 text-sm font-medium text-muted-foreground">{field.label}</div>
57
57
  <div class="w-2/3 text-sm text-foreground">
@@ -3,7 +3,7 @@
3
3
  import type { Identity } from '@svadmin/core';
4
4
  import { currentPath, navigate } from '@svadmin/core/router';
5
5
  import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
6
- import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
6
+ import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme, canAccessAsync } from '@svadmin/core';
7
7
  import { Button } from './ui/button/index.js';
8
8
  import * as Tooltip from './ui/tooltip/index.js';
9
9
  import { Separator } from './ui/separator/index.js';
@@ -36,15 +36,34 @@
36
36
  Icon: typeof LayoutDashboard;
37
37
  }
38
38
 
39
- // Use $derived so navItems rebuild when locale changes
40
- const navItems: NavItem[] = $derived([
41
- { path: '/', label: t('common.home'), Icon: LayoutDashboard },
42
- ...resources.map(r => ({
43
- path: `/${r.name}`,
44
- label: r.label,
45
- Icon: iconMap[r.name] ?? Settings,
46
- })),
47
- ]);
39
+ // Use effect to build nav items, taking access control into account
40
+ let navItems = $state<NavItem[]>([]);
41
+
42
+ $effect(() => {
43
+ // Re-run when locale or resources change
44
+ const localeVal = getLocale();
45
+
46
+ Promise.all(resources.map(async (r) => {
47
+ try {
48
+ const { can } = await canAccessAsync(r.name, 'list');
49
+ return { r, can };
50
+ } catch {
51
+ return { r, can: false };
52
+ }
53
+ })).then(results => {
54
+ const items: NavItem[] = [{ path: '/', label: t('common.home'), Icon: LayoutDashboard }];
55
+ for (const { r, can } of results) {
56
+ if (can) {
57
+ items.push({
58
+ path: `/${r.name}`,
59
+ label: r.label,
60
+ Icon: iconMap[r.name] ?? Settings,
61
+ });
62
+ }
63
+ }
64
+ navItems = items;
65
+ });
66
+ });
48
67
 
49
68
  /** Toggle between available locales */
50
69
  function toggleLocale() {
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import { getAdminOptions } from '@svadmin/core';
3
+
4
+ let {
5
+ collapsed = false,
6
+ icon,
7
+ text,
8
+ wrapperStyles = '',
9
+ } = $props<{
10
+ collapsed?: boolean;
11
+ icon?: string;
12
+ text?: string;
13
+ wrapperStyles?: string;
14
+ }>();
15
+
16
+ const adminOptions = getAdminOptions();
17
+ const titleText = $derived(text ?? adminOptions.title?.text ?? 'Admin');
18
+ const titleIcon = $derived(icon ?? adminOptions.title?.icon);
19
+ </script>
20
+
21
+ <div class="flex items-center gap-2 {wrapperStyles}">
22
+ {#if titleIcon}
23
+ <img src={titleIcon} alt="" class="h-6 w-6 shrink-0" />
24
+ {:else}
25
+ <div class="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
26
+ {titleText.charAt(0).toUpperCase()}
27
+ </div>
28
+ {/if}
29
+ {#if !collapsed}
30
+ <span class="text-lg font-semibold text-foreground truncate">{titleText}</span>
31
+ {/if}
32
+ </div>
@@ -2,6 +2,7 @@
2
2
  import { getToasts, removeToast } from '@svadmin/core/toast';
3
3
  import { Button } from './ui/button/index.js';
4
4
  import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-svelte';
5
+ import UndoableNotification from './UndoableNotification.svelte';
5
6
 
6
7
  const iconMap = {
7
8
  success: CheckCircle,
@@ -22,17 +23,26 @@
22
23
  {#if toasts.length > 0}
23
24
  <div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
24
25
  {#each toasts as t (t.id)}
25
- {@const Icon = iconMap[t.type]}
26
- <div
27
- class="flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg animate-slide-in {colorMap[t.type]}"
28
- role="alert"
29
- >
30
- <Icon class="h-5 w-5 flex-shrink-0 mt-0.5" />
31
- <p class="flex-1 text-sm font-medium">{t.message}</p>
32
- <Button variant="ghost" size="icon-xs" onclick={() => removeToast(t.id)} class="flex-shrink-0 -mr-2 -mt-1">
33
- <X class="h-4 w-4" />
34
- </Button>
35
- </div>
26
+ {#if t.type === 'undoable'}
27
+ <UndoableNotification
28
+ message={t.message}
29
+ duration={t.duration}
30
+ onUndo={() => { t.onUndo?.(); removeToast(t.id); }}
31
+ onTimeout={() => { t.onTimeout?.(); removeToast(t.id); }}
32
+ />
33
+ {:else}
34
+ {@const Icon = iconMap[t.type]}
35
+ <div
36
+ class="flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg animate-slide-in {colorMap[t.type]}"
37
+ role="alert"
38
+ >
39
+ <Icon class="h-5 w-5 flex-shrink-0 mt-0.5" />
40
+ <p class="flex-1 text-sm font-medium">{t.message}</p>
41
+ <Button variant="ghost" size="icon-xs" onclick={() => removeToast(t.id)} class="flex-shrink-0 -mr-2 -mt-1">
42
+ <X class="h-4 w-4" />
43
+ </Button>
44
+ </div>
45
+ {/if}
36
46
  {/each}
37
47
  </div>
38
48
  {/if}
@@ -12,7 +12,7 @@
12
12
  }>();
13
13
 
14
14
  const nav = useNavigation();
15
- const can = accessControl?.enabled ? useCan({ resource, action: 'create' }) : null;
15
+ const can = accessControl?.enabled ? useCan(resource, 'create') : null;
16
16
  const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
17
17
  </script>
18
18
 
@@ -11,7 +11,7 @@
11
11
  }>();
12
12
 
13
13
  const nav = useNavigation();
14
- const can = accessControl?.enabled ? useCan({ resource, action: 'create' }) : null;
14
+ const can = accessControl?.enabled ? useCan(resource, 'create') : null;
15
15
  const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
16
16
  </script>
17
17
 
@@ -16,8 +16,8 @@
16
16
  class?: string;
17
17
  }>();
18
18
 
19
- const deleteMut = useDelete(resource, { mutationMode: undoable ? 'undoable' : 'pessimistic' });
20
- const can = accessControl?.enabled ? useCan({ resource, action: 'delete' }) : null;
19
+ const deleteMut = useDelete({ resource, mutationMode: undoable ? 'undoable' : 'pessimistic' });
20
+ const can = accessControl?.enabled ? useCan(resource, 'delete') : null;
21
21
  const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
22
22
  let confirming = $state(false);
23
23
 
@@ -27,8 +27,7 @@
27
27
  return;
28
28
  }
29
29
  confirming = false;
30
- // @ts-expect-error $ rune prefix
31
- await $deleteMut.mutateAsync(recordItemId);
30
+ await deleteMut.mutation.mutateAsync({ id: recordItemId, resource });
32
31
  onSuccess?.();
33
32
  }
34
33
 
@@ -12,7 +12,7 @@
12
12
  }>();
13
13
 
14
14
  const nav = useNavigation();
15
- const can = accessControl?.enabled ? useCan({ resource, action: 'edit' }) : null;
15
+ const can = accessControl?.enabled ? useCan(resource, 'edit') : null;
16
16
  const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
17
17
  </script>
18
18
 
@@ -1,26 +1,48 @@
1
1
  <script lang="ts">
2
- import { useImport, t } from '@svadmin/core';
2
+ import { useImport, t, type UseImportOptions } from '@svadmin/core';
3
3
  import { Button } from '../ui/button/index.js';
4
4
  import { Upload } from 'lucide-svelte';
5
5
 
6
- let { resource, hideText = false, onComplete, class: className = '' } = $props<{
6
+ let { resource, hideText = false, onFinish, class: className = '' } = $props<{
7
7
  resource: string;
8
8
  hideText?: boolean;
9
- onComplete?: (result: { success: number; failed: number }) => void;
9
+ onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
10
10
  class?: string;
11
11
  }>();
12
12
 
13
- const { triggerImport, isLoading } = useImport({
14
- resource,
15
- onComplete: (result) => onComplete?.(result),
13
+ let fileInput: HTMLInputElement | undefined = $state();
14
+
15
+ const importHook = useImport({
16
+ get resource() { return resource; },
17
+ onFinish: (result) => onFinish?.(result),
16
18
  });
19
+
20
+ function triggerImport() {
21
+ fileInput?.click();
22
+ }
23
+
24
+ function handleFileChange(e: Event) {
25
+ const input = e.target as HTMLInputElement;
26
+ const file = input.files?.[0];
27
+ if (file) {
28
+ importHook.handleChange({ file });
29
+ input.value = ''; // reset for re-import
30
+ }
31
+ }
17
32
  </script>
18
33
 
34
+ <input
35
+ bind:this={fileInput}
36
+ type="file"
37
+ accept=".csv"
38
+ style="display:none"
39
+ onchange={handleFileChange}
40
+ />
19
41
  <Button
20
42
  variant="outline"
21
43
  size={hideText ? 'icon' : 'sm'}
22
44
  class={className}
23
- disabled={isLoading}
45
+ disabled={importHook.isLoading}
24
46
  onclick={triggerImport}
25
47
  >
26
48
  <Upload class="h-4 w-4" />
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { useQueryClient } from '@tanstack/svelte-query';
2
+ import { useInvalidate, t } from '@svadmin/core';
3
3
  import { Button } from '../ui/button/index.js';
4
4
  import { RefreshCw } from 'lucide-svelte';
5
5
 
@@ -9,12 +9,12 @@
9
9
  class?: string;
10
10
  }>();
11
11
 
12
- const queryClient = useQueryClient();
12
+ const invalidate = useInvalidate();
13
13
  let spinning = $state(false);
14
14
 
15
15
  function refresh() {
16
16
  spinning = true;
17
- queryClient.invalidateQueries({ queryKey: [resource] });
17
+ invalidate({ resource, invalidates: ['list', 'many'] });
18
18
  setTimeout(() => { spinning = false; }, 600);
19
19
  }
20
20
  </script>
@@ -26,5 +26,6 @@
26
26
  onclick={refresh}
27
27
  >
28
28
  <RefreshCw class="h-4 w-4 {spinning ? 'animate-spin' : ''}" />
29
- {#if !hideText}<span class="ml-1">Refresh</span>{/if}
29
+ {#if !hideText}<span class="ml-1">{t('common.retry')}</span>{/if}
30
30
  </Button>
31
+
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ interface Props { value: boolean | null | undefined; trueIcon?: string; falseIcon?: string }
3
+ let { value, trueIcon = '✓', falseIcon = '✗' }: Props = $props();
4
+ </script>
5
+ <span class="field-boolean" class:is-true={!!value} class:is-false={!value}>
6
+ {value ? trueIcon : falseIcon}
7
+ </span>
8
+
9
+ <style>
10
+ .field-boolean { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; font-size: 14px; }
11
+ .is-true { background: var(--color-success-bg, #dcfce7); color: var(--color-success, #16a34a); }
12
+ .is-false { background: var(--color-error-bg, #fef2f2); color: var(--color-error, #dc2626); }
13
+ </style>
@@ -0,0 +1,6 @@
1
+ <script lang="ts">
2
+ interface Props { value: string | number | Date | null | undefined; locale?: string; options?: Intl.DateTimeFormatOptions }
3
+ let { value, locale = 'en-US', options = { dateStyle: 'medium' } }: Props = $props();
4
+ const formatted = $derived(value != null ? new Intl.DateTimeFormat(locale, options).format(new Date(value as string | number | Date)) : '—');
5
+ </script>
6
+ <span class="field-date">{formatted}</span>