@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.0.1",
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": [
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,18 @@
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 LoginPage from './LoginPage.svelte';
13
+ import RegisterPage from './RegisterPage.svelte';
14
+ import ForgotPasswordPage from './ForgotPasswordPage.svelte';
15
+ import DevTools from './DevTools.svelte';
16
+ import { initRouter, getRoute, getParams } from '../router-state.svelte.js';
12
17
 
13
18
  interface Props {
14
19
  dataProvider: DataProvider;
@@ -45,27 +50,12 @@
45
50
  },
46
51
  });
47
52
 
48
- // Router state
49
- const routes = [
50
- '/login',
51
- '/',
52
- '/:resource',
53
- '/:resource/create',
54
- '/:resource/edit/:id',
55
- '/:resource/show/:id',
56
- ];
53
+ // Initialize hash router
54
+ initRouter();
57
55
 
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 ?? {});
56
+ // Reactive getters for route state
57
+ const route = $derived(getRoute());
58
+ const params = $derived(getParams());
69
59
 
70
60
  // Auth check
71
61
  let isAuthenticated = $state(!authProvider);
@@ -76,7 +66,7 @@
76
66
  authProvider.check().then(result => {
77
67
  isAuthenticated = result.authenticated;
78
68
  authChecked = true;
79
- if (!result.authenticated && route !== '/login') {
69
+ if (!result.authenticated && route !== '/login' && route !== '/register' && route !== '/forgot-password') {
80
70
  navigate(result.redirectTo ?? '/login');
81
71
  }
82
72
  });
@@ -90,11 +80,17 @@
90
80
  </div>
91
81
  {:else if route === '/login' && loginPage}
92
82
  {@render loginPage()}
93
- {:else if route === '/login'}
94
- <div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
95
- <div class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-xl text-center">
96
- <h1 class="text-xl font-bold text-gray-900">{title}</h1>
97
- <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>
98
94
  </div>
99
95
  </div>
100
96
  {:else if isAuthenticated || !authProvider}
@@ -104,18 +100,26 @@
104
100
  {@render dashboard()}
105
101
  {:else}
106
102
  <div class="space-y-4">
107
- <h1 class="text-2xl font-bold text-gray-900">Welcome to {title}</h1>
103
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Welcome to {title}</h1>
108
104
  <p class="text-gray-500">Select a resource from the sidebar to get started.</p>
109
105
  </div>
110
106
  {/if}
111
107
  {:else if route === '/:resource'}
112
- <AutoTable resourceName={params.resource} />
108
+ {#key params.resource}
109
+ <AutoTable resourceName={params.resource} />
110
+ {/key}
113
111
  {:else if route === '/:resource/create'}
114
- <AutoForm resourceName={params.resource} mode="create" />
112
+ {#key params.resource}
113
+ <AutoForm resourceName={params.resource} mode="create" />
114
+ {/key}
115
115
  {:else if route === '/:resource/edit/:id'}
116
- <AutoForm resourceName={params.resource} mode="edit" id={params.id} />
116
+ {#key `${params.resource}-${params.id}`}
117
+ <AutoForm resourceName={params.resource} mode="edit" id={params.id} />
118
+ {/key}
117
119
  {:else if route === '/:resource/show/:id'}
118
- <ShowPage resourceName={params.resource} id={params.id} />
120
+ {#key `${params.resource}-${params.id}`}
121
+ <ShowPage resourceName={params.resource} id={params.id} />
122
+ {/key}
119
123
  {/if}
120
124
  </Layout>
121
125
  {:else}
@@ -124,4 +128,5 @@
124
128
  </div>
125
129
  {/if}
126
130
  <Toast />
131
+ <DevTools />
127
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);
@@ -61,8 +62,8 @@
61
62
  }
62
63
  formData = defaults;
63
64
  initialized = true;
64
- } else if (existingQuery && $existingQuery?.data) {
65
- formData = { ...$existingQuery.data as Record<string, unknown> };
65
+ } else if (existingQuery && existingQuery?.data) {
66
+ formData = { ...existingQuery.data as Record<string, unknown> };
66
67
  initialized = true;
67
68
  }
68
69
  });
@@ -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) {
@@ -98,9 +125,9 @@
98
125
  }
99
126
 
100
127
  if (mode === 'create') {
101
- await $createMut.mutateAsync(cleanData);
128
+ await createMut.mutateAsync(cleanData);
102
129
  } else if (id != null) {
103
- await $updateMut.mutateAsync({ id, variables: cleanData });
130
+ await updateMut.mutateAsync({ id, variables: cleanData });
104
131
  }
105
132
 
106
133
  isDirty = false;
@@ -115,9 +142,15 @@
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
- const isLoading = $derived(mode === 'edit' && existingQuery ? $existingQuery?.isLoading : false);
153
+ const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery?.isLoading : false);
121
154
 
122
155
  const pageTitle = $derived(
123
156
  mode === 'create'
@@ -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>
@@ -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"
@@ -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}