@svadmin/ui 0.0.3 → 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 (54) hide show
  1. package/package.json +3 -2
  2. package/src/components/AdminApp.svelte +31 -21
  3. package/src/components/Authenticated.svelte +57 -0
  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/ConfigErrorScreen.svelte +224 -0
  10. package/src/components/ConfirmDialog.svelte +1 -1
  11. package/src/components/CreatePage.svelte +42 -0
  12. package/src/components/DevTools.svelte +83 -34
  13. package/src/components/EditPage.svelte +52 -0
  14. package/src/components/EmptyState.svelte +17 -31
  15. package/src/components/ErrorComponent.svelte +35 -0
  16. package/src/components/ForgotPasswordPage.svelte +12 -22
  17. package/src/components/Header.svelte +28 -59
  18. package/src/components/InferencerPanel.svelte +166 -0
  19. package/src/components/ListPage.svelte +47 -0
  20. package/src/components/LiveIndicator.svelte +115 -0
  21. package/src/components/LoginPage.svelte +13 -22
  22. package/src/components/NavigateToResource.svelte +20 -0
  23. package/src/components/PageSkeleton.svelte +70 -0
  24. package/src/components/RegisterPage.svelte +12 -23
  25. package/src/components/ShowPage.svelte +3 -3
  26. package/src/components/Sidebar.svelte +29 -10
  27. package/src/components/StatsCard.svelte +23 -2
  28. package/src/components/ThemedTitle.svelte +32 -0
  29. package/src/components/Toast.svelte +21 -11
  30. package/src/components/UpdatePasswordPage.svelte +217 -0
  31. package/src/components/buttons/CloneButton.svelte +30 -0
  32. package/src/components/buttons/CreateButton.svelte +29 -0
  33. package/src/components/buttons/DeleteButton.svelte +59 -0
  34. package/src/components/buttons/EditButton.svelte +30 -0
  35. package/src/components/buttons/ExportButton.svelte +24 -0
  36. package/src/components/buttons/ImportButton.svelte +50 -0
  37. package/src/components/buttons/ListButton.svelte +23 -0
  38. package/src/components/buttons/RefreshButton.svelte +31 -0
  39. package/src/components/buttons/SaveButton.svelte +27 -0
  40. package/src/components/buttons/ShowButton.svelte +24 -0
  41. package/src/components/buttons/index.ts +10 -0
  42. package/src/components/fields/BooleanField.svelte +13 -0
  43. package/src/components/fields/DateField.svelte +6 -0
  44. package/src/components/fields/EmailField.svelte +15 -0
  45. package/src/components/fields/FileField.svelte +19 -0
  46. package/src/components/fields/ImageField.svelte +14 -0
  47. package/src/components/fields/MarkdownField.svelte +19 -0
  48. package/src/components/fields/NumberField.svelte +6 -0
  49. package/src/components/fields/TagField.svelte +17 -0
  50. package/src/components/fields/TextField.svelte +16 -0
  51. package/src/components/fields/UrlField.svelte +20 -0
  52. package/src/components/ui/avatar/avatar.svelte +2 -1
  53. package/src/index.ts +32 -0
  54. package/src/router-state.svelte.ts +33 -7
@@ -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() {
@@ -1,12 +1,31 @@
1
1
  <script lang="ts">
2
2
  import { Loader2 } from 'lucide-svelte';
3
3
 
4
+ type ColorVariant = 'primary' | 'success' | 'warning' | 'danger' | 'info';
5
+ type StyleVariant = 'default' | 'outline' | 'filled';
6
+
7
+ const colorMap: Record<ColorVariant, string> = {
8
+ primary: 'bg-primary/10 text-primary',
9
+ success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
10
+ warning: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
11
+ danger: 'bg-red-500/10 text-red-600 dark:text-red-400',
12
+ info: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
13
+ };
14
+
15
+ const variantMap: Record<StyleVariant, string> = {
16
+ default: 'border border-border bg-card shadow-sm',
17
+ outline: 'border-2 border-border bg-transparent',
18
+ filled: 'border-0 bg-muted/50 shadow-sm',
19
+ };
20
+
4
21
  interface Props {
5
22
  label: string;
6
23
  value: string | number;
7
24
  icon?: typeof Loader2;
8
25
  trend?: { value: number; label?: string };
9
26
  loading?: boolean;
27
+ color?: ColorVariant;
28
+ variant?: StyleVariant;
10
29
  class?: string;
11
30
  }
12
31
 
@@ -16,13 +35,15 @@
16
35
  icon: Icon,
17
36
  trend,
18
37
  loading = false,
38
+ color = 'primary',
39
+ variant = 'default',
19
40
  class: className = '',
20
41
  }: Props = $props();
21
42
  </script>
22
43
 
23
- <div class="flex items-center gap-4 rounded-xl border border-border bg-card p-5 shadow-sm {className}">
44
+ <div class="flex items-center gap-4 rounded-xl p-5 {variantMap[variant]} {className}">
24
45
  {#if Icon}
25
- <div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
46
+ <div class="flex h-12 w-12 items-center justify-center rounded-xl {colorMap[color]}">
26
47
  <Icon class="h-6 w-6" />
27
48
  </div>
28
49
  {/if}
@@ -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}
@@ -0,0 +1,217 @@
1
+ <script lang="ts">
2
+ import { useUpdatePassword } from '@svadmin/core';
3
+ import { t } from '@svadmin/core/i18n';
4
+ import { navigate } from '@svadmin/core/router';
5
+ import { Button } from './ui/button/index.js';
6
+ import { Input } from './ui/input/index.js';
7
+ import * as Card from './ui/card/index.js';
8
+ import { Lock, Eye, EyeOff, ShieldCheck } from 'lucide-svelte';
9
+
10
+ let { title = 'Admin' } = $props<{
11
+ title?: string;
12
+ }>();
13
+
14
+ const updatePw = useUpdatePassword();
15
+
16
+ let password = $state('');
17
+ let confirmPassword = $state('');
18
+ let showPassword = $state(false);
19
+ let error = $state('');
20
+
21
+ async function handleSubmit(e: SubmitEvent) {
22
+ e.preventDefault();
23
+ error = '';
24
+
25
+ if (!password) { error = t('auth.passwordRequired'); return; }
26
+ if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
27
+
28
+ const result = await updatePw.mutate({ password, confirmPassword });
29
+ if (!result.success) {
30
+ error = result.error?.message ?? t('common.operationFailed');
31
+ }
32
+ }
33
+ </script>
34
+
35
+ <div class="auth-page">
36
+ <div class="auth-container">
37
+ <Card.Card class="auth-card">
38
+ <Card.CardHeader class="auth-header">
39
+ <div class="auth-icon">
40
+ <ShieldCheck class="h-6 w-6" />
41
+ </div>
42
+ <Card.CardTitle class="text-2xl font-bold">{t('auth.resetPassword')}</Card.CardTitle>
43
+ <p class="text-sm text-muted-foreground">Enter your new password below.</p>
44
+ </Card.CardHeader>
45
+ <Card.CardContent>
46
+ <form onsubmit={handleSubmit} class="space-y-4">
47
+ {#if error}
48
+ <div class="error-alert">
49
+ <p>{error}</p>
50
+ </div>
51
+ {/if}
52
+
53
+ <div class="space-y-2">
54
+ <label for="new-password" class="text-sm font-medium text-foreground">{t('auth.password')}</label>
55
+ <div class="input-with-icon">
56
+ <Lock class="input-icon h-4 w-4" />
57
+ <Input
58
+ id="new-password"
59
+ type={showPassword ? 'text' : 'password'}
60
+ placeholder="••••••••"
61
+ bind:value={password}
62
+ class="pl-9 pr-9"
63
+ autocomplete="new-password"
64
+ />
65
+ <button
66
+ type="button"
67
+ class="password-toggle"
68
+ onclick={() => showPassword = !showPassword}
69
+ tabindex={-1}
70
+ >
71
+ {#if showPassword}
72
+ <EyeOff class="h-4 w-4" />
73
+ {:else}
74
+ <Eye class="h-4 w-4" />
75
+ {/if}
76
+ </button>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="space-y-2">
81
+ <label for="confirm-password" class="text-sm font-medium text-foreground">{t('auth.confirmPassword')}</label>
82
+ <div class="input-with-icon">
83
+ <Lock class="input-icon h-4 w-4" />
84
+ <Input
85
+ id="confirm-password"
86
+ type={showPassword ? 'text' : 'password'}
87
+ placeholder="••••••••"
88
+ bind:value={confirmPassword}
89
+ class="pl-9"
90
+ autocomplete="new-password"
91
+ />
92
+ </div>
93
+ </div>
94
+
95
+ <Button type="submit" class="w-full" disabled={updatePw.isLoading}>
96
+ {#if updatePw.isLoading}
97
+ <span class="loading-spinner"></span>
98
+ {/if}
99
+ {t('auth.resetPassword')}
100
+ </Button>
101
+
102
+ <div class="auth-link">
103
+ <button
104
+ type="button"
105
+ class="text-sm text-primary hover:underline font-medium"
106
+ onclick={() => navigate('/login')}
107
+ >{t('auth.backToLogin')}</button>
108
+ </div>
109
+ </form>
110
+ </Card.CardContent>
111
+ </Card.Card>
112
+
113
+ <p class="text-xs text-muted-foreground mt-4 text-center opacity-60">
114
+ Powered by {title}
115
+ </p>
116
+ </div>
117
+ </div>
118
+
119
+ <style>
120
+ .auth-page {
121
+ min-height: 100vh;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
126
+ padding: 1rem;
127
+ }
128
+
129
+ .auth-container {
130
+ width: 100%;
131
+ max-width: 420px;
132
+ }
133
+
134
+ :global(.auth-card) {
135
+ backdrop-filter: blur(20px);
136
+ border: 1px solid hsl(var(--border) / 0.5);
137
+ box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
138
+ }
139
+
140
+ :global(.auth-header) {
141
+ text-align: center;
142
+ padding-bottom: 0.5rem;
143
+ }
144
+
145
+ .auth-icon {
146
+ display: inline-flex;
147
+ align-items: center;
148
+ justify-content: center;
149
+ width: 48px;
150
+ height: 48px;
151
+ border-radius: 12px;
152
+ background: hsl(var(--primary) / 0.1);
153
+ color: hsl(var(--primary));
154
+ margin: 0 auto 0.75rem;
155
+ }
156
+
157
+ .input-with-icon {
158
+ position: relative;
159
+ }
160
+
161
+ :global(.input-icon) {
162
+ position: absolute;
163
+ left: 0.75rem;
164
+ top: 50%;
165
+ transform: translateY(-50%);
166
+ color: hsl(var(--muted-foreground));
167
+ pointer-events: none;
168
+ z-index: 1;
169
+ }
170
+
171
+ .password-toggle {
172
+ position: absolute;
173
+ right: 0.75rem;
174
+ top: 50%;
175
+ transform: translateY(-50%);
176
+ color: hsl(var(--muted-foreground));
177
+ background: none;
178
+ border: none;
179
+ cursor: pointer;
180
+ padding: 2px;
181
+ z-index: 1;
182
+ }
183
+ .password-toggle:hover {
184
+ color: hsl(var(--foreground));
185
+ }
186
+
187
+ .error-alert {
188
+ padding: 0.75rem;
189
+ border-radius: 0.5rem;
190
+ background: hsl(var(--destructive) / 0.1);
191
+ border: 1px solid hsl(var(--destructive) / 0.3);
192
+ color: hsl(var(--destructive));
193
+ font-size: 0.875rem;
194
+ }
195
+
196
+ .loading-spinner {
197
+ display: inline-block;
198
+ width: 16px;
199
+ height: 16px;
200
+ border: 2px solid transparent;
201
+ border-top-color: currentColor;
202
+ border-radius: 50%;
203
+ animation: spin 0.6s linear infinite;
204
+ margin-right: 0.5rem;
205
+ }
206
+
207
+ @keyframes spin {
208
+ to { transform: rotate(360deg); }
209
+ }
210
+
211
+ .auth-link {
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ margin-top: 0.5rem;
216
+ }
217
+ </style>
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import { useNavigation, useCan, t } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { Copy } from 'lucide-svelte';
5
+
6
+ let { resource, recordItemId, hideText = false, accessControl, class: className = '' } = $props<{
7
+ resource: string;
8
+ recordItemId: string | number;
9
+ hideText?: boolean;
10
+ accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
11
+ class?: string;
12
+ }>();
13
+
14
+ const nav = useNavigation();
15
+ const can = accessControl?.enabled ? useCan(resource, 'create') : null;
16
+ const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
17
+ </script>
18
+
19
+ {#if !hidden}
20
+ <Button
21
+ variant="outline"
22
+ size={hideText ? 'icon' : 'sm'}
23
+ class={className}
24
+ disabled={can ? !can.allowed : false}
25
+ onclick={() => nav.clone(resource, recordItemId)}
26
+ >
27
+ <Copy class="h-4 w-4" />
28
+ {#if !hideText}<span class="ml-1">{t('common.clone')}</span>{/if}
29
+ </Button>
30
+ {/if}
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import { useNavigation, useCan, t } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { Plus } from 'lucide-svelte';
5
+
6
+ let { resource, hideText = false, accessControl, class: className = '' } = $props<{
7
+ resource: string;
8
+ hideText?: boolean;
9
+ accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
10
+ class?: string;
11
+ }>();
12
+
13
+ const nav = useNavigation();
14
+ const can = accessControl?.enabled ? useCan(resource, 'create') : null;
15
+ const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
16
+ </script>
17
+
18
+ {#if !hidden}
19
+ <Button
20
+ variant="default"
21
+ size={hideText ? 'icon' : 'default'}
22
+ class={className}
23
+ disabled={can ? !can.allowed : false}
24
+ onclick={() => nav.create(resource)}
25
+ >
26
+ <Plus class="h-4 w-4" />
27
+ {#if !hideText}<span class="ml-1">{t('common.create')}</span>{/if}
28
+ </Button>
29
+ {/if}
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { useDelete, useCan, t } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { Trash2 } from 'lucide-svelte';
5
+
6
+ let {
7
+ resource, recordItemId, hideText = false,
8
+ accessControl, onSuccess, undoable = false, class: className = '',
9
+ } = $props<{
10
+ resource: string;
11
+ recordItemId: string | number;
12
+ hideText?: boolean;
13
+ accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
14
+ onSuccess?: () => void;
15
+ undoable?: boolean;
16
+ class?: string;
17
+ }>();
18
+
19
+ const deleteMut = useDelete({ resource, mutationMode: undoable ? 'undoable' : 'pessimistic' });
20
+ const can = accessControl?.enabled ? useCan(resource, 'delete') : null;
21
+ const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
22
+ let confirming = $state(false);
23
+
24
+ async function handleDelete() {
25
+ if (!confirming) {
26
+ confirming = true;
27
+ return;
28
+ }
29
+ confirming = false;
30
+ await deleteMut.mutation.mutateAsync({ id: recordItemId, resource });
31
+ onSuccess?.();
32
+ }
33
+
34
+ function cancel() { confirming = false; }
35
+ </script>
36
+
37
+ {#if !hidden}
38
+ {#if confirming}
39
+ <div class="inline-flex items-center gap-1">
40
+ <Button variant="destructive" size="sm" onclick={handleDelete}>
41
+ {t('common.confirm')}
42
+ </Button>
43
+ <Button variant="ghost" size="sm" onclick={cancel}>
44
+ {t('common.cancel')}
45
+ </Button>
46
+ </div>
47
+ {:else}
48
+ <Button
49
+ variant="ghost"
50
+ size={hideText ? 'icon' : 'sm'}
51
+ class="text-destructive hover:text-destructive {className}"
52
+ disabled={can ? !can.allowed : false}
53
+ onclick={handleDelete}
54
+ >
55
+ <Trash2 class="h-4 w-4" />
56
+ {#if !hideText}<span class="ml-1">{t('common.delete')}</span>{/if}
57
+ </Button>
58
+ {/if}
59
+ {/if}
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import { useNavigation, useCan, t } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { Pencil } from 'lucide-svelte';
5
+
6
+ let { resource, recordItemId, hideText = false, accessControl, class: className = '' } = $props<{
7
+ resource: string;
8
+ recordItemId: string | number;
9
+ hideText?: boolean;
10
+ accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
11
+ class?: string;
12
+ }>();
13
+
14
+ const nav = useNavigation();
15
+ const can = accessControl?.enabled ? useCan(resource, 'edit') : null;
16
+ const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
17
+ </script>
18
+
19
+ {#if !hidden}
20
+ <Button
21
+ variant="outline"
22
+ size={hideText ? 'icon' : 'sm'}
23
+ class={className}
24
+ disabled={can ? !can.allowed : false}
25
+ onclick={() => nav.edit(resource, recordItemId)}
26
+ >
27
+ <Pencil class="h-4 w-4" />
28
+ {#if !hideText}<span class="ml-1">{t('common.edit')}</span>{/if}
29
+ </Button>
30
+ {/if}
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import { useExport, t } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { Download } from 'lucide-svelte';
5
+
6
+ let { resource, hideText = false, class: className = '' } = $props<{
7
+ resource: string;
8
+ hideText?: boolean;
9
+ class?: string;
10
+ }>();
11
+
12
+ const { triggerExport, isLoading } = useExport({ resource });
13
+ </script>
14
+
15
+ <Button
16
+ variant="outline"
17
+ size={hideText ? 'icon' : 'sm'}
18
+ class={className}
19
+ disabled={isLoading}
20
+ onclick={triggerExport}
21
+ >
22
+ <Download class="h-4 w-4" />
23
+ {#if !hideText}<span class="ml-1">{t('common.export')}</span>{/if}
24
+ </Button>
@@ -0,0 +1,50 @@
1
+ <script lang="ts">
2
+ import { useImport, t, type UseImportOptions } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { Upload } from 'lucide-svelte';
5
+
6
+ let { resource, hideText = false, onFinish, class: className = '' } = $props<{
7
+ resource: string;
8
+ hideText?: boolean;
9
+ onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
10
+ class?: string;
11
+ }>();
12
+
13
+ let fileInput: HTMLInputElement | undefined = $state();
14
+
15
+ const importHook = useImport({
16
+ get resource() { return resource; },
17
+ onFinish: (result) => onFinish?.(result),
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
+ }
32
+ </script>
33
+
34
+ <input
35
+ bind:this={fileInput}
36
+ type="file"
37
+ accept=".csv"
38
+ style="display:none"
39
+ onchange={handleFileChange}
40
+ />
41
+ <Button
42
+ variant="outline"
43
+ size={hideText ? 'icon' : 'sm'}
44
+ class={className}
45
+ disabled={importHook.isLoading}
46
+ onclick={triggerImport}
47
+ >
48
+ <Upload class="h-4 w-4" />
49
+ {#if !hideText}<span class="ml-1">{t('common.import')}</span>{/if}
50
+ </Button>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { useNavigation, t } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { List } from 'lucide-svelte';
5
+
6
+ let { resource, hideText = false, class: className = '' } = $props<{
7
+ resource: string;
8
+ hideText?: boolean;
9
+ class?: string;
10
+ }>();
11
+
12
+ const nav = useNavigation();
13
+ </script>
14
+
15
+ <Button
16
+ variant="outline"
17
+ size={hideText ? 'icon' : 'sm'}
18
+ class={className}
19
+ onclick={() => nav.list(resource)}
20
+ >
21
+ <List class="h-4 w-4" />
22
+ {#if !hideText}<span class="ml-1">{resource}</span>{/if}
23
+ </Button>
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ import { useInvalidate, t } from '@svadmin/core';
3
+ import { Button } from '../ui/button/index.js';
4
+ import { RefreshCw } from 'lucide-svelte';
5
+
6
+ let { resource, hideText = false, class: className = '' } = $props<{
7
+ resource: string;
8
+ hideText?: boolean;
9
+ class?: string;
10
+ }>();
11
+
12
+ const invalidate = useInvalidate();
13
+ let spinning = $state(false);
14
+
15
+ function refresh() {
16
+ spinning = true;
17
+ invalidate({ resource, invalidates: ['list', 'many'] });
18
+ setTimeout(() => { spinning = false; }, 600);
19
+ }
20
+ </script>
21
+
22
+ <Button
23
+ variant="ghost"
24
+ size={hideText ? 'icon' : 'sm'}
25
+ class={className}
26
+ onclick={refresh}
27
+ >
28
+ <RefreshCw class="h-4 w-4 {spinning ? 'animate-spin' : ''}" />
29
+ {#if !hideText}<span class="ml-1">{t('common.retry')}</span>{/if}
30
+ </Button>
31
+