@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.
- package/package.json +3 -2
- package/src/components/AdminApp.svelte +31 -21
- package/src/components/Authenticated.svelte +57 -0
- package/src/components/AutoForm.svelte +5 -5
- package/src/components/AutoSaveIndicator.svelte +30 -0
- package/src/components/AutoTable.svelte +81 -10
- package/src/components/Breadcrumbs.svelte +2 -0
- package/src/components/CatchAllNavigate.svelte +21 -0
- package/src/components/ConfigErrorScreen.svelte +224 -0
- package/src/components/ConfirmDialog.svelte +1 -1
- package/src/components/CreatePage.svelte +42 -0
- package/src/components/DevTools.svelte +83 -34
- package/src/components/EditPage.svelte +52 -0
- package/src/components/EmptyState.svelte +17 -31
- package/src/components/ErrorComponent.svelte +35 -0
- package/src/components/ForgotPasswordPage.svelte +12 -22
- package/src/components/Header.svelte +28 -59
- package/src/components/InferencerPanel.svelte +166 -0
- package/src/components/ListPage.svelte +47 -0
- package/src/components/LiveIndicator.svelte +115 -0
- package/src/components/LoginPage.svelte +13 -22
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +70 -0
- package/src/components/RegisterPage.svelte +12 -23
- package/src/components/ShowPage.svelte +3 -3
- package/src/components/Sidebar.svelte +29 -10
- package/src/components/StatsCard.svelte +23 -2
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +21 -11
- package/src/components/UpdatePasswordPage.svelte +217 -0
- package/src/components/buttons/CloneButton.svelte +30 -0
- package/src/components/buttons/CreateButton.svelte +29 -0
- package/src/components/buttons/DeleteButton.svelte +59 -0
- package/src/components/buttons/EditButton.svelte +30 -0
- package/src/components/buttons/ExportButton.svelte +24 -0
- package/src/components/buttons/ImportButton.svelte +50 -0
- package/src/components/buttons/ListButton.svelte +23 -0
- package/src/components/buttons/RefreshButton.svelte +31 -0
- package/src/components/buttons/SaveButton.svelte +27 -0
- package/src/components/buttons/ShowButton.svelte +24 -0
- package/src/components/buttons/index.ts +10 -0
- package/src/components/fields/BooleanField.svelte +13 -0
- package/src/components/fields/DateField.svelte +6 -0
- package/src/components/fields/EmailField.svelte +15 -0
- package/src/components/fields/FileField.svelte +19 -0
- package/src/components/fields/ImageField.svelte +14 -0
- package/src/components/fields/MarkdownField.svelte +19 -0
- package/src/components/fields/NumberField.svelte +6 -0
- package/src/components/fields/TagField.svelte +17 -0
- package/src/components/fields/TextField.svelte +16 -0
- package/src/components/fields/UrlField.svelte +20 -0
- package/src/components/ui/avatar/avatar.svelte +2 -1
- package/src/index.ts +32 -0
- 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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
|