@svadmin/ui 0.0.2 → 0.0.3
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 +1 -1
- package/src/components/AdminApp.svelte +17 -6
- package/src/components/AutoForm.svelte +57 -5
- package/src/components/CanAccess.svelte +21 -0
- package/src/components/DevTools.svelte +231 -0
- package/src/components/DrawerForm.svelte +26 -0
- package/src/components/ForgotPasswordPage.svelte +213 -0
- package/src/components/LoginPage.svelte +245 -0
- package/src/components/ModalForm.svelte +34 -0
- package/src/components/RegisterPage.svelte +252 -0
- package/src/components/UndoableNotification.svelte +132 -0
- package/src/index.ts +8 -0
- package/src/router-state.svelte.ts +2 -0
package/package.json
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
import AutoForm from './AutoForm.svelte';
|
|
10
10
|
import ShowPage from './ShowPage.svelte';
|
|
11
11
|
import Toast from './Toast.svelte';
|
|
12
|
+
import LoginPage from './LoginPage.svelte';
|
|
13
|
+
import RegisterPage from './RegisterPage.svelte';
|
|
14
|
+
import ForgotPasswordPage from './ForgotPasswordPage.svelte';
|
|
15
|
+
import DevTools from './DevTools.svelte';
|
|
12
16
|
import { initRouter, getRoute, getParams } from '../router-state.svelte.js';
|
|
13
17
|
|
|
14
18
|
interface Props {
|
|
@@ -62,7 +66,7 @@
|
|
|
62
66
|
authProvider.check().then(result => {
|
|
63
67
|
isAuthenticated = result.authenticated;
|
|
64
68
|
authChecked = true;
|
|
65
|
-
if (!result.authenticated && route !== '/login') {
|
|
69
|
+
if (!result.authenticated && route !== '/login' && route !== '/register' && route !== '/forgot-password') {
|
|
66
70
|
navigate(result.redirectTo ?? '/login');
|
|
67
71
|
}
|
|
68
72
|
});
|
|
@@ -76,11 +80,17 @@
|
|
|
76
80
|
</div>
|
|
77
81
|
{:else if route === '/login' && loginPage}
|
|
78
82
|
{@render loginPage()}
|
|
79
|
-
{:else if route === '/login'}
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
{:else if route === '/login' && authProvider}
|
|
84
|
+
<LoginPage {authProvider} {title} onSuccess={() => { isAuthenticated = true; navigate('/'); }} />
|
|
85
|
+
{:else if route === '/register' && authProvider?.register}
|
|
86
|
+
<RegisterPage {authProvider} {title} />
|
|
87
|
+
{:else if route === '/forgot-password' && authProvider?.forgotPassword}
|
|
88
|
+
<ForgotPasswordPage {authProvider} {title} />
|
|
89
|
+
{:else if route === '/login' || route === '/register' || route === '/forgot-password'}
|
|
90
|
+
<div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
|
91
|
+
<div class="w-full max-w-sm rounded-2xl bg-white dark:bg-gray-900 p-8 shadow-xl text-center">
|
|
92
|
+
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">{title}</h1>
|
|
93
|
+
<p class="mt-2 text-sm text-gray-500">Please configure an authProvider.</p>
|
|
84
94
|
</div>
|
|
85
95
|
</div>
|
|
86
96
|
{:else if isAuthenticated || !authProvider}
|
|
@@ -118,4 +128,5 @@
|
|
|
118
128
|
</div>
|
|
119
129
|
{/if}
|
|
120
130
|
<Toast />
|
|
131
|
+
<DevTools />
|
|
121
132
|
</QueryClientProvider>
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
// Form state
|
|
36
36
|
let formData = $state<Record<string, unknown>>({});
|
|
37
|
+
let fieldErrors = $state<Record<string, string>>({});
|
|
37
38
|
let submitting = $state(false);
|
|
38
39
|
let error = $state<string | null>(null);
|
|
39
40
|
let initialized = $state(false);
|
|
@@ -84,10 +85,36 @@
|
|
|
84
85
|
const createMut = useCreate(resourceName);
|
|
85
86
|
const updateMut = useUpdate(resourceName);
|
|
86
87
|
|
|
88
|
+
function validateFields(): boolean {
|
|
89
|
+
const errors: Record<string, string> = {};
|
|
90
|
+
for (const field of formFields) {
|
|
91
|
+
const value = formData[field.key];
|
|
92
|
+
// Required check
|
|
93
|
+
if (field.required) {
|
|
94
|
+
if (value === undefined || value === null || value === '') {
|
|
95
|
+
errors[field.key] = t('validation.required');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Custom per-field validator
|
|
100
|
+
if (field.validate) {
|
|
101
|
+
const msg = field.validate(value);
|
|
102
|
+
if (msg) { errors[field.key] = msg; }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
fieldErrors = errors;
|
|
106
|
+
return Object.keys(errors).length === 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
async function handleSubmit() {
|
|
88
110
|
submitting = true;
|
|
89
111
|
error = null;
|
|
90
112
|
|
|
113
|
+
if (!validateFields()) {
|
|
114
|
+
submitting = false;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
91
118
|
try {
|
|
92
119
|
const cleanData: Record<string, unknown> = {};
|
|
93
120
|
for (const field of formFields) {
|
|
@@ -115,6 +142,12 @@
|
|
|
115
142
|
function handleFieldChange(key: string, val: unknown) {
|
|
116
143
|
formData[key] = val;
|
|
117
144
|
isDirty = true;
|
|
145
|
+
// Clear field error when user starts typing
|
|
146
|
+
if (fieldErrors[key]) {
|
|
147
|
+
const next = { ...fieldErrors };
|
|
148
|
+
delete next[key];
|
|
149
|
+
fieldErrors = next;
|
|
150
|
+
}
|
|
118
151
|
}
|
|
119
152
|
|
|
120
153
|
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery?.isLoading : false);
|
|
@@ -158,11 +191,16 @@
|
|
|
158
191
|
<Card.Root>
|
|
159
192
|
<Card.Content class="space-y-5">
|
|
160
193
|
{#each formFields as field (field.key)}
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
194
|
+
<div class="field-wrapper" class:has-error={fieldErrors[field.key]}>
|
|
195
|
+
<FieldRenderer
|
|
196
|
+
{field}
|
|
197
|
+
value={formData[field.key]}
|
|
198
|
+
onchange={(val: unknown) => handleFieldChange(field.key, val)}
|
|
199
|
+
/>
|
|
200
|
+
{#if fieldErrors[field.key]}
|
|
201
|
+
<p class="field-error">{fieldErrors[field.key]}</p>
|
|
202
|
+
{/if}
|
|
203
|
+
</div>
|
|
166
204
|
{/each}
|
|
167
205
|
</Card.Content>
|
|
168
206
|
</Card.Root>
|
|
@@ -190,3 +228,17 @@
|
|
|
190
228
|
</form>
|
|
191
229
|
{/if}
|
|
192
230
|
</div>
|
|
231
|
+
|
|
232
|
+
<style>
|
|
233
|
+
.field-error {
|
|
234
|
+
color: hsl(var(--destructive));
|
|
235
|
+
font-size: 0.8125rem;
|
|
236
|
+
margin-top: 0.25rem;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.field-wrapper.has-error :global(input),
|
|
240
|
+
.field-wrapper.has-error :global(textarea),
|
|
241
|
+
.field-wrapper.has-error :global(select) {
|
|
242
|
+
border-color: hsl(var(--destructive));
|
|
243
|
+
}
|
|
244
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { canAccess } from '@svadmin/core/permissions';
|
|
4
|
+
import type { Action } from '@svadmin/core/permissions';
|
|
5
|
+
|
|
6
|
+
let { resource, action, params, children, fallback } = $props<{
|
|
7
|
+
resource: string;
|
|
8
|
+
action: Action;
|
|
9
|
+
params?: Record<string, unknown>;
|
|
10
|
+
children: Snippet;
|
|
11
|
+
fallback?: Snippet;
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const result = $derived(canAccess(resource, action, params));
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
{#if result.can}
|
|
18
|
+
{@render children()}
|
|
19
|
+
{:else if fallback}
|
|
20
|
+
{@render fallback()}
|
|
21
|
+
{/if}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResources } from '@svadmin/core';
|
|
3
|
+
import { getTheme, getColorTheme } from '@svadmin/core';
|
|
4
|
+
import { getLocale } from '@svadmin/core/i18n';
|
|
5
|
+
import { currentPath } from '@svadmin/core/router';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import { X, Bug, ChevronDown, ChevronUp } from 'lucide-svelte';
|
|
8
|
+
|
|
9
|
+
let visible = $state(false);
|
|
10
|
+
let collapsed = $state(false);
|
|
11
|
+
|
|
12
|
+
function toggle() {
|
|
13
|
+
visible = !visible;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Keyboard shortcut: Ctrl+Shift+D
|
|
17
|
+
if (typeof window !== 'undefined') {
|
|
18
|
+
window.addEventListener('keydown', (e) => {
|
|
19
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
toggle();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
|
|
27
|
+
const path = $derived(currentPath());
|
|
28
|
+
const theme = $derived(getTheme());
|
|
29
|
+
const colorTheme = $derived(getColorTheme());
|
|
30
|
+
const locale = $derived(getLocale());
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
{#if import.meta.env.DEV}
|
|
34
|
+
{#if visible}
|
|
35
|
+
<div class="devtools-panel" class:collapsed>
|
|
36
|
+
<div class="devtools-header">
|
|
37
|
+
<div class="devtools-title">
|
|
38
|
+
<Bug class="h-4 w-4" />
|
|
39
|
+
<span>svadmin DevTools</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="devtools-header-actions">
|
|
42
|
+
<button onclick={() => collapsed = !collapsed} class="devtools-btn">
|
|
43
|
+
{#if collapsed}
|
|
44
|
+
<ChevronUp class="h-3.5 w-3.5" />
|
|
45
|
+
{:else}
|
|
46
|
+
<ChevronDown class="h-3.5 w-3.5" />
|
|
47
|
+
{/if}
|
|
48
|
+
</button>
|
|
49
|
+
<button onclick={toggle} class="devtools-btn">
|
|
50
|
+
<X class="h-3.5 w-3.5" />
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{#if !collapsed}
|
|
56
|
+
<div class="devtools-body">
|
|
57
|
+
<div class="devtools-section">
|
|
58
|
+
<h4>Router</h4>
|
|
59
|
+
<div class="devtools-row">
|
|
60
|
+
<span class="devtools-label">Path</span>
|
|
61
|
+
<code class="devtools-value">{path}</code>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="devtools-section">
|
|
66
|
+
<h4>Theme</h4>
|
|
67
|
+
<div class="devtools-row">
|
|
68
|
+
<span class="devtools-label">Mode</span>
|
|
69
|
+
<code class="devtools-value">{theme}</code>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="devtools-row">
|
|
72
|
+
<span class="devtools-label">Color</span>
|
|
73
|
+
<code class="devtools-value">{colorTheme}</code>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="devtools-section">
|
|
78
|
+
<h4>i18n</h4>
|
|
79
|
+
<div class="devtools-row">
|
|
80
|
+
<span class="devtools-label">Locale</span>
|
|
81
|
+
<code class="devtools-value">{locale}</code>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="devtools-section">
|
|
86
|
+
<h4>Resources ({resources.length})</h4>
|
|
87
|
+
{#each resources as r}
|
|
88
|
+
<div class="devtools-row">
|
|
89
|
+
<span class="devtools-label">{r.name}</span>
|
|
90
|
+
<code class="devtools-value">{r.fields.length} fields</code>
|
|
91
|
+
</div>
|
|
92
|
+
{/each}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
97
|
+
{:else}
|
|
98
|
+
<button class="devtools-fab" onclick={toggle} title="DevTools (Ctrl+Shift+D)">
|
|
99
|
+
<Bug class="h-4 w-4" />
|
|
100
|
+
</button>
|
|
101
|
+
{/if}
|
|
102
|
+
{/if}
|
|
103
|
+
|
|
104
|
+
<style>
|
|
105
|
+
.devtools-panel {
|
|
106
|
+
position: fixed;
|
|
107
|
+
bottom: 0;
|
|
108
|
+
right: 1rem;
|
|
109
|
+
z-index: 9999;
|
|
110
|
+
width: 320px;
|
|
111
|
+
background: hsl(var(--card));
|
|
112
|
+
border: 1px solid hsl(var(--border));
|
|
113
|
+
border-bottom: none;
|
|
114
|
+
border-radius: 0.75rem 0.75rem 0 0;
|
|
115
|
+
box-shadow: 0 -4px 24px hsl(0 0% 0% / 0.12);
|
|
116
|
+
font-size: 0.8125rem;
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.devtools-panel.collapsed {
|
|
121
|
+
width: auto;
|
|
122
|
+
min-width: 200px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.devtools-header {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: space-between;
|
|
129
|
+
padding: 0.5rem 0.75rem;
|
|
130
|
+
background: hsl(var(--muted));
|
|
131
|
+
border-bottom: 1px solid hsl(var(--border));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.devtools-title {
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 0.375rem;
|
|
138
|
+
font-weight: 600;
|
|
139
|
+
font-size: 0.75rem;
|
|
140
|
+
text-transform: uppercase;
|
|
141
|
+
letter-spacing: 0.05em;
|
|
142
|
+
color: hsl(var(--foreground));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.devtools-header-actions {
|
|
146
|
+
display: flex;
|
|
147
|
+
gap: 0.25rem;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.devtools-btn {
|
|
151
|
+
background: none;
|
|
152
|
+
border: none;
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
padding: 0.25rem;
|
|
155
|
+
color: hsl(var(--muted-foreground));
|
|
156
|
+
border-radius: 0.25rem;
|
|
157
|
+
}
|
|
158
|
+
.devtools-btn:hover {
|
|
159
|
+
background: hsl(var(--accent));
|
|
160
|
+
color: hsl(var(--foreground));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.devtools-body {
|
|
164
|
+
max-height: 300px;
|
|
165
|
+
overflow-y: auto;
|
|
166
|
+
padding: 0.5rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.devtools-section {
|
|
170
|
+
padding: 0.375rem 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.devtools-section h4 {
|
|
174
|
+
font-size: 0.6875rem;
|
|
175
|
+
font-weight: 700;
|
|
176
|
+
text-transform: uppercase;
|
|
177
|
+
letter-spacing: 0.08em;
|
|
178
|
+
color: hsl(var(--muted-foreground));
|
|
179
|
+
margin: 0 0 0.25rem;
|
|
180
|
+
padding: 0 0.25rem;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.devtools-row {
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
justify-content: space-between;
|
|
187
|
+
padding: 0.1875rem 0.25rem;
|
|
188
|
+
border-radius: 0.25rem;
|
|
189
|
+
}
|
|
190
|
+
.devtools-row:hover {
|
|
191
|
+
background: hsl(var(--muted) / 0.5);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.devtools-label {
|
|
195
|
+
color: hsl(var(--foreground));
|
|
196
|
+
font-size: 0.75rem;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.devtools-value {
|
|
200
|
+
font-size: 0.6875rem;
|
|
201
|
+
color: hsl(var(--primary));
|
|
202
|
+
background: hsl(var(--primary) / 0.1);
|
|
203
|
+
padding: 0.125rem 0.375rem;
|
|
204
|
+
border-radius: 0.25rem;
|
|
205
|
+
font-family: monospace;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.devtools-fab {
|
|
209
|
+
position: fixed;
|
|
210
|
+
bottom: 1rem;
|
|
211
|
+
right: 1rem;
|
|
212
|
+
z-index: 9999;
|
|
213
|
+
width: 36px;
|
|
214
|
+
height: 36px;
|
|
215
|
+
border-radius: 50%;
|
|
216
|
+
background: hsl(var(--primary));
|
|
217
|
+
color: hsl(var(--primary-foreground));
|
|
218
|
+
border: none;
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
display: flex;
|
|
221
|
+
align-items: center;
|
|
222
|
+
justify-content: center;
|
|
223
|
+
box-shadow: 0 2px 8px hsl(var(--primary) / 0.3);
|
|
224
|
+
transition: all 0.2s;
|
|
225
|
+
opacity: 0.6;
|
|
226
|
+
}
|
|
227
|
+
.devtools-fab:hover {
|
|
228
|
+
opacity: 1;
|
|
229
|
+
transform: scale(1.1);
|
|
230
|
+
}
|
|
231
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResource } from '@svadmin/core';
|
|
3
|
+
import { Sheet } from './ui/sheet/index.js';
|
|
4
|
+
import AutoForm from './AutoForm.svelte';
|
|
5
|
+
|
|
6
|
+
let { resourceName, mode = 'create', id, open = $bindable(false), side = 'right' } = $props<{
|
|
7
|
+
resourceName: string;
|
|
8
|
+
mode?: 'create' | 'edit';
|
|
9
|
+
id?: string | number;
|
|
10
|
+
open: boolean;
|
|
11
|
+
side?: 'left' | 'right';
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const resource = $derived(getResource(resourceName));
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
{#if open}
|
|
18
|
+
<Sheet bind:open {side}>
|
|
19
|
+
<div class="p-6 space-y-4">
|
|
20
|
+
<h2 class="text-lg font-semibold text-foreground">
|
|
21
|
+
{mode === 'create' ? `Create ${resource.label}` : `Edit ${resource.label}`}
|
|
22
|
+
</h2>
|
|
23
|
+
<AutoForm {resourceName} {mode} {id} />
|
|
24
|
+
</div>
|
|
25
|
+
</Sheet>
|
|
26
|
+
{/if}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AuthProvider } from '@svadmin/core';
|
|
3
|
+
import { t } from '@svadmin/core/i18n';
|
|
4
|
+
import { navigate } from '@svadmin/core/router';
|
|
5
|
+
import { toast } from '@svadmin/core/toast';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import { Input } from './ui/input/index.js';
|
|
8
|
+
import * as Card from './ui/card/index.js';
|
|
9
|
+
import { KeyRound, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
|
|
10
|
+
|
|
11
|
+
let { authProvider, title = 'Admin' } = $props<{
|
|
12
|
+
authProvider: AuthProvider;
|
|
13
|
+
title?: string;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
let email = $state('');
|
|
17
|
+
let loading = $state(false);
|
|
18
|
+
let error = $state('');
|
|
19
|
+
let sent = $state(false);
|
|
20
|
+
|
|
21
|
+
async function handleSubmit(e: SubmitEvent) {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
error = '';
|
|
24
|
+
|
|
25
|
+
if (!email) { error = t('auth.emailRequired'); return; }
|
|
26
|
+
|
|
27
|
+
loading = true;
|
|
28
|
+
try {
|
|
29
|
+
const result = await authProvider.forgotPassword!({ email });
|
|
30
|
+
if (result.success) {
|
|
31
|
+
sent = true;
|
|
32
|
+
toast.success(t('auth.resetLinkSent'));
|
|
33
|
+
} else {
|
|
34
|
+
error = result.error?.message ?? t('common.operationFailed');
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
error = err instanceof Error ? err.message : t('common.operationFailed');
|
|
38
|
+
toast.error(error);
|
|
39
|
+
} finally {
|
|
40
|
+
loading = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div class="forgot-page">
|
|
46
|
+
<div class="forgot-container">
|
|
47
|
+
<Card.Card class="login-card">
|
|
48
|
+
<Card.CardHeader class="login-header">
|
|
49
|
+
<div class="forgot-icon">
|
|
50
|
+
{#if sent}
|
|
51
|
+
<CheckCircle class="h-6 w-6" />
|
|
52
|
+
{:else}
|
|
53
|
+
<KeyRound class="h-6 w-6" />
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
<Card.CardTitle class="text-2xl font-bold">
|
|
57
|
+
{sent ? t('auth.resetLinkSent') : t('auth.forgotPassword')}
|
|
58
|
+
</Card.CardTitle>
|
|
59
|
+
{#if !sent}
|
|
60
|
+
<p class="text-sm text-muted-foreground">{t('auth.forgotPasswordDescription')}</p>
|
|
61
|
+
{/if}
|
|
62
|
+
</Card.CardHeader>
|
|
63
|
+
<Card.CardContent>
|
|
64
|
+
{#if sent}
|
|
65
|
+
<div class="success-state">
|
|
66
|
+
<p class="text-sm text-muted-foreground text-center mb-4">
|
|
67
|
+
{t('auth.resetLinkSent')}
|
|
68
|
+
</p>
|
|
69
|
+
<Button variant="outline" class="w-full" onclick={() => navigate('/login')}>
|
|
70
|
+
<ArrowLeft class="h-4 w-4 mr-2" />
|
|
71
|
+
{t('auth.backToLogin')}
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
{:else}
|
|
75
|
+
<form onsubmit={handleSubmit} class="space-y-4">
|
|
76
|
+
{#if error}
|
|
77
|
+
<div class="error-alert">
|
|
78
|
+
<p>{error}</p>
|
|
79
|
+
</div>
|
|
80
|
+
{/if}
|
|
81
|
+
|
|
82
|
+
<div class="space-y-2">
|
|
83
|
+
<label for="forgot-email" class="text-sm font-medium text-foreground">{t('auth.email')}</label>
|
|
84
|
+
<div class="input-with-icon">
|
|
85
|
+
<Mail class="input-icon h-4 w-4" />
|
|
86
|
+
<Input
|
|
87
|
+
id="forgot-email"
|
|
88
|
+
type="email"
|
|
89
|
+
placeholder="name@example.com"
|
|
90
|
+
bind:value={email}
|
|
91
|
+
class="pl-9"
|
|
92
|
+
autocomplete="email"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<Button type="submit" class="w-full" disabled={loading}>
|
|
98
|
+
{#if loading}
|
|
99
|
+
<span class="loading-spinner"></span>
|
|
100
|
+
{/if}
|
|
101
|
+
{t('auth.sendResetLink')}
|
|
102
|
+
</Button>
|
|
103
|
+
</form>
|
|
104
|
+
|
|
105
|
+
<div class="auth-link">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
class="text-sm text-primary hover:underline font-medium inline-flex items-center gap-1"
|
|
109
|
+
onclick={() => navigate('/login')}
|
|
110
|
+
>
|
|
111
|
+
<ArrowLeft class="h-3 w-3" />
|
|
112
|
+
{t('auth.backToLogin')}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
{/if}
|
|
116
|
+
</Card.CardContent>
|
|
117
|
+
</Card.Card>
|
|
118
|
+
|
|
119
|
+
<p class="text-xs text-muted-foreground mt-4 text-center opacity-60">
|
|
120
|
+
Powered by {title}
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<style>
|
|
126
|
+
.forgot-page {
|
|
127
|
+
min-height: 100vh;
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
|
|
132
|
+
padding: 1rem;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.forgot-container {
|
|
136
|
+
width: 100%;
|
|
137
|
+
max-width: 420px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.forgot-icon {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
width: 48px;
|
|
145
|
+
height: 48px;
|
|
146
|
+
border-radius: 12px;
|
|
147
|
+
background: hsl(var(--primary) / 0.1);
|
|
148
|
+
color: hsl(var(--primary));
|
|
149
|
+
margin: 0 auto 0.75rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
:global(.login-card) {
|
|
153
|
+
backdrop-filter: blur(20px);
|
|
154
|
+
border: 1px solid hsl(var(--border) / 0.5);
|
|
155
|
+
box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
:global(.login-header) {
|
|
159
|
+
text-align: center;
|
|
160
|
+
padding-bottom: 0.5rem;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.input-with-icon {
|
|
164
|
+
position: relative;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.input-icon {
|
|
168
|
+
position: absolute;
|
|
169
|
+
left: 0.75rem;
|
|
170
|
+
top: 50%;
|
|
171
|
+
transform: translateY(-50%);
|
|
172
|
+
color: hsl(var(--muted-foreground));
|
|
173
|
+
pointer-events: none;
|
|
174
|
+
z-index: 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.error-alert {
|
|
178
|
+
padding: 0.75rem;
|
|
179
|
+
border-radius: 0.5rem;
|
|
180
|
+
background: hsl(var(--destructive) / 0.1);
|
|
181
|
+
border: 1px solid hsl(var(--destructive) / 0.3);
|
|
182
|
+
color: hsl(var(--destructive));
|
|
183
|
+
font-size: 0.875rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.success-state {
|
|
187
|
+
padding: 0.5rem 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.loading-spinner {
|
|
191
|
+
display: inline-block;
|
|
192
|
+
width: 16px;
|
|
193
|
+
height: 16px;
|
|
194
|
+
border: 2px solid transparent;
|
|
195
|
+
border-top-color: currentColor;
|
|
196
|
+
border-radius: 50%;
|
|
197
|
+
animation: spin 0.6s linear infinite;
|
|
198
|
+
margin-right: 0.5rem;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@keyframes spin {
|
|
202
|
+
to { transform: rotate(360deg); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.auth-link {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: center;
|
|
209
|
+
margin-top: 1.25rem;
|
|
210
|
+
padding-top: 1.25rem;
|
|
211
|
+
border-top: 1px solid hsl(var(--border));
|
|
212
|
+
}
|
|
213
|
+
</style>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AuthProvider } from '@svadmin/core';
|
|
3
|
+
import { t } from '@svadmin/core/i18n';
|
|
4
|
+
import { navigate } from '@svadmin/core/router';
|
|
5
|
+
import { toast } from '@svadmin/core/toast';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import { Input } from './ui/input/index.js';
|
|
8
|
+
import * as Card from './ui/card/index.js';
|
|
9
|
+
import { LogIn, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
|
|
10
|
+
|
|
11
|
+
let { authProvider, title = 'Admin', onSuccess } = $props<{
|
|
12
|
+
authProvider: AuthProvider;
|
|
13
|
+
title?: string;
|
|
14
|
+
onSuccess?: () => void;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
let email = $state('');
|
|
18
|
+
let password = $state('');
|
|
19
|
+
let loading = $state(false);
|
|
20
|
+
let showPassword = $state(false);
|
|
21
|
+
let error = $state('');
|
|
22
|
+
|
|
23
|
+
async function handleSubmit(e: SubmitEvent) {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
error = '';
|
|
26
|
+
|
|
27
|
+
if (!email) { error = t('auth.emailRequired'); return; }
|
|
28
|
+
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
29
|
+
|
|
30
|
+
loading = true;
|
|
31
|
+
try {
|
|
32
|
+
const result = await authProvider.login({ email, password });
|
|
33
|
+
if (result.success) {
|
|
34
|
+
onSuccess?.();
|
|
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;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<div class="login-page">
|
|
49
|
+
<div class="login-container">
|
|
50
|
+
<Card.Card class="login-card">
|
|
51
|
+
<Card.CardHeader class="login-header">
|
|
52
|
+
<div class="login-icon">
|
|
53
|
+
<LogIn class="h-6 w-6" />
|
|
54
|
+
</div>
|
|
55
|
+
<Card.CardTitle class="text-2xl font-bold">{t('auth.welcomeBack')}</Card.CardTitle>
|
|
56
|
+
<p class="text-sm text-muted-foreground">{t('auth.welcomeMessage')}</p>
|
|
57
|
+
</Card.CardHeader>
|
|
58
|
+
<Card.CardContent>
|
|
59
|
+
<form onsubmit={handleSubmit} class="space-y-4">
|
|
60
|
+
{#if error}
|
|
61
|
+
<div class="error-alert">
|
|
62
|
+
<p>{error}</p>
|
|
63
|
+
</div>
|
|
64
|
+
{/if}
|
|
65
|
+
|
|
66
|
+
<div class="space-y-2">
|
|
67
|
+
<label for="login-email" class="text-sm font-medium text-foreground">{t('auth.email')}</label>
|
|
68
|
+
<div class="input-with-icon">
|
|
69
|
+
<Mail class="input-icon h-4 w-4" />
|
|
70
|
+
<Input
|
|
71
|
+
id="login-email"
|
|
72
|
+
type="email"
|
|
73
|
+
placeholder="name@example.com"
|
|
74
|
+
bind:value={email}
|
|
75
|
+
class="pl-9"
|
|
76
|
+
autocomplete="email"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="space-y-2">
|
|
82
|
+
<div class="flex items-center justify-between">
|
|
83
|
+
<label for="login-password" class="text-sm font-medium text-foreground">{t('auth.password')}</label>
|
|
84
|
+
{#if authProvider.forgotPassword}
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
class="text-xs text-primary hover:underline"
|
|
88
|
+
onclick={() => navigate('/forgot-password')}
|
|
89
|
+
>{t('auth.forgotPasswordLink')}</button>
|
|
90
|
+
{/if}
|
|
91
|
+
</div>
|
|
92
|
+
<div class="input-with-icon">
|
|
93
|
+
<Lock class="input-icon h-4 w-4" />
|
|
94
|
+
<Input
|
|
95
|
+
id="login-password"
|
|
96
|
+
type={showPassword ? 'text' : 'password'}
|
|
97
|
+
placeholder="••••••••"
|
|
98
|
+
bind:value={password}
|
|
99
|
+
class="pl-9 pr-9"
|
|
100
|
+
autocomplete="current-password"
|
|
101
|
+
/>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
class="password-toggle"
|
|
105
|
+
onclick={() => showPassword = !showPassword}
|
|
106
|
+
tabindex={-1}
|
|
107
|
+
>
|
|
108
|
+
{#if showPassword}
|
|
109
|
+
<EyeOff class="h-4 w-4" />
|
|
110
|
+
{:else}
|
|
111
|
+
<Eye class="h-4 w-4" />
|
|
112
|
+
{/if}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<Button type="submit" class="w-full" disabled={loading}>
|
|
118
|
+
{#if loading}
|
|
119
|
+
<span class="loading-spinner"></span>
|
|
120
|
+
{/if}
|
|
121
|
+
{t('auth.loginButton')}
|
|
122
|
+
</Button>
|
|
123
|
+
</form>
|
|
124
|
+
|
|
125
|
+
{#if authProvider.register}
|
|
126
|
+
<div class="auth-link">
|
|
127
|
+
<span class="text-sm text-muted-foreground">{t('auth.noAccount')}</span>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
class="text-sm text-primary hover:underline font-medium"
|
|
131
|
+
onclick={() => navigate('/register')}
|
|
132
|
+
>{t('auth.register')}</button>
|
|
133
|
+
</div>
|
|
134
|
+
{/if}
|
|
135
|
+
</Card.CardContent>
|
|
136
|
+
</Card.Card>
|
|
137
|
+
|
|
138
|
+
<p class="text-xs text-muted-foreground mt-4 text-center opacity-60">
|
|
139
|
+
Powered by {title}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<style>
|
|
145
|
+
.login-page {
|
|
146
|
+
min-height: 100vh;
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
|
|
151
|
+
padding: 1rem;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.login-container {
|
|
155
|
+
width: 100%;
|
|
156
|
+
max-width: 420px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
:global(.login-card) {
|
|
160
|
+
backdrop-filter: blur(20px);
|
|
161
|
+
border: 1px solid hsl(var(--border) / 0.5);
|
|
162
|
+
box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
:global(.login-header) {
|
|
166
|
+
text-align: center;
|
|
167
|
+
padding-bottom: 0.5rem;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.login-icon {
|
|
171
|
+
display: inline-flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
justify-content: center;
|
|
174
|
+
width: 48px;
|
|
175
|
+
height: 48px;
|
|
176
|
+
border-radius: 12px;
|
|
177
|
+
background: hsl(var(--primary) / 0.1);
|
|
178
|
+
color: hsl(var(--primary));
|
|
179
|
+
margin: 0 auto 0.75rem;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.input-with-icon {
|
|
183
|
+
position: relative;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.input-icon {
|
|
187
|
+
position: absolute;
|
|
188
|
+
left: 0.75rem;
|
|
189
|
+
top: 50%;
|
|
190
|
+
transform: translateY(-50%);
|
|
191
|
+
color: hsl(var(--muted-foreground));
|
|
192
|
+
pointer-events: none;
|
|
193
|
+
z-index: 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.password-toggle {
|
|
197
|
+
position: absolute;
|
|
198
|
+
right: 0.75rem;
|
|
199
|
+
top: 50%;
|
|
200
|
+
transform: translateY(-50%);
|
|
201
|
+
color: hsl(var(--muted-foreground));
|
|
202
|
+
background: none;
|
|
203
|
+
border: none;
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
padding: 2px;
|
|
206
|
+
z-index: 1;
|
|
207
|
+
}
|
|
208
|
+
.password-toggle:hover {
|
|
209
|
+
color: hsl(var(--foreground));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.error-alert {
|
|
213
|
+
padding: 0.75rem;
|
|
214
|
+
border-radius: 0.5rem;
|
|
215
|
+
background: hsl(var(--destructive) / 0.1);
|
|
216
|
+
border: 1px solid hsl(var(--destructive) / 0.3);
|
|
217
|
+
color: hsl(var(--destructive));
|
|
218
|
+
font-size: 0.875rem;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.loading-spinner {
|
|
222
|
+
display: inline-block;
|
|
223
|
+
width: 16px;
|
|
224
|
+
height: 16px;
|
|
225
|
+
border: 2px solid transparent;
|
|
226
|
+
border-top-color: currentColor;
|
|
227
|
+
border-radius: 50%;
|
|
228
|
+
animation: spin 0.6s linear infinite;
|
|
229
|
+
margin-right: 0.5rem;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@keyframes spin {
|
|
233
|
+
to { transform: rotate(360deg); }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.auth-link {
|
|
237
|
+
display: flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
justify-content: center;
|
|
240
|
+
gap: 0.25rem;
|
|
241
|
+
margin-top: 1.25rem;
|
|
242
|
+
padding-top: 1.25rem;
|
|
243
|
+
border-top: 1px solid hsl(var(--border));
|
|
244
|
+
}
|
|
245
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { FieldDefinition } from '@svadmin/core';
|
|
4
|
+
import { getResource, useForm } from '@svadmin/core';
|
|
5
|
+
import * as Dialog from './ui/dialog/index.js';
|
|
6
|
+
import AutoForm from './AutoForm.svelte';
|
|
7
|
+
|
|
8
|
+
let { resourceName, mode = 'create', id, open = $bindable(false), onSuccess } = $props<{
|
|
9
|
+
resourceName: string;
|
|
10
|
+
mode?: 'create' | 'edit';
|
|
11
|
+
id?: string | number;
|
|
12
|
+
open: boolean;
|
|
13
|
+
onSuccess?: () => void;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const resource = $derived(getResource(resourceName));
|
|
17
|
+
|
|
18
|
+
function handleClose() {
|
|
19
|
+
open = false;
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
{#if open}
|
|
24
|
+
<Dialog.Dialog bind:open>
|
|
25
|
+
<Dialog.DialogContent class="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
|
|
26
|
+
<Dialog.DialogHeader>
|
|
27
|
+
<Dialog.DialogTitle>
|
|
28
|
+
{mode === 'create' ? `Create ${resource.label}` : `Edit ${resource.label}`}
|
|
29
|
+
</Dialog.DialogTitle>
|
|
30
|
+
</Dialog.DialogHeader>
|
|
31
|
+
<AutoForm {resourceName} {mode} {id} />
|
|
32
|
+
</Dialog.DialogContent>
|
|
33
|
+
</Dialog.Dialog>
|
|
34
|
+
{/if}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AuthProvider } from '@svadmin/core';
|
|
3
|
+
import { t } from '@svadmin/core/i18n';
|
|
4
|
+
import { navigate } from '@svadmin/core/router';
|
|
5
|
+
import { toast } from '@svadmin/core/toast';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import { Input } from './ui/input/index.js';
|
|
8
|
+
import * as Card from './ui/card/index.js';
|
|
9
|
+
import { UserPlus, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
|
|
10
|
+
|
|
11
|
+
let { authProvider, title = 'Admin', onSuccess } = $props<{
|
|
12
|
+
authProvider: AuthProvider;
|
|
13
|
+
title?: string;
|
|
14
|
+
onSuccess?: () => void;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
let email = $state('');
|
|
18
|
+
let password = $state('');
|
|
19
|
+
let confirmPassword = $state('');
|
|
20
|
+
let loading = $state(false);
|
|
21
|
+
let showPassword = $state(false);
|
|
22
|
+
let error = $state('');
|
|
23
|
+
|
|
24
|
+
async function handleSubmit(e: SubmitEvent) {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
error = '';
|
|
27
|
+
|
|
28
|
+
if (!email) { error = t('auth.emailRequired'); return; }
|
|
29
|
+
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
30
|
+
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
|
|
31
|
+
|
|
32
|
+
loading = true;
|
|
33
|
+
try {
|
|
34
|
+
const result = await authProvider.register!({ email, password });
|
|
35
|
+
if (result.success) {
|
|
36
|
+
toast.success(t('auth.registerSuccess'));
|
|
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;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<div class="register-page">
|
|
52
|
+
<div class="register-container">
|
|
53
|
+
<Card.Card class="login-card">
|
|
54
|
+
<Card.CardHeader class="login-header">
|
|
55
|
+
<div class="register-icon">
|
|
56
|
+
<UserPlus class="h-6 w-6" />
|
|
57
|
+
</div>
|
|
58
|
+
<Card.CardTitle class="text-2xl font-bold">{t('auth.createAccount')}</Card.CardTitle>
|
|
59
|
+
<p class="text-sm text-muted-foreground">{t('auth.createAccountMessage')}</p>
|
|
60
|
+
</Card.CardHeader>
|
|
61
|
+
<Card.CardContent>
|
|
62
|
+
<form onsubmit={handleSubmit} class="space-y-4">
|
|
63
|
+
{#if error}
|
|
64
|
+
<div class="error-alert">
|
|
65
|
+
<p>{error}</p>
|
|
66
|
+
</div>
|
|
67
|
+
{/if}
|
|
68
|
+
|
|
69
|
+
<div class="space-y-2">
|
|
70
|
+
<label for="register-email" class="text-sm font-medium text-foreground">{t('auth.email')}</label>
|
|
71
|
+
<div class="input-with-icon">
|
|
72
|
+
<Mail class="input-icon h-4 w-4" />
|
|
73
|
+
<Input
|
|
74
|
+
id="register-email"
|
|
75
|
+
type="email"
|
|
76
|
+
placeholder="name@example.com"
|
|
77
|
+
bind:value={email}
|
|
78
|
+
class="pl-9"
|
|
79
|
+
autocomplete="email"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div class="space-y-2">
|
|
85
|
+
<label for="register-password" class="text-sm font-medium text-foreground">{t('auth.password')}</label>
|
|
86
|
+
<div class="input-with-icon">
|
|
87
|
+
<Lock class="input-icon h-4 w-4" />
|
|
88
|
+
<Input
|
|
89
|
+
id="register-password"
|
|
90
|
+
type={showPassword ? 'text' : 'password'}
|
|
91
|
+
placeholder="••••••••"
|
|
92
|
+
bind:value={password}
|
|
93
|
+
class="pl-9 pr-9"
|
|
94
|
+
autocomplete="new-password"
|
|
95
|
+
/>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
class="password-toggle"
|
|
99
|
+
onclick={() => showPassword = !showPassword}
|
|
100
|
+
tabindex={-1}
|
|
101
|
+
>
|
|
102
|
+
{#if showPassword}
|
|
103
|
+
<EyeOff class="h-4 w-4" />
|
|
104
|
+
{:else}
|
|
105
|
+
<Eye class="h-4 w-4" />
|
|
106
|
+
{/if}
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="space-y-2">
|
|
112
|
+
<label for="register-confirm" class="text-sm font-medium text-foreground">{t('auth.confirmPassword')}</label>
|
|
113
|
+
<div class="input-with-icon">
|
|
114
|
+
<Lock class="input-icon h-4 w-4" />
|
|
115
|
+
<Input
|
|
116
|
+
id="register-confirm"
|
|
117
|
+
type={showPassword ? 'text' : 'password'}
|
|
118
|
+
placeholder="••••••••"
|
|
119
|
+
bind:value={confirmPassword}
|
|
120
|
+
class="pl-9"
|
|
121
|
+
autocomplete="new-password"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<Button type="submit" class="w-full" disabled={loading}>
|
|
127
|
+
{#if loading}
|
|
128
|
+
<span class="loading-spinner"></span>
|
|
129
|
+
{/if}
|
|
130
|
+
{t('auth.registerButton')}
|
|
131
|
+
</Button>
|
|
132
|
+
</form>
|
|
133
|
+
|
|
134
|
+
<div class="auth-link">
|
|
135
|
+
<span class="text-sm text-muted-foreground">{t('auth.hasAccount')}</span>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
class="text-sm text-primary hover:underline font-medium"
|
|
139
|
+
onclick={() => navigate('/login')}
|
|
140
|
+
>{t('auth.login')}</button>
|
|
141
|
+
</div>
|
|
142
|
+
</Card.CardContent>
|
|
143
|
+
</Card.Card>
|
|
144
|
+
|
|
145
|
+
<p class="text-xs text-muted-foreground mt-4 text-center opacity-60">
|
|
146
|
+
Powered by {title}
|
|
147
|
+
</p>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<style>
|
|
152
|
+
.register-page {
|
|
153
|
+
min-height: 100vh;
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: center;
|
|
157
|
+
background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
|
|
158
|
+
padding: 1rem;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.register-container {
|
|
162
|
+
width: 100%;
|
|
163
|
+
max-width: 420px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.register-icon {
|
|
167
|
+
display: inline-flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: center;
|
|
170
|
+
width: 48px;
|
|
171
|
+
height: 48px;
|
|
172
|
+
border-radius: 12px;
|
|
173
|
+
background: hsl(var(--primary) / 0.1);
|
|
174
|
+
color: hsl(var(--primary));
|
|
175
|
+
margin: 0 auto 0.75rem;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
:global(.login-card) {
|
|
179
|
+
backdrop-filter: blur(20px);
|
|
180
|
+
border: 1px solid hsl(var(--border) / 0.5);
|
|
181
|
+
box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
:global(.login-header) {
|
|
185
|
+
text-align: center;
|
|
186
|
+
padding-bottom: 0.5rem;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.input-with-icon {
|
|
190
|
+
position: relative;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.input-icon {
|
|
194
|
+
position: absolute;
|
|
195
|
+
left: 0.75rem;
|
|
196
|
+
top: 50%;
|
|
197
|
+
transform: translateY(-50%);
|
|
198
|
+
color: hsl(var(--muted-foreground));
|
|
199
|
+
pointer-events: none;
|
|
200
|
+
z-index: 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.password-toggle {
|
|
204
|
+
position: absolute;
|
|
205
|
+
right: 0.75rem;
|
|
206
|
+
top: 50%;
|
|
207
|
+
transform: translateY(-50%);
|
|
208
|
+
color: hsl(var(--muted-foreground));
|
|
209
|
+
background: none;
|
|
210
|
+
border: none;
|
|
211
|
+
cursor: pointer;
|
|
212
|
+
padding: 2px;
|
|
213
|
+
z-index: 1;
|
|
214
|
+
}
|
|
215
|
+
.password-toggle:hover {
|
|
216
|
+
color: hsl(var(--foreground));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.error-alert {
|
|
220
|
+
padding: 0.75rem;
|
|
221
|
+
border-radius: 0.5rem;
|
|
222
|
+
background: hsl(var(--destructive) / 0.1);
|
|
223
|
+
border: 1px solid hsl(var(--destructive) / 0.3);
|
|
224
|
+
color: hsl(var(--destructive));
|
|
225
|
+
font-size: 0.875rem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.loading-spinner {
|
|
229
|
+
display: inline-block;
|
|
230
|
+
width: 16px;
|
|
231
|
+
height: 16px;
|
|
232
|
+
border: 2px solid transparent;
|
|
233
|
+
border-top-color: currentColor;
|
|
234
|
+
border-radius: 50%;
|
|
235
|
+
animation: spin 0.6s linear infinite;
|
|
236
|
+
margin-right: 0.5rem;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
@keyframes spin {
|
|
240
|
+
to { transform: rotate(360deg); }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.auth-link {
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: center;
|
|
247
|
+
gap: 0.25rem;
|
|
248
|
+
margin-top: 1.25rem;
|
|
249
|
+
padding-top: 1.25rem;
|
|
250
|
+
border-top: 1px solid hsl(var(--border));
|
|
251
|
+
}
|
|
252
|
+
</style>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from './ui/button/index.js';
|
|
3
|
+
import { X, Undo2 } from 'lucide-svelte';
|
|
4
|
+
|
|
5
|
+
let { message, duration = 5000, onUndo, onTimeout } = $props<{
|
|
6
|
+
message: string;
|
|
7
|
+
duration?: number;
|
|
8
|
+
onUndo: () => void;
|
|
9
|
+
onTimeout: () => void;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
let remaining = $state(duration);
|
|
13
|
+
let dismissed = $state(false);
|
|
14
|
+
|
|
15
|
+
const interval = setInterval(() => {
|
|
16
|
+
remaining -= 100;
|
|
17
|
+
if (remaining <= 0) {
|
|
18
|
+
clearInterval(interval);
|
|
19
|
+
if (!dismissed) {
|
|
20
|
+
dismissed = true;
|
|
21
|
+
onTimeout();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}, 100);
|
|
25
|
+
|
|
26
|
+
function handleUndo() {
|
|
27
|
+
clearInterval(interval);
|
|
28
|
+
dismissed = true;
|
|
29
|
+
onUndo();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleDismiss() {
|
|
33
|
+
clearInterval(interval);
|
|
34
|
+
dismissed = true;
|
|
35
|
+
onTimeout();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const progress = $derived(remaining / duration * 100);
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
{#if !dismissed}
|
|
42
|
+
<div class="undoable-notification">
|
|
43
|
+
<div class="undoable-content">
|
|
44
|
+
<p class="undoable-message">{message}</p>
|
|
45
|
+
<div class="undoable-actions">
|
|
46
|
+
<Button variant="ghost" size="sm" onclick={handleUndo} class="undoable-undo-btn">
|
|
47
|
+
<Undo2 class="h-3.5 w-3.5 mr-1" />
|
|
48
|
+
Undo
|
|
49
|
+
</Button>
|
|
50
|
+
<button class="undoable-close" onclick={handleDismiss}>
|
|
51
|
+
<X class="h-3.5 w-3.5" />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="undoable-progress">
|
|
56
|
+
<div class="undoable-progress-bar" style="width: {progress}%"></div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
{/if}
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
.undoable-notification {
|
|
63
|
+
position: fixed;
|
|
64
|
+
bottom: 1.5rem;
|
|
65
|
+
left: 50%;
|
|
66
|
+
transform: translateX(-50%);
|
|
67
|
+
z-index: 100;
|
|
68
|
+
min-width: 320px;
|
|
69
|
+
max-width: 480px;
|
|
70
|
+
background: hsl(var(--card));
|
|
71
|
+
border: 1px solid hsl(var(--border));
|
|
72
|
+
border-radius: 0.75rem;
|
|
73
|
+
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.15);
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
animation: slideUp 0.3s ease;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.undoable-content {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: space-between;
|
|
82
|
+
padding: 0.75rem 1rem;
|
|
83
|
+
gap: 0.75rem;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.undoable-message {
|
|
87
|
+
font-size: 0.875rem;
|
|
88
|
+
color: hsl(var(--foreground));
|
|
89
|
+
margin: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.undoable-actions {
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: 0.25rem;
|
|
96
|
+
flex-shrink: 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
:global(.undoable-undo-btn) {
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
color: hsl(var(--primary)) !important;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.undoable-close {
|
|
105
|
+
background: none;
|
|
106
|
+
border: none;
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
padding: 0.25rem;
|
|
109
|
+
color: hsl(var(--muted-foreground));
|
|
110
|
+
border-radius: 0.25rem;
|
|
111
|
+
}
|
|
112
|
+
.undoable-close:hover {
|
|
113
|
+
color: hsl(var(--foreground));
|
|
114
|
+
background: hsl(var(--muted));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.undoable-progress {
|
|
118
|
+
height: 3px;
|
|
119
|
+
background: hsl(var(--muted));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.undoable-progress-bar {
|
|
123
|
+
height: 100%;
|
|
124
|
+
background: hsl(var(--primary));
|
|
125
|
+
transition: width 100ms linear;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@keyframes slideUp {
|
|
129
|
+
from { transform: translateX(-50%) translateY(100%); opacity: 0; }
|
|
130
|
+
to { transform: translateX(-50%) translateY(0); opacity: 1; }
|
|
131
|
+
}
|
|
132
|
+
</style>
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,14 @@ export { default as FieldRenderer } from './components/FieldRenderer.svelte';
|
|
|
18
18
|
export { default as EmptyState } from './components/EmptyState.svelte';
|
|
19
19
|
export { default as StatsCard } from './components/StatsCard.svelte';
|
|
20
20
|
export { default as PageHeader } from './components/PageHeader.svelte';
|
|
21
|
+
export { default as LoginPage } from './components/LoginPage.svelte';
|
|
22
|
+
export { default as RegisterPage } from './components/RegisterPage.svelte';
|
|
23
|
+
export { default as ForgotPasswordPage } from './components/ForgotPasswordPage.svelte';
|
|
24
|
+
export { default as CanAccess } from './components/CanAccess.svelte';
|
|
25
|
+
export { default as UndoableNotification } from './components/UndoableNotification.svelte';
|
|
26
|
+
export { default as ModalForm } from './components/ModalForm.svelte';
|
|
27
|
+
export { default as DrawerForm } from './components/DrawerForm.svelte';
|
|
28
|
+
export { default as DevTools } from './components/DevTools.svelte';
|
|
21
29
|
|
|
22
30
|
// Base UI components (shadcn-svelte)
|
|
23
31
|
export { Button, buttonVariants } from './components/ui/button/index.js';
|