@svadmin/ui 0.0.1
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 +44 -0
- package/src/app.css +113 -0
- package/src/components/AdminApp.svelte +127 -0
- package/src/components/AutoForm.svelte +192 -0
- package/src/components/AutoTable.svelte +343 -0
- package/src/components/Breadcrumbs.svelte +51 -0
- package/src/components/ConfirmDialog.svelte +45 -0
- package/src/components/EmptyState.svelte +41 -0
- package/src/components/ErrorBoundary.svelte +51 -0
- package/src/components/FieldRenderer.svelte +154 -0
- package/src/components/Header.svelte +66 -0
- package/src/components/Layout.svelte +66 -0
- package/src/components/PageHeader.svelte +39 -0
- package/src/components/ShowPage.svelte +71 -0
- package/src/components/Sidebar.svelte +169 -0
- package/src/components/StatsCard.svelte +49 -0
- package/src/components/Toast.svelte +48 -0
- package/src/components/ui/alert/alert-description.svelte +17 -0
- package/src/components/ui/alert/alert-title.svelte +17 -0
- package/src/components/ui/alert/alert.svelte +44 -0
- package/src/components/ui/alert/index.ts +14 -0
- package/src/components/ui/avatar/avatar.svelte +62 -0
- package/src/components/ui/avatar/index.ts +2 -0
- package/src/components/ui/badge/badge.svelte +49 -0
- package/src/components/ui/badge/index.ts +2 -0
- package/src/components/ui/button/button.svelte +82 -0
- package/src/components/ui/button/index.ts +17 -0
- package/src/components/ui/card/card-action.svelte +23 -0
- package/src/components/ui/card/card-content.svelte +20 -0
- package/src/components/ui/card/card-description.svelte +20 -0
- package/src/components/ui/card/card-footer.svelte +20 -0
- package/src/components/ui/card/card-header.svelte +23 -0
- package/src/components/ui/card/card-title.svelte +15 -0
- package/src/components/ui/card/card.svelte +22 -0
- package/src/components/ui/card/index.ts +25 -0
- package/src/components/ui/checkbox/checkbox.svelte +39 -0
- package/src/components/ui/checkbox/index.ts +6 -0
- package/src/components/ui/dialog/dialog-close.svelte +11 -0
- package/src/components/ui/dialog/dialog-content.svelte +48 -0
- package/src/components/ui/dialog/dialog-description.svelte +17 -0
- package/src/components/ui/dialog/dialog-footer.svelte +32 -0
- package/src/components/ui/dialog/dialog-header.svelte +20 -0
- package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
- package/src/components/ui/dialog/dialog-portal.svelte +7 -0
- package/src/components/ui/dialog/dialog-title.svelte +17 -0
- package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
- package/src/components/ui/dialog/dialog.svelte +7 -0
- package/src/components/ui/dialog/index.ts +34 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +30 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +36 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +10 -0
- package/src/components/ui/dropdown-menu/dropdown-menu.svelte +41 -0
- package/src/components/ui/dropdown-menu/index.ts +15 -0
- package/src/components/ui/input/index.ts +7 -0
- package/src/components/ui/input/input.svelte +48 -0
- package/src/components/ui/select/index.ts +2 -0
- package/src/components/ui/select/select.svelte +34 -0
- package/src/components/ui/separator/index.ts +7 -0
- package/src/components/ui/separator/separator.svelte +23 -0
- package/src/components/ui/sheet/index.ts +2 -0
- package/src/components/ui/sheet/sheet.svelte +77 -0
- package/src/components/ui/skeleton/index.ts +2 -0
- package/src/components/ui/skeleton/skeleton.svelte +21 -0
- package/src/components/ui/switch/index.ts +2 -0
- package/src/components/ui/switch/switch.svelte +49 -0
- package/src/components/ui/table/index.ts +28 -0
- package/src/components/ui/table/table-body.svelte +15 -0
- package/src/components/ui/table/table-caption.svelte +20 -0
- package/src/components/ui/table/table-cell.svelte +15 -0
- package/src/components/ui/table/table-footer.svelte +20 -0
- package/src/components/ui/table/table-head.svelte +15 -0
- package/src/components/ui/table/table-header.svelte +20 -0
- package/src/components/ui/table/table-row.svelte +15 -0
- package/src/components/ui/table/table.svelte +17 -0
- package/src/components/ui/tabs/index.ts +15 -0
- package/src/components/ui/tabs/tabs-content.svelte +30 -0
- package/src/components/ui/tabs/tabs-list.svelte +26 -0
- package/src/components/ui/tabs/tabs-trigger.svelte +37 -0
- package/src/components/ui/tabs/tabs.svelte +27 -0
- package/src/components/ui/textarea/index.ts +2 -0
- package/src/components/ui/textarea/textarea.svelte +24 -0
- package/src/components/ui/tooltip/index.ts +19 -0
- package/src/components/ui/tooltip/tooltip-content.svelte +52 -0
- package/src/components/ui/tooltip/tooltip-portal.svelte +7 -0
- package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
- package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
- package/src/components/ui/tooltip/tooltip.svelte +10 -0
- package/src/index.ts +43 -0
- package/src/utils.ts +28 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@svadmin/ui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"./src/app.css"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"src/**/*"
|
|
11
|
+
],
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"types": "src/index.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./app.css": "./src/app.css",
|
|
20
|
+
"./components/*": "./src/components/*"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"svelte": "^5.0.0",
|
|
24
|
+
"@svadmin/core": "0.1.0",
|
|
25
|
+
"bits-ui": "^2.0.0",
|
|
26
|
+
"tailwind-variants": "^3.0.0",
|
|
27
|
+
"clsx": "^2.0.0",
|
|
28
|
+
"tailwind-merge": "^3.0.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@lucide/svelte": "^0.577.0",
|
|
32
|
+
"lucide-svelte": "^0.577.0"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": "zuohuadong",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/zuohuadong/svadmin.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/zuohuadong/svadmin#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/zuohuadong/svadmin/issues"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/app.css
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
}
|
|
38
|
+
|
|
39
|
+
:root {
|
|
40
|
+
--radius: 0.625rem;
|
|
41
|
+
--background: oklch(1 0 0);
|
|
42
|
+
--foreground: oklch(0.145 0 0);
|
|
43
|
+
--card: oklch(1 0 0);
|
|
44
|
+
--card-foreground: oklch(0.145 0 0);
|
|
45
|
+
--popover: oklch(1 0 0);
|
|
46
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
47
|
+
--primary: oklch(0.205 0 0);
|
|
48
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
49
|
+
--secondary: oklch(0.97 0 0);
|
|
50
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
51
|
+
--muted: oklch(0.97 0 0);
|
|
52
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
53
|
+
--accent: oklch(0.97 0 0);
|
|
54
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
55
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
56
|
+
--border: oklch(0.922 0 0);
|
|
57
|
+
--input: oklch(0.922 0 0);
|
|
58
|
+
--ring: oklch(0.708 0 0);
|
|
59
|
+
--sidebar: oklch(0.985 0 0);
|
|
60
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
61
|
+
--sidebar-primary: oklch(0.205 0 0);
|
|
62
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
63
|
+
--sidebar-accent: oklch(0.97 0 0);
|
|
64
|
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
65
|
+
--sidebar-border: oklch(0.922 0 0);
|
|
66
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.dark {
|
|
70
|
+
--background: oklch(0.145 0 0);
|
|
71
|
+
--foreground: oklch(0.985 0 0);
|
|
72
|
+
--card: oklch(0.205 0 0);
|
|
73
|
+
--card-foreground: oklch(0.985 0 0);
|
|
74
|
+
--popover: oklch(0.205 0 0);
|
|
75
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
76
|
+
--primary: oklch(0.922 0 0);
|
|
77
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
78
|
+
--secondary: oklch(0.269 0 0);
|
|
79
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
80
|
+
--muted: oklch(0.269 0 0);
|
|
81
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
82
|
+
--accent: oklch(0.269 0 0);
|
|
83
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
84
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
85
|
+
--border: oklch(1 0 0 / 10%);
|
|
86
|
+
--input: oklch(1 0 0 / 15%);
|
|
87
|
+
--ring: oklch(0.556 0 0);
|
|
88
|
+
--sidebar: oklch(0.205 0 0);
|
|
89
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
90
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
91
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
92
|
+
--sidebar-accent: oklch(0.269 0 0);
|
|
93
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
94
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
95
|
+
--sidebar-ring: oklch(0.556 0 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
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
|
+
/* Scrollbar styling */
|
|
110
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
111
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
112
|
+
::-webkit-scrollbar-thumb { background: oklch(0.8 0 0); border-radius: 3px; }
|
|
113
|
+
::-webkit-scrollbar-thumb:hover { background: oklch(0.65 0 0); }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script lang="ts">
|
|
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';
|
|
5
|
+
import { matchRoute, currentPath, navigate } from '@svadmin/core/router';
|
|
6
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
|
7
|
+
import Layout from './Layout.svelte';
|
|
8
|
+
import AutoTable from './AutoTable.svelte';
|
|
9
|
+
import AutoForm from './AutoForm.svelte';
|
|
10
|
+
import ShowPage from './ShowPage.svelte';
|
|
11
|
+
import Toast from './Toast.svelte';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
dataProvider: DataProvider;
|
|
15
|
+
authProvider?: AuthProvider;
|
|
16
|
+
resources: ResourceDefinition[];
|
|
17
|
+
locale?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
defaultTheme?: ThemeMode;
|
|
20
|
+
dashboard?: Snippet;
|
|
21
|
+
loginPage?: Snippet;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
dataProvider,
|
|
26
|
+
authProvider,
|
|
27
|
+
resources,
|
|
28
|
+
locale,
|
|
29
|
+
title = 'Admin',
|
|
30
|
+
defaultTheme,
|
|
31
|
+
dashboard,
|
|
32
|
+
loginPage,
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
|
|
35
|
+
// Set up context
|
|
36
|
+
setDataProvider(dataProvider);
|
|
37
|
+
if (authProvider) setAuthProvider(authProvider);
|
|
38
|
+
setResources(resources);
|
|
39
|
+
if (locale) setLocale(locale);
|
|
40
|
+
if (defaultTheme) setTheme(defaultTheme);
|
|
41
|
+
|
|
42
|
+
const queryClient = new QueryClient({
|
|
43
|
+
defaultOptions: {
|
|
44
|
+
queries: { staleTime: 30_000, retry: 1 },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Router state
|
|
49
|
+
const routes = [
|
|
50
|
+
'/login',
|
|
51
|
+
'/',
|
|
52
|
+
'/:resource',
|
|
53
|
+
'/:resource/create',
|
|
54
|
+
'/:resource/edit/:id',
|
|
55
|
+
'/:resource/show/:id',
|
|
56
|
+
];
|
|
57
|
+
|
|
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 ?? {});
|
|
69
|
+
|
|
70
|
+
// Auth check
|
|
71
|
+
let isAuthenticated = $state(!authProvider);
|
|
72
|
+
let authChecked = $state(!authProvider);
|
|
73
|
+
|
|
74
|
+
$effect(() => {
|
|
75
|
+
if (!authProvider) return;
|
|
76
|
+
authProvider.check().then(result => {
|
|
77
|
+
isAuthenticated = result.authenticated;
|
|
78
|
+
authChecked = true;
|
|
79
|
+
if (!result.authenticated && route !== '/login') {
|
|
80
|
+
navigate(result.redirectTo ?? '/login');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<QueryClientProvider client={queryClient}>
|
|
87
|
+
{#if !authChecked}
|
|
88
|
+
<div class="flex h-screen items-center justify-center">
|
|
89
|
+
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"></div>
|
|
90
|
+
</div>
|
|
91
|
+
{:else if route === '/login' && loginPage}
|
|
92
|
+
{@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>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{:else if isAuthenticated || !authProvider}
|
|
101
|
+
<Layout {title}>
|
|
102
|
+
{#if route === '/'}
|
|
103
|
+
{#if dashboard}
|
|
104
|
+
{@render dashboard()}
|
|
105
|
+
{:else}
|
|
106
|
+
<div class="space-y-4">
|
|
107
|
+
<h1 class="text-2xl font-bold text-gray-900">Welcome to {title}</h1>
|
|
108
|
+
<p class="text-gray-500">Select a resource from the sidebar to get started.</p>
|
|
109
|
+
</div>
|
|
110
|
+
{/if}
|
|
111
|
+
{:else if route === '/:resource'}
|
|
112
|
+
<AutoTable resourceName={params.resource} />
|
|
113
|
+
{:else if route === '/:resource/create'}
|
|
114
|
+
<AutoForm resourceName={params.resource} mode="create" />
|
|
115
|
+
{:else if route === '/:resource/edit/:id'}
|
|
116
|
+
<AutoForm resourceName={params.resource} mode="edit" id={params.id} />
|
|
117
|
+
{:else if route === '/:resource/show/:id'}
|
|
118
|
+
<ShowPage resourceName={params.resource} id={params.id} />
|
|
119
|
+
{/if}
|
|
120
|
+
</Layout>
|
|
121
|
+
{:else}
|
|
122
|
+
<div class="flex h-screen items-center justify-center">
|
|
123
|
+
<p class="text-gray-500">Redirecting to login...</p>
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
<Toast />
|
|
127
|
+
</QueryClientProvider>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useOne, useCreate, useUpdate, getResource } from '@svadmin/core';
|
|
3
|
+
import type { FieldDefinition } from '@svadmin/core';
|
|
4
|
+
import { navigate } from '@svadmin/core/router';
|
|
5
|
+
import { canAccess } from '@svadmin/core/permissions';
|
|
6
|
+
import { t } from '@svadmin/core/i18n';
|
|
7
|
+
import { Button } from './ui/button/index.js';
|
|
8
|
+
import * as Card from './ui/card/index.js';
|
|
9
|
+
import { Badge } from './ui/badge/index.js';
|
|
10
|
+
import { Save, ArrowLeft, Loader2 } from 'lucide-svelte';
|
|
11
|
+
import FieldRenderer from './FieldRenderer.svelte';
|
|
12
|
+
|
|
13
|
+
let { resourceName, id = undefined, mode = 'create' } = $props<{
|
|
14
|
+
resourceName: string;
|
|
15
|
+
id?: string | number;
|
|
16
|
+
mode?: 'create' | 'edit';
|
|
17
|
+
}>();
|
|
18
|
+
|
|
19
|
+
const resource = getResource(resourceName);
|
|
20
|
+
const primaryKey = resource.primaryKey ?? 'id';
|
|
21
|
+
|
|
22
|
+
const formFields = resource.fields.filter(f => {
|
|
23
|
+
if (f.key === primaryKey) return false;
|
|
24
|
+
if (f.showInForm === false) return false;
|
|
25
|
+
if (mode === 'create' && f.showInCreate === false) return false;
|
|
26
|
+
if (mode === 'edit' && f.showInEdit === false) return false;
|
|
27
|
+
return true;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Load existing data for edit mode
|
|
31
|
+
const existingQuery = mode === 'edit' && id != null
|
|
32
|
+
? useOne({ resource: resourceName, id })
|
|
33
|
+
: null;
|
|
34
|
+
|
|
35
|
+
// Form state
|
|
36
|
+
let formData = $state<Record<string, unknown>>({});
|
|
37
|
+
let submitting = $state(false);
|
|
38
|
+
let error = $state<string | null>(null);
|
|
39
|
+
let initialized = $state(false);
|
|
40
|
+
let isDirty = $state(false);
|
|
41
|
+
|
|
42
|
+
function getDefaultForType(field: FieldDefinition): unknown {
|
|
43
|
+
switch (field.type) {
|
|
44
|
+
case 'text': case 'textarea': case 'richtext': case 'image': return '';
|
|
45
|
+
case 'number': return 0;
|
|
46
|
+
case 'boolean': return false;
|
|
47
|
+
case 'tags': case 'images': case 'multiselect': return [];
|
|
48
|
+
case 'select': return field.options?.[0]?.value ?? '';
|
|
49
|
+
case 'json': return {};
|
|
50
|
+
default: return '';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Initialize form data
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (initialized) return;
|
|
57
|
+
if (mode === 'create') {
|
|
58
|
+
const defaults: Record<string, unknown> = {};
|
|
59
|
+
for (const field of formFields) {
|
|
60
|
+
defaults[field.key] = field.defaultValue ?? getDefaultForType(field);
|
|
61
|
+
}
|
|
62
|
+
formData = defaults;
|
|
63
|
+
initialized = true;
|
|
64
|
+
} else if (existingQuery && $existingQuery?.data) {
|
|
65
|
+
formData = { ...$existingQuery.data as Record<string, unknown> };
|
|
66
|
+
initialized = true;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Unsaved changes warning
|
|
71
|
+
$effect(() => {
|
|
72
|
+
if (!isDirty) return;
|
|
73
|
+
|
|
74
|
+
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
79
|
+
return () => {
|
|
80
|
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const createMut = useCreate(resourceName);
|
|
85
|
+
const updateMut = useUpdate(resourceName);
|
|
86
|
+
|
|
87
|
+
async function handleSubmit() {
|
|
88
|
+
submitting = true;
|
|
89
|
+
error = null;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const cleanData: Record<string, unknown> = {};
|
|
93
|
+
for (const field of formFields) {
|
|
94
|
+
const value = formData[field.key];
|
|
95
|
+
if (value !== undefined) {
|
|
96
|
+
cleanData[field.key] = value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (mode === 'create') {
|
|
101
|
+
await $createMut.mutateAsync(cleanData);
|
|
102
|
+
} else if (id != null) {
|
|
103
|
+
await $updateMut.mutateAsync({ id, variables: cleanData });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isDirty = false;
|
|
107
|
+
navigate(`/${resourceName}`);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
error = e instanceof Error ? e.message : t('common.operationFailed');
|
|
110
|
+
} finally {
|
|
111
|
+
submitting = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function handleFieldChange(key: string, val: unknown) {
|
|
116
|
+
formData[key] = val;
|
|
117
|
+
isDirty = true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isLoading = $derived(mode === 'edit' && existingQuery ? $existingQuery?.isLoading : false);
|
|
121
|
+
|
|
122
|
+
const pageTitle = $derived(
|
|
123
|
+
mode === 'create'
|
|
124
|
+
? `${t('common.create')}${resource.label}`
|
|
125
|
+
: `${t('common.edit')}${resource.label}`
|
|
126
|
+
);
|
|
127
|
+
</script>
|
|
128
|
+
|
|
129
|
+
<div class="space-y-6">
|
|
130
|
+
<div class="flex items-center gap-4">
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost" size="icon"
|
|
133
|
+
onclick={() => {
|
|
134
|
+
if (isDirty && !confirm(t('common.unsavedChanges'))) return;
|
|
135
|
+
navigate(`/${resourceName}`);
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<ArrowLeft class="h-5 w-5" />
|
|
139
|
+
</Button>
|
|
140
|
+
<h1 class="text-2xl font-bold text-foreground">{pageTitle}</h1>
|
|
141
|
+
{#if isDirty}
|
|
142
|
+
<Badge variant="outline" class="border-amber-200 bg-amber-50 text-amber-700">{t('common.unsaved')}</Badge>
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{#if isLoading}
|
|
147
|
+
<div class="flex h-64 items-center justify-center">
|
|
148
|
+
<Loader2 class="h-6 w-6 animate-spin text-primary" />
|
|
149
|
+
</div>
|
|
150
|
+
{:else}
|
|
151
|
+
<form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-3xl space-y-6">
|
|
152
|
+
{#if error}
|
|
153
|
+
<div class="rounded-lg bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
|
154
|
+
{error}
|
|
155
|
+
</div>
|
|
156
|
+
{/if}
|
|
157
|
+
|
|
158
|
+
<Card.Root>
|
|
159
|
+
<Card.Content class="space-y-5">
|
|
160
|
+
{#each formFields as field (field.key)}
|
|
161
|
+
<FieldRenderer
|
|
162
|
+
{field}
|
|
163
|
+
value={formData[field.key]}
|
|
164
|
+
onchange={(val: unknown) => handleFieldChange(field.key, val)}
|
|
165
|
+
/>
|
|
166
|
+
{/each}
|
|
167
|
+
</Card.Content>
|
|
168
|
+
</Card.Root>
|
|
169
|
+
|
|
170
|
+
<div class="flex items-center gap-3">
|
|
171
|
+
<Button type="submit" disabled={submitting}>
|
|
172
|
+
{#if submitting}
|
|
173
|
+
<Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
|
|
174
|
+
{:else}
|
|
175
|
+
<Save class="h-4 w-4" data-icon="inline-start" />
|
|
176
|
+
{/if}
|
|
177
|
+
{t('common.save')}
|
|
178
|
+
</Button>
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="outline"
|
|
182
|
+
onclick={() => {
|
|
183
|
+
if (isDirty && !confirm(t('common.unsavedChanges'))) return;
|
|
184
|
+
navigate(`/${resourceName}`);
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{t('common.cancel')}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
</form>
|
|
191
|
+
{/if}
|
|
192
|
+
</div>
|