@svadmin/ui 0.0.1 → 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.
@@ -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>
@@ -43,15 +43,15 @@
43
43
  {/if}
44
44
  </div>
45
45
 
46
- {#if $query.isLoading}
46
+ {#if query.isLoading}
47
47
  <div class="flex h-64 items-center justify-center">
48
48
  <Loader2 class="h-6 w-6 animate-spin text-primary" />
49
49
  </div>
50
- {:else if $query.data}
50
+ {:else if query.data}
51
51
  <Card.Root>
52
52
  <Card.Content class="divide-y divide-border p-0">
53
53
  {#each showFields as field}
54
- {@const value = ($query.data as Record<string, unknown>)[field.key]}
54
+ {@const value = (query.data as Record<string, unknown>)[field.key]}
55
55
  <div class="flex px-6 py-4">
56
56
  <div class="w-1/3 text-sm font-medium text-muted-foreground">{field.label}</div>
57
57
  <div class="w-2/3 text-sm text-foreground">
@@ -2,14 +2,14 @@
2
2
  import { getResources } from '@svadmin/core';
3
3
  import type { Identity } from '@svadmin/core';
4
4
  import { currentPath, navigate } from '@svadmin/core/router';
5
- import { t } from '@svadmin/core/i18n';
6
- import { toggleTheme, getResolvedTheme } from '@svadmin/core';
5
+ import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
6
+ import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
7
7
  import { Button } from './ui/button/index.js';
8
8
  import * as Tooltip from './ui/tooltip/index.js';
9
9
  import { Separator } from './ui/separator/index.js';
10
10
  import {
11
11
  LayoutDashboard, FileText, Users, Settings, Home,
12
- ChevronLeft, ChevronRight, LogOut, Sun, Moon
12
+ ChevronLeft, ChevronRight, LogOut, Sun, Moon, Languages
13
13
  } from 'lucide-svelte';
14
14
 
15
15
  let { collapsed, identity, title, onToggle, onLogout } = $props<{
@@ -36,14 +36,27 @@
36
36
  Icon: typeof LayoutDashboard;
37
37
  }
38
38
 
39
- const navItems: NavItem[] = [
39
+ // Use $derived so navItems rebuild when locale changes
40
+ const navItems: NavItem[] = $derived([
40
41
  { path: '/', label: t('common.home'), Icon: LayoutDashboard },
41
42
  ...resources.map(r => ({
42
43
  path: `/${r.name}`,
43
44
  label: r.label,
44
45
  Icon: iconMap[r.name] ?? Settings,
45
46
  })),
46
- ];
47
+ ]);
48
+
49
+ /** Toggle between available locales */
50
+ function toggleLocale() {
51
+ const locales = getAvailableLocales();
52
+ const current = getLocale();
53
+ const idx = locales.indexOf(current);
54
+ const next = locales[(idx + 1) % locales.length];
55
+ setLocale(next);
56
+ }
57
+
58
+ /** Short display label for current locale */
59
+ const localeLabel = $derived(getLocale() === 'zh-CN' ? '中' : 'EN');
47
60
 
48
61
  // Track current hash for active state
49
62
  let path = $state(currentPath());
@@ -60,7 +73,7 @@
60
73
  </script>
61
74
 
62
75
  <aside
63
- class="fixed inset-y-0 left-0 z-30 flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-300"
76
+ class="fixed inset-y-0 left-0 z-30 flex flex-col bg-sidebar/80 backdrop-blur-xl border-r border-sidebar-border/50 shadow-xl transition-all duration-300"
64
77
  class:w-64={!collapsed}
65
78
  class:w-16={collapsed}
66
79
  >
@@ -123,7 +136,21 @@
123
136
 
124
137
  <!-- Footer -->
125
138
  <Separator class="bg-sidebar-border" />
126
- <div class="p-3 space-y-1">
139
+ <div class="p-3 space-y-2">
140
+ <!-- Color theme picker -->
141
+ {#if !collapsed}
142
+ <div class="flex items-center justify-center gap-1.5 px-2 py-1">
143
+ {#each colorThemes as ct}
144
+ <button
145
+ class="h-5 w-5 rounded-full transition-all duration-200 hover:scale-110 {getColorTheme() === ct.id ? 'ring-2 ring-offset-2 ring-offset-sidebar scale-110' : 'opacity-70 hover:opacity-100'}"
146
+ style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
147
+ title={ct.label}
148
+ onclick={() => setColorTheme(ct.id)}
149
+ ></button>
150
+ {/each}
151
+ </div>
152
+ {/if}
153
+
127
154
  {#if !collapsed && identity}
128
155
  <div class="flex items-center gap-3 rounded-lg px-2 py-2">
129
156
  <div class="flex h-8 w-8 items-center justify-center rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground">
@@ -132,6 +159,9 @@
132
159
  <div class="flex-1 min-w-0">
133
160
  <p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
134
161
  </div>
162
+ <Button variant="ghost" size="icon-sm" onclick={toggleLocale} class="text-sidebar-foreground" title="Switch language">
163
+ <span class="text-xs font-bold">{localeLabel}</span>
164
+ </Button>
135
165
  <Button variant="ghost" size="icon-sm" onclick={toggleTheme} class="text-sidebar-foreground" title={t('common.toggleTheme')}>
136
166
  {#if getResolvedTheme() === 'dark'}
137
167
  <Sun class="h-4 w-4" />
@@ -144,6 +174,9 @@
144
174
  </Button>
145
175
  </div>
146
176
  {:else if collapsed}
177
+ <Button variant="ghost" size="icon" onclick={toggleLocale} class="w-full text-sidebar-foreground" title="Switch language">
178
+ <span class="text-xs font-bold">{localeLabel}</span>
179
+ </Button>
147
180
  <Button variant="ghost" size="icon" onclick={toggleTheme} class="w-full text-sidebar-foreground" title={t('common.toggleTheme')}>
148
181
  {#if getResolvedTheme() === 'dark'}
149
182
  <Sun class="h-5 w-5" />
@@ -156,6 +189,9 @@
156
189
  </Button>
157
190
  {:else}
158
191
  <div class="flex gap-1">
192
+ <Button variant="ghost" size="icon" onclick={toggleLocale} class="flex-1 text-sidebar-foreground" title="Switch language">
193
+ <span class="text-xs font-bold">{localeLabel}</span>
194
+ </Button>
159
195
  <Button variant="ghost" size="icon" onclick={toggleTheme} class="flex-1 text-sidebar-foreground" title={t('common.toggleTheme')}>
160
196
  {#if getResolvedTheme() === 'dark'}
161
197
  <Sun class="h-5 w-5" />
@@ -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';
@@ -0,0 +1,39 @@
1
+ // Shared reactive hash router state — .svelte.ts enables $state at module level
2
+ import { matchRoute } from '@svadmin/core/router';
3
+
4
+ let _route = $state('/');
5
+ let _params: Record<string, string> = $state({});
6
+ let _initialized = false;
7
+
8
+ const ROUTES = [
9
+ '/login',
10
+ '/register',
11
+ '/forgot-password',
12
+ '/',
13
+ '/:resource',
14
+ '/:resource/create',
15
+ '/:resource/edit/:id',
16
+ '/:resource/show/:id',
17
+ ];
18
+
19
+ function sync() {
20
+ const hash = window.location.hash;
21
+ const m = matchRoute(hash, ROUTES);
22
+ _route = m?.route ?? '/';
23
+ _params = m?.params ?? {};
24
+ }
25
+
26
+ export function initRouter() {
27
+ if (_initialized) return;
28
+ _initialized = true;
29
+ sync();
30
+ window.addEventListener('hashchange', sync);
31
+ }
32
+
33
+ export function getRoute(): string {
34
+ return _route;
35
+ }
36
+
37
+ export function getParams(): Record<string, string> {
38
+ return _params;
39
+ }