@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 +1 -1
- package/src/app.css +4 -48
- package/src/components/AdminApp.svelte +37 -32
- package/src/components/AutoForm.svelte +62 -10
- package/src/components/AutoTable.svelte +12 -12
- package/src/components/CanAccess.svelte +21 -0
- package/src/components/DevTools.svelte +231 -0
- package/src/components/DrawerForm.svelte +26 -0
- package/src/components/ForgotPasswordPage.svelte +213 -0
- package/src/components/Layout.svelte +2 -2
- package/src/components/LoginPage.svelte +245 -0
- package/src/components/ModalForm.svelte +34 -0
- package/src/components/RegisterPage.svelte +252 -0
- package/src/components/ShowPage.svelte +3 -3
- package/src/components/Sidebar.svelte +43 -7
- package/src/components/UndoableNotification.svelte +132 -0
- package/src/index.ts +8 -0
- package/src/router-state.svelte.ts +39 -0
package/package.json
CHANGED
package/src/app.css
CHANGED
|
@@ -1,40 +1,7 @@
|
|
|
1
|
-
@
|
|
2
|
-
@import "
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
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
|
-
//
|
|
49
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
$
|
|
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
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
108
|
+
{#key params.resource}
|
|
109
|
+
<AutoTable resourceName={params.resource} />
|
|
110
|
+
{/key}
|
|
113
111
|
{:else if route === '/:resource/create'}
|
|
114
|
-
|
|
112
|
+
{#key params.resource}
|
|
113
|
+
<AutoForm resourceName={params.resource} mode="create" />
|
|
114
|
+
{/key}
|
|
115
115
|
{:else if route === '/:resource/edit/:id'}
|
|
116
|
-
|
|
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
|
-
|
|
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 &&
|
|
65
|
-
formData = {
|
|
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
|
|
128
|
+
await createMut.mutateAsync(cleanData);
|
|
102
129
|
} else if (id != null) {
|
|
103
|
-
await
|
|
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 ?
|
|
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
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 = (
|
|
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
|
|
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
|
|
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 =
|
|
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((
|
|
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-
|
|
221
|
-
{#if
|
|
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
|
|
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: (
|
|
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
|
|
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:
|
|
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}
|