@svadmin/ui 0.0.3 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.0.3",
3
+ "version": "0.0.6",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "peerDependencies": {
23
23
  "svelte": "^5.0.0",
24
- "@svadmin/core": "0.1.0",
24
+ "@svadmin/core": "0.0.5",
25
25
  "bits-ui": "^2.0.0",
26
26
  "tailwind-variants": "^3.0.0",
27
27
  "clsx": "^2.0.0",
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode } from '@svadmin/core';
4
- import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme } from '@svadmin/core';
3
+ import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider } from '@svadmin/core';
4
+ import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider } from '@svadmin/core';
5
5
  import { navigate } from '@svadmin/core/router';
6
6
  import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
7
7
  import Layout from './Layout.svelte';
@@ -12,12 +12,15 @@
12
12
  import LoginPage from './LoginPage.svelte';
13
13
  import RegisterPage from './RegisterPage.svelte';
14
14
  import ForgotPasswordPage from './ForgotPasswordPage.svelte';
15
+ import UpdatePasswordPage from './UpdatePasswordPage.svelte';
16
+ import ConfigErrorScreen from './ConfigErrorScreen.svelte';
15
17
  import DevTools from './DevTools.svelte';
16
18
  import { initRouter, getRoute, getParams } from '../router-state.svelte.js';
17
19
 
18
20
  interface Props {
19
21
  dataProvider: DataProvider;
20
22
  authProvider?: AuthProvider;
23
+ routerProvider?: RouterProvider;
21
24
  resources: ResourceDefinition[];
22
25
  locale?: string;
23
26
  title?: string;
@@ -29,6 +32,7 @@
29
32
  let {
30
33
  dataProvider,
31
34
  authProvider,
35
+ routerProvider,
32
36
  resources,
33
37
  locale,
34
38
  title = 'Admin',
@@ -37,10 +41,14 @@
37
41
  loginPage,
38
42
  }: Props = $props();
39
43
 
44
+ // Resolve router provider (default to hash)
45
+ const resolvedRouter = routerProvider ?? createHashRouterProvider();
46
+
40
47
  // Set up context
41
48
  setDataProvider(dataProvider);
42
49
  if (authProvider) setAuthProvider(authProvider);
43
50
  setResources(resources);
51
+ setRouterProvider(resolvedRouter);
44
52
  if (locale) setLocale(locale);
45
53
  if (defaultTheme) setTheme(defaultTheme);
46
54
 
@@ -50,23 +58,27 @@
50
58
  },
51
59
  });
52
60
 
53
- // Initialize hash router
54
- initRouter();
61
+ // Initialize router with provider
62
+ initRouter(resolvedRouter);
55
63
 
56
64
  // Reactive getters for route state
57
65
  const route = $derived(getRoute());
58
66
  const params = $derived(getParams());
59
67
 
60
68
  // Auth check
61
- let isAuthenticated = $state(!authProvider);
62
- let authChecked = $state(!authProvider);
69
+ let isAuthenticated = $state(false);
70
+ let authChecked = $state(false);
63
71
 
64
72
  $effect(() => {
65
- if (!authProvider) return;
73
+ if (!authProvider) {
74
+ isAuthenticated = true;
75
+ authChecked = true;
76
+ return;
77
+ }
66
78
  authProvider.check().then(result => {
67
79
  isAuthenticated = result.authenticated;
68
80
  authChecked = true;
69
- if (!result.authenticated && route !== '/login' && route !== '/register' && route !== '/forgot-password') {
81
+ if (!result.authenticated && route !== '/login' && route !== '/register' && route !== '/forgot-password' && route !== '/update-password') {
70
82
  navigate(result.redirectTo ?? '/login');
71
83
  }
72
84
  });
@@ -81,18 +93,15 @@
81
93
  {:else if route === '/login' && loginPage}
82
94
  {@render loginPage()}
83
95
  {:else if route === '/login' && authProvider}
84
- <LoginPage {authProvider} {title} onSuccess={() => { isAuthenticated = true; navigate('/'); }} />
96
+ <LoginPage {title} onSuccess={() => { isAuthenticated = true; navigate('/'); }} />
85
97
  {:else if route === '/register' && authProvider?.register}
86
- <RegisterPage {authProvider} {title} />
98
+ <RegisterPage {title} />
87
99
  {: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>
94
- </div>
95
- </div>
100
+ <ForgotPasswordPage {title} />
101
+ {:else if route === '/update-password' && authProvider?.updatePassword}
102
+ <UpdatePasswordPage {title} />
103
+ {:else if route === '/login' || route === '/register' || route === '/forgot-password' || route === '/update-password'}
104
+ <ConfigErrorScreen title="{title} Configuration Required" />
96
105
  {:else if isAuthenticated || !authProvider}
97
106
  <Layout {title}>
98
107
  {#if route === '/'}
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { useIsAuthenticated } from '@svadmin/core';
4
+
5
+ let { children, fallback, loading } = $props<{
6
+ children: Snippet;
7
+ fallback?: Snippet;
8
+ loading?: Snippet;
9
+ }>();
10
+
11
+ const auth = useIsAuthenticated();
12
+ </script>
13
+
14
+ {#if auth.isLoading}
15
+ {#if loading}
16
+ {@render loading()}
17
+ {:else}
18
+ <div class="flex items-center justify-center min-h-[200px]">
19
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
20
+ </div>
21
+ {/if}
22
+ {:else if auth.isAuthenticated}
23
+ {@render children()}
24
+ {:else if fallback}
25
+ {@render fallback()}
26
+ {/if}
@@ -0,0 +1,224 @@
1
+ <script lang="ts">
2
+ import { t } from '@svadmin/core/i18n';
3
+ import { AlertTriangle, Copy, CheckCircle } from 'lucide-svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import * as Card from './ui/card/index.js';
6
+
7
+ let { title = 'Configuration Required', missingVars = [], envTemplate = '' } = $props<{
8
+ title?: string;
9
+ missingVars?: { key: string; description?: string }[];
10
+ envTemplate?: string;
11
+ }>();
12
+
13
+ let copied = $state<Record<string, boolean>>({});
14
+
15
+ async function copyToClipboard(text: string, key: string) {
16
+ try {
17
+ await navigator.clipboard.writeText(text);
18
+ copied = { ...copied, [key]: true };
19
+ setTimeout(() => {
20
+ copied = { ...copied, [key]: false };
21
+ }, 2000);
22
+ } catch {
23
+ console.warn('[svadmin] clipboard API unavailable');
24
+ }
25
+ }
26
+
27
+ async function copyAll() {
28
+ const text = envTemplate || missingVars.map((v: { key: string }) => `${v.key}=`).join('\n');
29
+ await copyToClipboard(text, '__all__');
30
+ }
31
+ </script>
32
+
33
+ <div class="config-error-page">
34
+ <div class="config-error-container">
35
+ <Card.Card class="config-error-card">
36
+ <Card.CardHeader class="config-error-header">
37
+ <div class="config-error-icon">
38
+ <AlertTriangle class="h-7 w-7" />
39
+ </div>
40
+ <Card.CardTitle class="text-xl font-bold">{title}</Card.CardTitle>
41
+ <p class="text-sm text-muted-foreground">
42
+ {t('config.missingEnvDescription')}
43
+ </p>
44
+ </Card.CardHeader>
45
+ <Card.CardContent class="space-y-4">
46
+ {#if missingVars.length > 0}
47
+ <div class="env-var-list">
48
+ {#each missingVars as v (v.key)}
49
+ <div class="env-var-row">
50
+ <div class="env-var-info">
51
+ <code class="env-var-key">{v.key}</code>
52
+ {#if v.description}
53
+ <span class="env-var-desc">{v.description}</span>
54
+ {/if}
55
+ </div>
56
+ <button
57
+ class="copy-btn"
58
+ onclick={() => copyToClipboard(`${v.key}=`, v.key)}
59
+ title="Copy"
60
+ >
61
+ {#if copied[v.key]}
62
+ <CheckCircle class="h-3.5 w-3.5 text-green-500" />
63
+ {:else}
64
+ <Copy class="h-3.5 w-3.5" />
65
+ {/if}
66
+ </button>
67
+ </div>
68
+ {/each}
69
+ </div>
70
+ {/if}
71
+
72
+ {#if envTemplate}
73
+ <div class="env-template">
74
+ <div class="env-template-header">
75
+ <span class="text-xs font-medium text-muted-foreground">{t('config.envFilePath')}</span>
76
+ <button
77
+ class="copy-btn"
78
+ onclick={copyAll}
79
+ >
80
+ {#if copied['__all__']}
81
+ <CheckCircle class="h-3.5 w-3.5 text-green-500" />
82
+ <span class="text-xs">Copied!</span>
83
+ {:else}
84
+ <Copy class="h-3.5 w-3.5" />
85
+ <span class="text-xs">Copy All</span>
86
+ {/if}
87
+ </button>
88
+ </div>
89
+ <pre class="env-template-code">{envTemplate}</pre>
90
+ </div>
91
+ {/if}
92
+
93
+ <p class="text-xs text-muted-foreground text-center mt-4">
94
+ {t('config.reload')}
95
+ </p>
96
+
97
+ <Button variant="outline" class="w-full" onclick={() => window.location.reload()}>
98
+ Reload Page
99
+ </Button>
100
+ </Card.CardContent>
101
+ </Card.Card>
102
+ </div>
103
+ </div>
104
+
105
+ <style>
106
+ .config-error-page {
107
+ min-height: 100vh;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ background: linear-gradient(135deg, hsl(var(--destructive) / 0.05) 0%, hsl(var(--background)) 50%, hsl(var(--destructive) / 0.03) 100%);
112
+ padding: 1rem;
113
+ }
114
+
115
+ .config-error-container {
116
+ width: 100%;
117
+ max-width: 480px;
118
+ }
119
+
120
+ :global(.config-error-card) {
121
+ backdrop-filter: blur(20px);
122
+ border: 1px solid hsl(var(--border) / 0.5);
123
+ box-shadow: 0 8px 32px hsl(var(--destructive) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
124
+ }
125
+
126
+ :global(.config-error-header) {
127
+ text-align: center;
128
+ padding-bottom: 0.5rem;
129
+ }
130
+
131
+ .config-error-icon {
132
+ display: inline-flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ width: 56px;
136
+ height: 56px;
137
+ border-radius: 14px;
138
+ background: hsl(var(--destructive) / 0.1);
139
+ color: hsl(var(--destructive));
140
+ margin: 0 auto 0.75rem;
141
+ }
142
+
143
+ .env-var-list {
144
+ border: 1px solid hsl(var(--border));
145
+ border-radius: 0.5rem;
146
+ overflow: hidden;
147
+ }
148
+
149
+ .env-var-row {
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: space-between;
153
+ padding: 0.625rem 0.75rem;
154
+ border-bottom: 1px solid hsl(var(--border) / 0.5);
155
+ gap: 0.5rem;
156
+ }
157
+ .env-var-row:last-child {
158
+ border-bottom: none;
159
+ }
160
+
161
+ .env-var-info {
162
+ display: flex;
163
+ flex-direction: column;
164
+ gap: 0.125rem;
165
+ min-width: 0;
166
+ }
167
+
168
+ .env-var-key {
169
+ font-size: 0.8125rem;
170
+ font-weight: 600;
171
+ color: hsl(var(--foreground));
172
+ font-family: monospace;
173
+ }
174
+
175
+ .env-var-desc {
176
+ font-size: 0.6875rem;
177
+ color: hsl(var(--muted-foreground));
178
+ }
179
+
180
+ .copy-btn {
181
+ display: flex;
182
+ align-items: center;
183
+ gap: 0.25rem;
184
+ background: none;
185
+ border: none;
186
+ cursor: pointer;
187
+ padding: 0.25rem 0.5rem;
188
+ color: hsl(var(--muted-foreground));
189
+ border-radius: 0.25rem;
190
+ transition: all 0.15s;
191
+ flex-shrink: 0;
192
+ }
193
+ .copy-btn:hover {
194
+ color: hsl(var(--foreground));
195
+ background: hsl(var(--muted) / 0.5);
196
+ }
197
+
198
+ .env-template {
199
+ border: 1px solid hsl(var(--border));
200
+ border-radius: 0.5rem;
201
+ overflow: hidden;
202
+ }
203
+
204
+ .env-template-header {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ padding: 0.5rem 0.75rem;
209
+ background: hsl(var(--muted) / 0.5);
210
+ border-bottom: 1px solid hsl(var(--border) / 0.5);
211
+ }
212
+
213
+ .env-template-code {
214
+ padding: 0.75rem;
215
+ font-size: 0.75rem;
216
+ font-family: monospace;
217
+ line-height: 1.6;
218
+ color: hsl(var(--foreground));
219
+ background: hsl(var(--muted) / 0.2);
220
+ margin: 0;
221
+ white-space: pre-wrap;
222
+ word-break: break-all;
223
+ }
224
+ </style>
@@ -4,10 +4,12 @@
4
4
  import { getLocale } from '@svadmin/core/i18n';
5
5
  import { currentPath } from '@svadmin/core/router';
6
6
  import { Button } from './ui/button/index.js';
7
- import { X, Bug, ChevronDown, ChevronUp } from 'lucide-svelte';
7
+ import { X, Bug, ChevronDown, ChevronUp, Wand2 } from 'lucide-svelte';
8
+ import InferencerPanel from './InferencerPanel.svelte';
8
9
 
9
10
  let visible = $state(false);
10
11
  let collapsed = $state(false);
12
+ let activeTab = $state<'state' | 'inferencer'>('state');
11
13
 
12
14
  function toggle() {
13
15
  visible = !visible;
@@ -53,44 +55,57 @@
53
55
  </div>
54
56
 
55
57
  {#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>
58
+ <div class="devtools-tabs">
59
+ <button class="devtools-tab" class:active={activeTab === 'state'} onclick={() => activeTab = 'state'}>
60
+ State
61
+ </button>
62
+ <button class="devtools-tab" class:active={activeTab === 'inferencer'} onclick={() => activeTab = 'inferencer'}>
63
+ <Wand2 class="h-3 w-3" /> Inferencer
64
+ </button>
65
+ </div>
64
66
 
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>
67
+ <div class="devtools-body">
68
+ {#if activeTab === 'state'}
69
+ <div class="devtools-section">
70
+ <h4>Router</h4>
71
+ <div class="devtools-row">
72
+ <span class="devtools-label">Path</span>
73
+ <code class="devtools-value">{path}</code>
74
+ </div>
74
75
  </div>
75
- </div>
76
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>
77
+ <div class="devtools-section">
78
+ <h4>Theme</h4>
79
+ <div class="devtools-row">
80
+ <span class="devtools-label">Mode</span>
81
+ <code class="devtools-value">{theme}</code>
82
+ </div>
83
+ <div class="devtools-row">
84
+ <span class="devtools-label">Color</span>
85
+ <code class="devtools-value">{colorTheme}</code>
86
+ </div>
82
87
  </div>
83
- </div>
84
88
 
85
- <div class="devtools-section">
86
- <h4>Resources ({resources.length})</h4>
87
- {#each resources as r}
89
+ <div class="devtools-section">
90
+ <h4>i18n</h4>
88
91
  <div class="devtools-row">
89
- <span class="devtools-label">{r.name}</span>
90
- <code class="devtools-value">{r.fields.length} fields</code>
92
+ <span class="devtools-label">Locale</span>
93
+ <code class="devtools-value">{locale}</code>
91
94
  </div>
92
- {/each}
93
- </div>
95
+ </div>
96
+
97
+ <div class="devtools-section">
98
+ <h4>Resources ({resources.length})</h4>
99
+ {#each resources as r}
100
+ <div class="devtools-row">
101
+ <span class="devtools-label">{r.name}</span>
102
+ <code class="devtools-value">{r.fields.length} fields</code>
103
+ </div>
104
+ {/each}
105
+ </div>
106
+ {:else}
107
+ <InferencerPanel />
108
+ {/if}
94
109
  </div>
95
110
  {/if}
96
111
  </div>
@@ -107,7 +122,8 @@
107
122
  bottom: 0;
108
123
  right: 1rem;
109
124
  z-index: 9999;
110
- width: 320px;
125
+ width: 420px;
126
+ max-width: 95vw;
111
127
  background: hsl(var(--card));
112
128
  border: 1px solid hsl(var(--border));
113
129
  border-bottom: none;
@@ -161,7 +177,7 @@
161
177
  }
162
178
 
163
179
  .devtools-body {
164
- max-height: 300px;
180
+ max-height: 400px;
165
181
  overflow-y: auto;
166
182
  padding: 0.5rem;
167
183
  }
@@ -228,4 +244,37 @@
228
244
  opacity: 1;
229
245
  transform: scale(1.1);
230
246
  }
247
+ .devtools-tabs {
248
+ display: flex;
249
+ border-bottom: 1px solid hsl(var(--border));
250
+ }
251
+
252
+ .devtools-tab {
253
+ flex: 1;
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: center;
257
+ gap: 0.25rem;
258
+ padding: 0.375rem 0.5rem;
259
+ font-size: 0.6875rem;
260
+ font-weight: 600;
261
+ text-transform: uppercase;
262
+ letter-spacing: 0.05em;
263
+ background: none;
264
+ border: none;
265
+ cursor: pointer;
266
+ color: hsl(var(--muted-foreground));
267
+ border-bottom: 2px solid transparent;
268
+ transition: all 0.15s;
269
+ }
270
+
271
+ .devtools-tab:hover {
272
+ color: hsl(var(--foreground));
273
+ background: hsl(var(--muted) / 0.3);
274
+ }
275
+
276
+ .devtools-tab.active {
277
+ color: hsl(var(--primary));
278
+ border-bottom-color: hsl(var(--primary));
279
+ }
231
280
  </style>
@@ -1,20 +1,19 @@
1
1
  <script lang="ts">
2
- import type { AuthProvider } from '@svadmin/core';
2
+ import { useForgotPassword } from '@svadmin/core';
3
3
  import { t } from '@svadmin/core/i18n';
4
4
  import { navigate } from '@svadmin/core/router';
5
- import { toast } from '@svadmin/core/toast';
6
5
  import { Button } from './ui/button/index.js';
7
6
  import { Input } from './ui/input/index.js';
8
7
  import * as Card from './ui/card/index.js';
9
8
  import { KeyRound, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
10
9
 
11
- let { authProvider, title = 'Admin' } = $props<{
12
- authProvider: AuthProvider;
10
+ let { title = 'Admin' } = $props<{
13
11
  title?: string;
14
12
  }>();
15
13
 
14
+ const forgot = useForgotPassword();
15
+
16
16
  let email = $state('');
17
- let loading = $state(false);
18
17
  let error = $state('');
19
18
  let sent = $state(false);
20
19
 
@@ -24,20 +23,11 @@
24
23
 
25
24
  if (!email) { error = t('auth.emailRequired'); return; }
26
25
 
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;
26
+ const result = await forgot.mutate({ email });
27
+ if (result.success) {
28
+ sent = true;
29
+ } else {
30
+ error = result.error?.message ?? t('common.operationFailed');
41
31
  }
42
32
  }
43
33
  </script>
@@ -94,8 +84,8 @@
94
84
  </div>
95
85
  </div>
96
86
 
97
- <Button type="submit" class="w-full" disabled={loading}>
98
- {#if loading}
87
+ <Button type="submit" class="w-full" disabled={forgot.isLoading}>
88
+ {#if forgot.isLoading}
99
89
  <span class="loading-spinner"></span>
100
90
  {/if}
101
91
  {t('auth.sendResetLink')}
@@ -164,7 +154,7 @@
164
154
  position: relative;
165
155
  }
166
156
 
167
- .input-icon {
157
+ :global(.input-icon) {
168
158
  position: absolute;
169
159
  left: 0.75rem;
170
160
  top: 50%;