@svadmin/ui 0.0.1 → 0.0.2

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.1",
3
+ "version": "0.0.2",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/src/app.css CHANGED
@@ -1,40 +1,7 @@
1
- @import "tailwindcss";
2
- @import "tw-animate-css";
3
-
4
- @custom-variant dark (&:is(.dark *));
5
-
6
- @theme inline {
7
- --color-background: var(--background);
8
- --color-foreground: var(--foreground);
9
- --color-card: var(--card);
10
- --color-card-foreground: var(--card-foreground);
11
- --color-popover: var(--popover);
12
- --color-popover-foreground: var(--popover-foreground);
13
- --color-primary: var(--primary);
14
- --color-primary-foreground: var(--primary-foreground);
15
- --color-secondary: var(--secondary);
16
- --color-secondary-foreground: var(--secondary-foreground);
17
- --color-muted: var(--muted);
18
- --color-muted-foreground: var(--muted-foreground);
19
- --color-accent: var(--accent);
20
- --color-accent-foreground: var(--accent-foreground);
21
- --color-destructive: var(--destructive);
22
- --color-border: var(--border);
23
- --color-input: var(--input);
24
- --color-ring: var(--ring);
25
- --color-sidebar: var(--sidebar);
26
- --color-sidebar-foreground: var(--sidebar-foreground);
27
- --color-sidebar-primary: var(--sidebar-primary);
28
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
29
- --color-sidebar-accent: var(--sidebar-accent);
30
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
31
- --color-sidebar-border: var(--sidebar-border);
32
- --color-sidebar-ring: var(--sidebar-ring);
33
- --radius-sm: calc(var(--radius) - 4px);
34
- --radius-md: calc(var(--radius) - 2px);
35
- --radius-lg: var(--radius);
36
- --radius-xl: calc(var(--radius) + 4px);
37
- }
1
+ /* @svadmin/ui — Design tokens (CSS custom properties)
2
+ * Import this in your app's CSS AFTER @import "tailwindcss"
3
+ * or import it standalone for the color variables.
4
+ */
38
5
 
39
6
  :root {
40
7
  --radius: 0.625rem;
@@ -95,17 +62,6 @@
95
62
  --sidebar-ring: oklch(0.556 0 0);
96
63
  }
97
64
 
98
- @layer base {
99
- * {
100
- @apply border-border;
101
- }
102
- body {
103
- @apply bg-background text-foreground m-0;
104
- font-family: 'Inter', system-ui, -apple-system, sans-serif;
105
- -webkit-font-smoothing: antialiased;
106
- }
107
- }
108
-
109
65
  /* Scrollbar styling */
110
66
  ::-webkit-scrollbar { width: 6px; height: 6px; }
111
67
  ::-webkit-scrollbar-track { background: transparent; }
@@ -2,13 +2,14 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode } from '@svadmin/core';
4
4
  import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme } from '@svadmin/core';
5
- import { matchRoute, currentPath, navigate } from '@svadmin/core/router';
5
+ import { navigate } from '@svadmin/core/router';
6
6
  import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
7
7
  import Layout from './Layout.svelte';
8
8
  import AutoTable from './AutoTable.svelte';
9
9
  import AutoForm from './AutoForm.svelte';
10
10
  import ShowPage from './ShowPage.svelte';
11
11
  import Toast from './Toast.svelte';
12
+ import { initRouter, getRoute, getParams } from '../router-state.svelte.js';
12
13
 
13
14
  interface Props {
14
15
  dataProvider: DataProvider;
@@ -45,27 +46,12 @@
45
46
  },
46
47
  });
47
48
 
48
- // Router state
49
- const routes = [
50
- '/login',
51
- '/',
52
- '/:resource',
53
- '/:resource/create',
54
- '/:resource/edit/:id',
55
- '/:resource/show/:id',
56
- ];
49
+ // Initialize hash router
50
+ initRouter();
57
51
 
58
- let hash = $state(window.location.hash);
59
-
60
- $effect(() => {
61
- const handler = () => { hash = window.location.hash; };
62
- window.addEventListener('hashchange', handler);
63
- return () => window.removeEventListener('hashchange', handler);
64
- });
65
-
66
- const match = $derived(matchRoute(hash, routes));
67
- const route = $derived(match?.route ?? '/');
68
- const params = $derived(match?.params ?? {});
52
+ // Reactive getters for route state
53
+ const route = $derived(getRoute());
54
+ const params = $derived(getParams());
69
55
 
70
56
  // Auth check
71
57
  let isAuthenticated = $state(!authProvider);
@@ -104,18 +90,26 @@
104
90
  {@render dashboard()}
105
91
  {:else}
106
92
  <div class="space-y-4">
107
- <h1 class="text-2xl font-bold text-gray-900">Welcome to {title}</h1>
93
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Welcome to {title}</h1>
108
94
  <p class="text-gray-500">Select a resource from the sidebar to get started.</p>
109
95
  </div>
110
96
  {/if}
111
97
  {:else if route === '/:resource'}
112
- <AutoTable resourceName={params.resource} />
98
+ {#key params.resource}
99
+ <AutoTable resourceName={params.resource} />
100
+ {/key}
113
101
  {:else if route === '/:resource/create'}
114
- <AutoForm resourceName={params.resource} mode="create" />
102
+ {#key params.resource}
103
+ <AutoForm resourceName={params.resource} mode="create" />
104
+ {/key}
115
105
  {:else if route === '/:resource/edit/:id'}
116
- <AutoForm resourceName={params.resource} mode="edit" id={params.id} />
106
+ {#key `${params.resource}-${params.id}`}
107
+ <AutoForm resourceName={params.resource} mode="edit" id={params.id} />
108
+ {/key}
117
109
  {:else if route === '/:resource/show/:id'}
118
- <ShowPage resourceName={params.resource} id={params.id} />
110
+ {#key `${params.resource}-${params.id}`}
111
+ <ShowPage resourceName={params.resource} id={params.id} />
112
+ {/key}
119
113
  {/if}
120
114
  </Layout>
121
115
  {:else}
@@ -61,8 +61,8 @@
61
61
  }
62
62
  formData = defaults;
63
63
  initialized = true;
64
- } else if (existingQuery && $existingQuery?.data) {
65
- formData = { ...$existingQuery.data as Record<string, unknown> };
64
+ } else if (existingQuery && existingQuery?.data) {
65
+ formData = { ...existingQuery.data as Record<string, unknown> };
66
66
  initialized = true;
67
67
  }
68
68
  });
@@ -98,9 +98,9 @@
98
98
  }
99
99
 
100
100
  if (mode === 'create') {
101
- await $createMut.mutateAsync(cleanData);
101
+ await createMut.mutateAsync(cleanData);
102
102
  } else if (id != null) {
103
- await $updateMut.mutateAsync({ id, variables: cleanData });
103
+ await updateMut.mutateAsync({ id, variables: cleanData });
104
104
  }
105
105
 
106
106
  isDirty = false;
@@ -117,7 +117,7 @@
117
117
  isDirty = true;
118
118
  }
119
119
 
120
- const isLoading = $derived(mode === 'edit' && existingQuery ? $existingQuery?.isLoading : false);
120
+ const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery?.isLoading : false);
121
121
 
122
122
  const pageTitle = $derived(
123
123
  mode === 'create'
@@ -102,7 +102,7 @@
102
102
  selectedIds = new Set();
103
103
  selectAll = false;
104
104
  } else {
105
- const allIds = ($query.data?.data ?? []).map(r => r[primaryKey] as string | number);
105
+ const allIds = (query.data?.data ?? []).map(r => r[primaryKey] as string | number);
106
106
  selectedIds = new Set(allIds);
107
107
  selectAll = true;
108
108
  }
@@ -111,7 +111,7 @@
111
111
  function confirmDelete(id: string | number) {
112
112
  confirmMessage = t('common.deleteConfirm');
113
113
  confirmAction = async () => {
114
- await $deleteMutation.mutateAsync(id);
114
+ await deleteMutation.mutateAsync(id);
115
115
  confirmOpen = false;
116
116
  };
117
117
  confirmOpen = true;
@@ -121,7 +121,7 @@
121
121
  confirmMessage = t('common.batchDeleteConfirm', { count: selectedIds.size });
122
122
  confirmAction = async () => {
123
123
  for (const id of selectedIds) {
124
- await $deleteMutation.mutateAsync(id);
124
+ await deleteMutation.mutateAsync(id);
125
125
  }
126
126
  selectedIds = new Set();
127
127
  selectAll = false;
@@ -132,7 +132,7 @@
132
132
 
133
133
  // CSV Export
134
134
  function exportCSV() {
135
- const data = $query.data?.data ?? [];
135
+ const data = query.data?.data ?? [];
136
136
  if (data.length === 0) return;
137
137
 
138
138
  const headers = listFields.map(f => f.label);
@@ -173,7 +173,7 @@
173
173
  const canDelete = canAccess(resourceName, 'delete').can && resource.canDelete !== false;
174
174
  const canExport = canAccess(resourceName, 'export').can;
175
175
 
176
- const totalPages = $derived(Math.ceil(($query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
176
+ const totalPages = $derived(Math.ceil((query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
177
177
  </script>
178
178
 
179
179
  <div class="space-y-4">
@@ -217,14 +217,14 @@
217
217
  {/if}
218
218
 
219
219
  <!-- Table -->
220
- <div class="rounded-xl border border-border bg-card shadow-sm">
221
- {#if $query.isLoading}
220
+ <div class="rounded-xl border border-border/60 bg-card shadow-md overflow-hidden">
221
+ {#if query.isLoading}
222
222
  <div class="flex h-64 items-center justify-center">
223
223
  <Loader2 class="h-6 w-6 animate-spin text-primary" />
224
224
  </div>
225
- {:else if $query.error}
225
+ {:else if query.error}
226
226
  <div class="flex h-64 items-center justify-center text-destructive text-sm">
227
- {t('common.loadFailed', { message: ($query.error as Error).message })}
227
+ {t('common.loadFailed', { message: (query.error as Error).message })}
228
228
  </div>
229
229
  {:else}
230
230
  <Table.Root>
@@ -247,9 +247,9 @@
247
247
  </Table.Row>
248
248
  </Table.Header>
249
249
  <Table.Body>
250
- {#each $query.data?.data ?? [] as record}
250
+ {#each query.data?.data ?? [] as record}
251
251
  {@const id = record[primaryKey] as string | number}
252
- <Table.Row class={selectedIds.has(id) ? 'bg-accent' : ''}>
252
+ <Table.Row class="transition-colors {selectedIds.has(id) ? 'bg-accent' : ''}">
253
253
  {#if canDelete}
254
254
  <Table.Cell>
255
255
  <Checkbox checked={selectedIds.has(id)} onCheckedChange={() => toggleSelect(id)} />
@@ -313,7 +313,7 @@
313
313
 
314
314
  <!-- Pagination -->
315
315
  <div class="flex items-center justify-between text-sm text-muted-foreground">
316
- <span>{t('common.total', { total: $query.data?.total ?? 0 })}</span>
316
+ <span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
317
317
  <div class="flex items-center gap-2">
318
318
  <Button
319
319
  variant="outline" size="icon-sm"
@@ -51,10 +51,10 @@
51
51
  <Loader2 class="h-8 w-8 animate-spin text-primary" />
52
52
  </div>
53
53
  {:else}
54
- <div class="flex h-screen">
54
+ <div class="flex h-screen bg-gradient-to-br from-background via-background to-muted/30">
55
55
  <Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
56
56
  <main
57
- class="flex-1 overflow-y-auto p-6 transition-all duration-300"
57
+ class="flex-1 overflow-y-auto p-8 transition-all duration-300"
58
58
  class:ml-64={!collapsed}
59
59
  class:ml-16={collapsed}
60
60
  >
@@ -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,37 @@
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
+ '/',
11
+ '/:resource',
12
+ '/:resource/create',
13
+ '/:resource/edit/:id',
14
+ '/:resource/show/:id',
15
+ ];
16
+
17
+ function sync() {
18
+ const hash = window.location.hash;
19
+ const m = matchRoute(hash, ROUTES);
20
+ _route = m?.route ?? '/';
21
+ _params = m?.params ?? {};
22
+ }
23
+
24
+ export function initRouter() {
25
+ if (_initialized) return;
26
+ _initialized = true;
27
+ sync();
28
+ window.addEventListener('hashchange', sync);
29
+ }
30
+
31
+ export function getRoute(): string {
32
+ return _route;
33
+ }
34
+
35
+ export function getParams(): Record<string, string> {
36
+ return _params;
37
+ }