@svadmin/ui 0.0.3 → 0.0.6
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 +2 -2
- package/src/components/AdminApp.svelte +27 -18
- package/src/components/Authenticated.svelte +26 -0
- package/src/components/ConfigErrorScreen.svelte +224 -0
- package/src/components/DevTools.svelte +83 -34
- package/src/components/ForgotPasswordPage.svelte +12 -22
- package/src/components/InferencerPanel.svelte +166 -0
- package/src/components/LoginPage.svelte +13 -22
- package/src/components/RegisterPage.svelte +12 -23
- package/src/components/StatsCard.svelte +23 -2
- 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 +60 -0
- package/src/components/buttons/EditButton.svelte +30 -0
- package/src/components/buttons/ExportButton.svelte +24 -0
- package/src/components/buttons/ImportButton.svelte +28 -0
- package/src/components/buttons/ListButton.svelte +23 -0
- package/src/components/buttons/RefreshButton.svelte +30 -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/index.ts +10 -0
- package/src/router-state.svelte.ts +24 -5
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getDataProvider, getResources, inferResource } from '@svadmin/core';
|
|
3
|
+
import type { InferResult, ResourceDefinition } from '@svadmin/core';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import { Wand2, Copy, Check, RefreshCw, Loader2 } from 'lucide-svelte';
|
|
6
|
+
|
|
7
|
+
const dataProvider = getDataProvider();
|
|
8
|
+
const resources = getResources();
|
|
9
|
+
|
|
10
|
+
let selectedResource = $state('');
|
|
11
|
+
let inferResult = $state<InferResult | null>(null);
|
|
12
|
+
let loading = $state(false);
|
|
13
|
+
let error = $state<string | null>(null);
|
|
14
|
+
let copied = $state(false);
|
|
15
|
+
let customEndpoint = $state('');
|
|
16
|
+
|
|
17
|
+
async function runInference() {
|
|
18
|
+
const resourceName = customEndpoint.trim() || selectedResource;
|
|
19
|
+
if (!resourceName) return;
|
|
20
|
+
|
|
21
|
+
loading = true;
|
|
22
|
+
error = null;
|
|
23
|
+
inferResult = null;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await dataProvider.getList({
|
|
27
|
+
resource: resourceName,
|
|
28
|
+
pagination: { current: 1, pageSize: 25 },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!response.data || response.data.length === 0) {
|
|
32
|
+
error = `No data returned from "${resourceName}". The API must return at least one record to infer fields.`;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
inferResult = inferResource(
|
|
37
|
+
resourceName,
|
|
38
|
+
response.data as Record<string, unknown>[],
|
|
39
|
+
);
|
|
40
|
+
} catch (e: any) {
|
|
41
|
+
error = e?.message ?? 'Failed to fetch data for inference.';
|
|
42
|
+
} finally {
|
|
43
|
+
loading = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function copyCode() {
|
|
48
|
+
if (!inferResult) return;
|
|
49
|
+
navigator.clipboard.writeText(inferResult.code);
|
|
50
|
+
copied = true;
|
|
51
|
+
setTimeout(() => { copied = false; }, 2000);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const typeColors: Record<string, string> = {
|
|
55
|
+
text: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
|
56
|
+
number: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
57
|
+
boolean: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
|
58
|
+
date: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
|
59
|
+
email: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200',
|
|
60
|
+
url: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
|
|
61
|
+
image: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
|
|
62
|
+
images: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
|
|
63
|
+
textarea: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
64
|
+
json: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
|
|
65
|
+
tags: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
|
|
66
|
+
select: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
|
67
|
+
relation: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
68
|
+
color: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-900 dark:text-fuchsia-200',
|
|
69
|
+
phone: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
|
|
70
|
+
};
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<div class="inferencer-panel space-y-4">
|
|
74
|
+
<div class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
75
|
+
<Wand2 class="h-4 w-4" />
|
|
76
|
+
Resource Inferencer
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Resource selector -->
|
|
80
|
+
<div class="flex gap-2">
|
|
81
|
+
<select
|
|
82
|
+
class="flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
|
83
|
+
bind:value={selectedResource}
|
|
84
|
+
>
|
|
85
|
+
<option value="">— Select a resource —</option>
|
|
86
|
+
{#each resources as res}
|
|
87
|
+
<option value={res.name}>{res.label} ({res.name})</option>
|
|
88
|
+
{/each}
|
|
89
|
+
</select>
|
|
90
|
+
<span class="flex items-center text-xs text-gray-400">or</span>
|
|
91
|
+
<input
|
|
92
|
+
type="text"
|
|
93
|
+
class="w-36 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
|
94
|
+
placeholder="custom endpoint"
|
|
95
|
+
bind:value={customEndpoint}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<Button size="sm" onclick={runInference} disabled={loading || (!selectedResource && !customEndpoint.trim())}>
|
|
100
|
+
{#if loading}
|
|
101
|
+
<Loader2 class="mr-1 h-3 w-3 animate-spin" />
|
|
102
|
+
Inferring...
|
|
103
|
+
{:else}
|
|
104
|
+
<RefreshCw class="mr-1 h-3 w-3" />
|
|
105
|
+
Infer Fields
|
|
106
|
+
{/if}
|
|
107
|
+
</Button>
|
|
108
|
+
|
|
109
|
+
{#if error}
|
|
110
|
+
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300">
|
|
111
|
+
{error}
|
|
112
|
+
</div>
|
|
113
|
+
{/if}
|
|
114
|
+
|
|
115
|
+
{#if inferResult}
|
|
116
|
+
<!-- Field table -->
|
|
117
|
+
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
|
118
|
+
<table class="w-full text-left text-sm">
|
|
119
|
+
<thead class="bg-gray-50 dark:bg-gray-800">
|
|
120
|
+
<tr>
|
|
121
|
+
<th class="px-3 py-2 font-medium">Field</th>
|
|
122
|
+
<th class="px-3 py-2 font-medium">Type</th>
|
|
123
|
+
<th class="px-3 py-2 font-medium">List</th>
|
|
124
|
+
<th class="px-3 py-2 font-medium">Form</th>
|
|
125
|
+
</tr>
|
|
126
|
+
</thead>
|
|
127
|
+
<tbody>
|
|
128
|
+
{#each inferResult.fields as field}
|
|
129
|
+
<tr class="border-t border-gray-100 dark:border-gray-700/50">
|
|
130
|
+
<td class="px-3 py-1.5 font-mono text-xs">{field.key}</td>
|
|
131
|
+
<td class="px-3 py-1.5">
|
|
132
|
+
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {typeColors[field.type] ?? 'bg-gray-100 text-gray-600'}">
|
|
133
|
+
{field.type}
|
|
134
|
+
{#if field.resource}→ {field.resource}{/if}
|
|
135
|
+
</span>
|
|
136
|
+
</td>
|
|
137
|
+
<td class="px-3 py-1.5 text-center">{field.showInList ? '✓' : '—'}</td>
|
|
138
|
+
<td class="px-3 py-1.5 text-center">{field.showInForm ? '✓' : '—'}</td>
|
|
139
|
+
</tr>
|
|
140
|
+
{/each}
|
|
141
|
+
</tbody>
|
|
142
|
+
</table>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Generated code -->
|
|
146
|
+
<div class="relative">
|
|
147
|
+
<div class="flex items-center justify-between rounded-t-lg bg-gray-800 px-3 py-1.5 text-xs text-gray-400">
|
|
148
|
+
<span>Generated ResourceDefinition</span>
|
|
149
|
+
<button class="flex items-center gap-1 hover:text-white transition-colors" onclick={copyCode}>
|
|
150
|
+
{#if copied}
|
|
151
|
+
<Check class="h-3 w-3 text-green-400" />
|
|
152
|
+
<span class="text-green-400">Copied!</span>
|
|
153
|
+
{:else}
|
|
154
|
+
<Copy class="h-3 w-3" />
|
|
155
|
+
Copy
|
|
156
|
+
{/if}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
<pre class="max-h-64 overflow-auto rounded-b-lg bg-gray-900 p-3 text-xs text-green-300 font-mono">{inferResult.code}</pre>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<p class="text-xs text-gray-400">
|
|
163
|
+
Inferred {inferResult.fields.length} fields from sample data. Copy the code above into your <code>resources.ts</code> file.
|
|
164
|
+
</p>
|
|
165
|
+
{/if}
|
|
166
|
+
</div>
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { useLogin, getAuthProvider } from '@svadmin/core';
|
|
3
3
|
import { t } from '@svadmin/core/i18n';
|
|
4
4
|
import { navigate } from '@svadmin/core/router';
|
|
5
|
-
import { toast } from '@svadmin/core/toast';
|
|
6
5
|
import { Button } from './ui/button/index.js';
|
|
7
6
|
import { Input } from './ui/input/index.js';
|
|
8
7
|
import * as Card from './ui/card/index.js';
|
|
9
8
|
import { LogIn, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
|
|
10
9
|
|
|
11
|
-
let {
|
|
12
|
-
authProvider: AuthProvider;
|
|
10
|
+
let { title = 'Admin', onSuccess } = $props<{
|
|
13
11
|
title?: string;
|
|
14
12
|
onSuccess?: () => void;
|
|
15
13
|
}>();
|
|
16
14
|
|
|
15
|
+
const login = useLogin();
|
|
16
|
+
const authProvider = getAuthProvider();
|
|
17
|
+
|
|
17
18
|
let email = $state('');
|
|
18
19
|
let password = $state('');
|
|
19
|
-
let loading = $state(false);
|
|
20
20
|
let showPassword = $state(false);
|
|
21
21
|
let error = $state('');
|
|
22
22
|
|
|
@@ -27,20 +27,11 @@
|
|
|
27
27
|
if (!email) { error = t('auth.emailRequired'); return; }
|
|
28
28
|
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (result.redirectTo) navigate(result.redirectTo);
|
|
36
|
-
} else {
|
|
37
|
-
error = result.error?.message ?? t('common.loginFailed');
|
|
38
|
-
}
|
|
39
|
-
} catch (err) {
|
|
40
|
-
error = err instanceof Error ? err.message : t('common.loginFailed');
|
|
41
|
-
toast.error(error);
|
|
42
|
-
} finally {
|
|
43
|
-
loading = false;
|
|
30
|
+
const result = await login.mutate({ email, password });
|
|
31
|
+
if (result.success) {
|
|
32
|
+
onSuccess?.();
|
|
33
|
+
} else {
|
|
34
|
+
error = result.error?.message ?? t('common.loginFailed');
|
|
44
35
|
}
|
|
45
36
|
}
|
|
46
37
|
</script>
|
|
@@ -114,8 +105,8 @@
|
|
|
114
105
|
</div>
|
|
115
106
|
</div>
|
|
116
107
|
|
|
117
|
-
<Button type="submit" class="w-full" disabled={
|
|
118
|
-
{#if
|
|
108
|
+
<Button type="submit" class="w-full" disabled={login.isLoading}>
|
|
109
|
+
{#if login.isLoading}
|
|
119
110
|
<span class="loading-spinner"></span>
|
|
120
111
|
{/if}
|
|
121
112
|
{t('auth.loginButton')}
|
|
@@ -183,7 +174,7 @@
|
|
|
183
174
|
position: relative;
|
|
184
175
|
}
|
|
185
176
|
|
|
186
|
-
.input-icon {
|
|
177
|
+
:global(.input-icon) {
|
|
187
178
|
position: absolute;
|
|
188
179
|
left: 0.75rem;
|
|
189
180
|
top: 50%;
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { useRegister } from '@svadmin/core';
|
|
3
3
|
import { t } from '@svadmin/core/i18n';
|
|
4
4
|
import { navigate } from '@svadmin/core/router';
|
|
5
|
-
import { toast } from '@svadmin/core/toast';
|
|
6
5
|
import { Button } from './ui/button/index.js';
|
|
7
6
|
import { Input } from './ui/input/index.js';
|
|
8
7
|
import * as Card from './ui/card/index.js';
|
|
9
8
|
import { UserPlus, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
|
|
10
9
|
|
|
11
|
-
let {
|
|
12
|
-
authProvider: AuthProvider;
|
|
10
|
+
let { title = 'Admin', onSuccess } = $props<{
|
|
13
11
|
title?: string;
|
|
14
12
|
onSuccess?: () => void;
|
|
15
13
|
}>();
|
|
16
14
|
|
|
15
|
+
const register = useRegister();
|
|
16
|
+
|
|
17
17
|
let email = $state('');
|
|
18
18
|
let password = $state('');
|
|
19
19
|
let confirmPassword = $state('');
|
|
20
|
-
let loading = $state(false);
|
|
21
20
|
let showPassword = $state(false);
|
|
22
21
|
let error = $state('');
|
|
23
22
|
|
|
@@ -29,21 +28,11 @@
|
|
|
29
28
|
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
30
29
|
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
onSuccess?.();
|
|
38
|
-
navigate(result.redirectTo ?? '/login');
|
|
39
|
-
} else {
|
|
40
|
-
error = result.error?.message ?? t('common.operationFailed');
|
|
41
|
-
}
|
|
42
|
-
} catch (err) {
|
|
43
|
-
error = err instanceof Error ? err.message : t('common.operationFailed');
|
|
44
|
-
toast.error(error);
|
|
45
|
-
} finally {
|
|
46
|
-
loading = false;
|
|
31
|
+
const result = await register.mutate({ email, password });
|
|
32
|
+
if (result.success) {
|
|
33
|
+
onSuccess?.();
|
|
34
|
+
} else {
|
|
35
|
+
error = result.error?.message ?? t('common.operationFailed');
|
|
47
36
|
}
|
|
48
37
|
}
|
|
49
38
|
</script>
|
|
@@ -123,8 +112,8 @@
|
|
|
123
112
|
</div>
|
|
124
113
|
</div>
|
|
125
114
|
|
|
126
|
-
<Button type="submit" class="w-full" disabled={
|
|
127
|
-
{#if
|
|
115
|
+
<Button type="submit" class="w-full" disabled={register.isLoading}>
|
|
116
|
+
{#if register.isLoading}
|
|
128
117
|
<span class="loading-spinner"></span>
|
|
129
118
|
{/if}
|
|
130
119
|
{t('auth.registerButton')}
|
|
@@ -190,7 +179,7 @@
|
|
|
190
179
|
position: relative;
|
|
191
180
|
}
|
|
192
181
|
|
|
193
|
-
.input-icon {
|
|
182
|
+
:global(.input-icon) {
|
|
194
183
|
position: absolute;
|
|
195
184
|
left: 0.75rem;
|
|
196
185
|
top: 50%;
|
|
@@ -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,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, action: '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, action: '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}
|