@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -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
- <div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
81
- <div class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-xl text-center">
82
- <h1 class="text-xl font-bold text-gray-900">{title}</h1>
83
- <p class="mt-2 text-sm text-gray-500">Please configure a loginPage snippet or authProvider.</p>
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
- <FieldRenderer
162
- {field}
163
- value={formData[field.key]}
164
- onchange={(val: unknown) => handleFieldChange(field.key, val)}
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';
@@ -7,6 +7,8 @@ let _initialized = false;
7
7
 
8
8
  const ROUTES = [
9
9
  '/login',
10
+ '/register',
11
+ '/forgot-password',
10
12
  '/',
11
13
  '/:resource',
12
14
  '/:resource/create',