@svadmin/ui 0.0.2 → 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.
Files changed (29) hide show
  1. package/package.json +2 -2
  2. package/src/components/AdminApp.svelte +35 -15
  3. package/src/components/Authenticated.svelte +26 -0
  4. package/src/components/AutoForm.svelte +57 -5
  5. package/src/components/CanAccess.svelte +21 -0
  6. package/src/components/ConfigErrorScreen.svelte +224 -0
  7. package/src/components/DevTools.svelte +280 -0
  8. package/src/components/DrawerForm.svelte +26 -0
  9. package/src/components/ForgotPasswordPage.svelte +203 -0
  10. package/src/components/InferencerPanel.svelte +166 -0
  11. package/src/components/LoginPage.svelte +236 -0
  12. package/src/components/ModalForm.svelte +34 -0
  13. package/src/components/RegisterPage.svelte +241 -0
  14. package/src/components/StatsCard.svelte +23 -2
  15. package/src/components/UndoableNotification.svelte +132 -0
  16. package/src/components/UpdatePasswordPage.svelte +217 -0
  17. package/src/components/buttons/CloneButton.svelte +30 -0
  18. package/src/components/buttons/CreateButton.svelte +29 -0
  19. package/src/components/buttons/DeleteButton.svelte +60 -0
  20. package/src/components/buttons/EditButton.svelte +30 -0
  21. package/src/components/buttons/ExportButton.svelte +24 -0
  22. package/src/components/buttons/ImportButton.svelte +28 -0
  23. package/src/components/buttons/ListButton.svelte +23 -0
  24. package/src/components/buttons/RefreshButton.svelte +30 -0
  25. package/src/components/buttons/SaveButton.svelte +27 -0
  26. package/src/components/buttons/ShowButton.svelte +24 -0
  27. package/src/components/buttons/index.ts +10 -0
  28. package/src/index.ts +18 -0
  29. package/src/router-state.svelte.ts +26 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.0.2",
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';
@@ -9,11 +9,18 @@
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 UpdatePasswordPage from './UpdatePasswordPage.svelte';
16
+ import ConfigErrorScreen from './ConfigErrorScreen.svelte';
17
+ import DevTools from './DevTools.svelte';
12
18
  import { initRouter, getRoute, getParams } from '../router-state.svelte.js';
13
19
 
14
20
  interface Props {
15
21
  dataProvider: DataProvider;
16
22
  authProvider?: AuthProvider;
23
+ routerProvider?: RouterProvider;
17
24
  resources: ResourceDefinition[];
18
25
  locale?: string;
19
26
  title?: string;
@@ -25,6 +32,7 @@
25
32
  let {
26
33
  dataProvider,
27
34
  authProvider,
35
+ routerProvider,
28
36
  resources,
29
37
  locale,
30
38
  title = 'Admin',
@@ -33,10 +41,14 @@
33
41
  loginPage,
34
42
  }: Props = $props();
35
43
 
44
+ // Resolve router provider (default to hash)
45
+ const resolvedRouter = routerProvider ?? createHashRouterProvider();
46
+
36
47
  // Set up context
37
48
  setDataProvider(dataProvider);
38
49
  if (authProvider) setAuthProvider(authProvider);
39
50
  setResources(resources);
51
+ setRouterProvider(resolvedRouter);
40
52
  if (locale) setLocale(locale);
41
53
  if (defaultTheme) setTheme(defaultTheme);
42
54
 
@@ -46,23 +58,27 @@
46
58
  },
47
59
  });
48
60
 
49
- // Initialize hash router
50
- initRouter();
61
+ // Initialize router with provider
62
+ initRouter(resolvedRouter);
51
63
 
52
64
  // Reactive getters for route state
53
65
  const route = $derived(getRoute());
54
66
  const params = $derived(getParams());
55
67
 
56
68
  // Auth check
57
- let isAuthenticated = $state(!authProvider);
58
- let authChecked = $state(!authProvider);
69
+ let isAuthenticated = $state(false);
70
+ let authChecked = $state(false);
59
71
 
60
72
  $effect(() => {
61
- if (!authProvider) return;
73
+ if (!authProvider) {
74
+ isAuthenticated = true;
75
+ authChecked = true;
76
+ return;
77
+ }
62
78
  authProvider.check().then(result => {
63
79
  isAuthenticated = result.authenticated;
64
80
  authChecked = true;
65
- if (!result.authenticated && route !== '/login') {
81
+ if (!result.authenticated && route !== '/login' && route !== '/register' && route !== '/forgot-password' && route !== '/update-password') {
66
82
  navigate(result.redirectTo ?? '/login');
67
83
  }
68
84
  });
@@ -76,13 +92,16 @@
76
92
  </div>
77
93
  {:else if route === '/login' && loginPage}
78
94
  {@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>
84
- </div>
85
- </div>
95
+ {:else if route === '/login' && authProvider}
96
+ <LoginPage {title} onSuccess={() => { isAuthenticated = true; navigate('/'); }} />
97
+ {:else if route === '/register' && authProvider?.register}
98
+ <RegisterPage {title} />
99
+ {:else if route === '/forgot-password' && authProvider?.forgotPassword}
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" />
86
105
  {:else if isAuthenticated || !authProvider}
87
106
  <Layout {title}>
88
107
  {#if route === '/'}
@@ -118,4 +137,5 @@
118
137
  </div>
119
138
  {/if}
120
139
  <Toast />
140
+ <DevTools />
121
141
  </QueryClientProvider>
@@ -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}
@@ -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,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>