@svadmin/ui 0.0.6 → 0.4.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 +8 -3
- package/src/actions.ts +110 -0
- package/src/components/AdminApp.svelte +8 -4
- package/src/components/Authenticated.svelte +38 -13
- package/src/components/AutoForm.svelte +163 -74
- package/src/components/AutoSaveIndicator.svelte +35 -0
- package/src/components/AutoTable.svelte +476 -183
- package/src/components/Breadcrumbs.svelte +22 -17
- package/src/components/CatchAllNavigate.svelte +21 -0
- package/src/components/ComboboxField.svelte +122 -0
- package/src/components/CommandPalette.svelte +93 -0
- package/src/components/ConfigErrorScreen.svelte +53 -151
- package/src/components/ConfirmDialog.svelte +25 -17
- package/src/components/CreatePage.svelte +43 -0
- package/src/components/DevTools.svelte +99 -230
- package/src/components/DrawerForm.svelte +19 -10
- package/src/components/EditPage.svelte +71 -0
- package/src/components/EmptyState.svelte +21 -31
- package/src/components/ErrorBoundary.svelte +38 -40
- package/src/components/ErrorComponent.svelte +36 -0
- package/src/components/FieldRenderer.svelte +240 -75
- package/src/components/ForgotPasswordPage.svelte +20 -111
- package/src/components/Header.svelte +46 -57
- package/src/components/InferencerPanel.svelte +136 -110
- package/src/components/InfiniteList.svelte +93 -0
- package/src/components/Layout.svelte +85 -22
- package/src/components/ListPage.svelte +71 -0
- package/src/components/LiveIndicator.svelte +58 -0
- package/src/components/LoginPage.svelte +33 -154
- package/src/components/ModalForm.svelte +6 -2
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +71 -0
- package/src/components/PasswordInput.svelte +95 -0
- package/src/components/RegisterPage.svelte +35 -164
- package/src/components/ShowPage.svelte +35 -36
- package/src/components/Sidebar.svelte +212 -81
- package/src/components/StatsCard.svelte +12 -3
- package/src/components/StepsForm.svelte +139 -0
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +15 -44
- package/src/components/TooltipButton.svelte +52 -0
- package/src/components/UndoableNotification.svelte +28 -96
- package/src/components/UpdatePasswordPage.svelte +32 -160
- package/src/components/buttons/CloneButton.svelte +1 -1
- package/src/components/buttons/CreateButton.svelte +1 -1
- package/src/components/buttons/DeleteButton.svelte +3 -4
- package/src/components/buttons/EditButton.svelte +1 -1
- package/src/components/buttons/ImportButton.svelte +29 -7
- package/src/components/buttons/RefreshButton.svelte +11 -5
- package/src/components/fieldComponentMap.ts +79 -0
- package/src/components/fields/BooleanField.svelte +13 -0
- package/src/components/fields/DateField.svelte +6 -0
- package/src/components/fields/EmailField.svelte +9 -0
- package/src/components/fields/FileField.svelte +14 -0
- package/src/components/fields/ImageField.svelte +9 -0
- package/src/components/fields/JsonField.svelte +72 -0
- package/src/components/fields/MarkdownField.svelte +9 -0
- package/src/components/fields/MultiSelectField.svelte +20 -0
- package/src/components/fields/NumberField.svelte +6 -0
- package/src/components/fields/RelationField.svelte +24 -0
- package/src/components/fields/RichTextField.svelte +35 -0
- package/src/components/fields/SelectField.svelte +14 -0
- package/src/components/fields/TagField.svelte +12 -0
- package/src/components/fields/TextField.svelte +11 -0
- package/src/components/fields/UrlField.svelte +14 -0
- package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
- package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
- package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
- package/src/components/ui/alert-dialog/index.ts +20 -0
- package/src/components/ui/avatar/avatar.svelte +2 -1
- package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
- package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
- package/src/components/ui/breadcrumb/index.ts +7 -0
- package/src/components/ui/collapsible/index.ts +7 -0
- package/src/components/ui/command/command-dialog.svelte +31 -0
- package/src/components/ui/command/index.ts +2 -0
- package/src/components/ui/context-menu/index.ts +11 -0
- package/src/components/ui/drawer/drawer-content.svelte +21 -0
- package/src/components/ui/drawer/drawer-description.svelte +11 -0
- package/src/components/ui/drawer/drawer-footer.svelte +11 -0
- package/src/components/ui/drawer/drawer-header.svelte +11 -0
- package/src/components/ui/drawer/drawer-title.svelte +11 -0
- package/src/components/ui/drawer/drawer.svelte +18 -0
- package/src/components/ui/drawer/index.ts +6 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
- package/src/components/ui/dropdown-menu/index.ts +6 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/label/label.svelte +18 -0
- package/src/components/ui/pagination/index.ts +7 -0
- package/src/components/ui/pagination/pagination-content.svelte +11 -0
- package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
- package/src/components/ui/pagination/pagination-item.svelte +11 -0
- package/src/components/ui/pagination/pagination-link.svelte +26 -0
- package/src/components/ui/pagination/pagination-next.svelte +22 -0
- package/src/components/ui/pagination/pagination-previous.svelte +22 -0
- package/src/components/ui/pagination/pagination.svelte +11 -0
- package/src/components/ui/popover/index.ts +12 -0
- package/src/components/ui/popover/popover-content.svelte +25 -0
- package/src/components/ui/progress/index.ts +1 -0
- package/src/components/ui/progress/progress.svelte +22 -0
- package/src/components/ui/scroll-area/index.ts +1 -0
- package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
- package/src/components/ui/sheet/index.ts +15 -1
- package/src/components/ui/sheet/sheet-content.svelte +28 -0
- package/src/components/ui/sheet/sheet-description.svelte +22 -0
- package/src/components/ui/sheet/sheet-footer.svelte +22 -0
- package/src/components/ui/sheet/sheet-header.svelte +22 -0
- package/src/components/ui/sheet/sheet-title.svelte +22 -0
- package/src/index.ts +52 -1
- package/src/router-state.svelte.ts +16 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"svelte": "^5.0.0",
|
|
24
|
-
"@svadmin/core": "0.
|
|
24
|
+
"@svadmin/core": "^0.4.1",
|
|
25
|
+
"@tanstack/svelte-query": "^5.0.0",
|
|
25
26
|
"bits-ui": "^2.0.0",
|
|
26
27
|
"tailwind-variants": "^3.0.0",
|
|
27
28
|
"clsx": "^2.0.0",
|
|
@@ -29,7 +30,11 @@
|
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@lucide/svelte": "^0.577.0",
|
|
32
|
-
"
|
|
33
|
+
"@tanstack/svelte-table": "^9.0.0-alpha.10",
|
|
34
|
+
"cmdk-sv": "^0.0.19",
|
|
35
|
+
"lucide-svelte": "^0.577.0",
|
|
36
|
+
"svelte-sonner": "^1.1.0",
|
|
37
|
+
"vaul-svelte": "^0.3.2"
|
|
33
38
|
},
|
|
34
39
|
"license": "MIT",
|
|
35
40
|
"author": "zuohuadong",
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Svelte Actions — reusable DOM behavior directives
|
|
2
|
+
// Usage: <div use:clickOutside={() => close()}>
|
|
3
|
+
// <div use:shortcut={{ key: 'k', ctrl: true, action: open }}>
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fires callback when clicking outside the node.
|
|
7
|
+
*/
|
|
8
|
+
export function clickOutside(node: HTMLElement, callback: () => void) {
|
|
9
|
+
const handle = (e: MouseEvent) => {
|
|
10
|
+
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
|
11
|
+
callback();
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
document.addEventListener('pointerdown', handle, true);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
update(newCallback: () => void) {
|
|
19
|
+
callback = newCallback;
|
|
20
|
+
},
|
|
21
|
+
destroy() {
|
|
22
|
+
document.removeEventListener('pointerdown', handle, true);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ShortcutParams {
|
|
28
|
+
key: string;
|
|
29
|
+
ctrl?: boolean;
|
|
30
|
+
shift?: boolean;
|
|
31
|
+
alt?: boolean;
|
|
32
|
+
action: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fires action on keyboard shortcut.
|
|
37
|
+
*/
|
|
38
|
+
export function shortcut(_node: HTMLElement, params: ShortcutParams) {
|
|
39
|
+
let current = params;
|
|
40
|
+
|
|
41
|
+
const handle = (e: KeyboardEvent) => {
|
|
42
|
+
const match =
|
|
43
|
+
e.key.toLowerCase() === current.key.toLowerCase() &&
|
|
44
|
+
(!current.ctrl || e.ctrlKey || e.metaKey) &&
|
|
45
|
+
(!current.shift || e.shiftKey) &&
|
|
46
|
+
(!current.alt || e.altKey);
|
|
47
|
+
|
|
48
|
+
if (match) {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
current.action();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
window.addEventListener('keydown', handle);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
update(newParams: ShortcutParams) {
|
|
58
|
+
current = newParams;
|
|
59
|
+
},
|
|
60
|
+
destroy() {
|
|
61
|
+
window.removeEventListener('keydown', handle);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fires callback when element enters the viewport (infinite scroll sentinel).
|
|
68
|
+
*/
|
|
69
|
+
export function intersect(node: HTMLElement, callback: () => void) {
|
|
70
|
+
const observer = new IntersectionObserver(
|
|
71
|
+
([entry]) => {
|
|
72
|
+
if (entry.isIntersecting) callback();
|
|
73
|
+
},
|
|
74
|
+
{ rootMargin: '200px' },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
observer.observe(node);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
update(newCallback: () => void) {
|
|
81
|
+
callback = newCallback;
|
|
82
|
+
},
|
|
83
|
+
destroy() {
|
|
84
|
+
observer.disconnect();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Copies text content to clipboard on click.
|
|
91
|
+
*/
|
|
92
|
+
export function copyOnClick(node: HTMLElement, text?: string) {
|
|
93
|
+
let value = text;
|
|
94
|
+
|
|
95
|
+
const handle = async () => {
|
|
96
|
+
const content = value ?? node.textContent ?? '';
|
|
97
|
+
await navigator.clipboard.writeText(content);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
node.addEventListener('click', handle);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
update(newText: string) {
|
|
104
|
+
value = newText;
|
|
105
|
+
},
|
|
106
|
+
destroy() {
|
|
107
|
+
node.removeEventListener('click', handle);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider } from '@svadmin/core';
|
|
4
4
|
import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider } from '@svadmin/core';
|
|
5
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
6
|
import { navigate } from '@svadmin/core/router';
|
|
6
7
|
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
|
7
8
|
import Layout from './Layout.svelte';
|
|
@@ -88,7 +89,10 @@
|
|
|
88
89
|
<QueryClientProvider client={queryClient}>
|
|
89
90
|
{#if !authChecked}
|
|
90
91
|
<div class="flex h-screen items-center justify-center">
|
|
91
|
-
<div class="
|
|
92
|
+
<div class="space-y-4 text-center">
|
|
93
|
+
<div class="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary mx-auto"></div>
|
|
94
|
+
<p class="text-sm text-muted-foreground">Loading...</p>
|
|
95
|
+
</div>
|
|
92
96
|
</div>
|
|
93
97
|
{:else if route === '/login' && loginPage}
|
|
94
98
|
{@render loginPage()}
|
|
@@ -109,8 +113,8 @@
|
|
|
109
113
|
{@render dashboard()}
|
|
110
114
|
{:else}
|
|
111
115
|
<div class="space-y-4">
|
|
112
|
-
<h1 class="text-2xl font-bold text-
|
|
113
|
-
<p class="text-
|
|
116
|
+
<h1 class="text-2xl font-bold text-foreground">{t('common.welcome', { title })}</h1>
|
|
117
|
+
<p class="text-muted-foreground">{t('common.dashboardHint')}</p>
|
|
114
118
|
</div>
|
|
115
119
|
{/if}
|
|
116
120
|
{:else if route === '/:resource'}
|
|
@@ -133,7 +137,7 @@
|
|
|
133
137
|
</Layout>
|
|
134
138
|
{:else}
|
|
135
139
|
<div class="flex h-screen items-center justify-center">
|
|
136
|
-
<p class="text-
|
|
140
|
+
<p class="text-muted-foreground">{t('common.redirecting')}</p>
|
|
137
141
|
</div>
|
|
138
142
|
{/if}
|
|
139
143
|
<Toast />
|
|
@@ -1,26 +1,51 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
3
2
|
import { useIsAuthenticated } from '@svadmin/core';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import { navigate } from '@svadmin/core';
|
|
4
5
|
|
|
5
|
-
let {
|
|
6
|
-
children
|
|
6
|
+
let {
|
|
7
|
+
children,
|
|
8
|
+
fallback,
|
|
9
|
+
loading,
|
|
10
|
+
redirectTo = '/login',
|
|
11
|
+
appendCurrentPathToQuery = true,
|
|
12
|
+
} = $props<{
|
|
13
|
+
children?: Snippet;
|
|
7
14
|
fallback?: Snippet;
|
|
8
15
|
loading?: Snippet;
|
|
16
|
+
redirectTo?: string;
|
|
17
|
+
appendCurrentPathToQuery?: boolean;
|
|
9
18
|
}>();
|
|
10
19
|
|
|
11
|
-
const
|
|
20
|
+
const isAuth = useIsAuthenticated();
|
|
21
|
+
|
|
22
|
+
function triggerRedirect() {
|
|
23
|
+
if (fallback) return;
|
|
24
|
+
let url = redirectTo;
|
|
25
|
+
if (appendCurrentPathToQuery && typeof window !== 'undefined') {
|
|
26
|
+
const current = encodeURIComponent(window.location.pathname + window.location.search);
|
|
27
|
+
url += `?to=${current}`;
|
|
28
|
+
}
|
|
29
|
+
navigate(url);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
$effect(() => {
|
|
33
|
+
if (!isAuth.isLoading && !isAuth.isAuthenticated) {
|
|
34
|
+
triggerRedirect();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
12
37
|
</script>
|
|
13
38
|
|
|
14
|
-
{#if
|
|
39
|
+
{#if isAuth.isLoading}
|
|
15
40
|
{#if loading}
|
|
16
41
|
{@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
42
|
{/if}
|
|
22
|
-
{:else if
|
|
23
|
-
{
|
|
24
|
-
{
|
|
25
|
-
{
|
|
43
|
+
{:else if isAuth.isAuthenticated}
|
|
44
|
+
{#if children}
|
|
45
|
+
{@render children()}
|
|
46
|
+
{/if}
|
|
47
|
+
{:else}
|
|
48
|
+
{#if fallback}
|
|
49
|
+
{@render fallback()}
|
|
50
|
+
{/if}
|
|
26
51
|
{/if}
|
|
@@ -5,16 +5,37 @@
|
|
|
5
5
|
import { canAccess } from '@svadmin/core/permissions';
|
|
6
6
|
import { t } from '@svadmin/core/i18n';
|
|
7
7
|
import { Button } from './ui/button/index.js';
|
|
8
|
+
import TooltipButton from './TooltipButton.svelte';
|
|
8
9
|
import * as Card from './ui/card/index.js';
|
|
9
10
|
import { Badge } from './ui/badge/index.js';
|
|
10
|
-
import { Save, ArrowLeft, Loader2 } from 'lucide-svelte';
|
|
11
|
+
import { Save, ArrowLeft, Loader2, AlertCircle } from 'lucide-svelte';
|
|
11
12
|
import FieldRenderer from './FieldRenderer.svelte';
|
|
13
|
+
import * as Alert from './ui/alert/index.js';
|
|
14
|
+
import { Skeleton } from './ui/skeleton/index.js';
|
|
15
|
+
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
import type { Snippet } from 'svelte';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
14
20
|
resourceName: string;
|
|
15
21
|
id?: string | number;
|
|
16
22
|
mode?: 'create' | 'edit';
|
|
17
|
-
|
|
23
|
+
/** Custom field renderer — overrides default FieldRenderer */
|
|
24
|
+
fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
|
|
25
|
+
/** Custom form action buttons */
|
|
26
|
+
formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
|
|
27
|
+
/** Custom header content (after title) */
|
|
28
|
+
headerContent?: Snippet;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
resourceName,
|
|
33
|
+
id = undefined,
|
|
34
|
+
mode = 'create',
|
|
35
|
+
fieldRenderer,
|
|
36
|
+
formActions,
|
|
37
|
+
headerContent,
|
|
38
|
+
}: Props = $props();
|
|
18
39
|
|
|
19
40
|
const resource = getResource(resourceName);
|
|
20
41
|
const primaryKey = resource.primaryKey ?? 'id';
|
|
@@ -27,6 +48,19 @@
|
|
|
27
48
|
return true;
|
|
28
49
|
});
|
|
29
50
|
|
|
51
|
+
const hasGroups = formFields.some(f => f.group);
|
|
52
|
+
const groups = (() => {
|
|
53
|
+
if (!hasGroups) return [];
|
|
54
|
+
const order: string[] = [];
|
|
55
|
+
const map = new Map<string, typeof formFields>();
|
|
56
|
+
for (const f of formFields) {
|
|
57
|
+
const g = f.group ?? '';
|
|
58
|
+
if (!map.has(g)) { order.push(g); map.set(g, []); }
|
|
59
|
+
map.get(g)!.push(f);
|
|
60
|
+
}
|
|
61
|
+
return order.map(g => ({ name: g, fields: map.get(g)! }));
|
|
62
|
+
})();
|
|
63
|
+
|
|
30
64
|
// Load existing data for edit mode
|
|
31
65
|
const existingQuery = mode === 'edit' && id != null
|
|
32
66
|
? useOne({ resource: resourceName, id })
|
|
@@ -52,8 +86,8 @@
|
|
|
52
86
|
}
|
|
53
87
|
}
|
|
54
88
|
|
|
55
|
-
// Initialize form data
|
|
56
|
-
$effect(() => {
|
|
89
|
+
// Initialize form data (pre-DOM to avoid flash of empty values)
|
|
90
|
+
$effect.pre(() => {
|
|
57
91
|
if (initialized) return;
|
|
58
92
|
if (mode === 'create') {
|
|
59
93
|
const defaults: Record<string, unknown> = {};
|
|
@@ -62,8 +96,8 @@
|
|
|
62
96
|
}
|
|
63
97
|
formData = defaults;
|
|
64
98
|
initialized = true;
|
|
65
|
-
} else if (existingQuery && existingQuery?.data) {
|
|
66
|
-
formData = { ...existingQuery.data as Record<string, unknown> };
|
|
99
|
+
} else if (existingQuery && existingQuery.query.data?.data) {
|
|
100
|
+
formData = { ...existingQuery.query.data.data as Record<string, unknown> };
|
|
67
101
|
initialized = true;
|
|
68
102
|
}
|
|
69
103
|
});
|
|
@@ -82,8 +116,8 @@
|
|
|
82
116
|
};
|
|
83
117
|
});
|
|
84
118
|
|
|
85
|
-
const createMut = useCreate(resourceName);
|
|
86
|
-
const updateMut = useUpdate(resourceName);
|
|
119
|
+
const createMut = useCreate({ resource: resourceName });
|
|
120
|
+
const updateMut = useUpdate({ resource: resourceName });
|
|
87
121
|
|
|
88
122
|
function validateFields(): boolean {
|
|
89
123
|
const errors: Record<string, string> = {};
|
|
@@ -125,9 +159,9 @@
|
|
|
125
159
|
}
|
|
126
160
|
|
|
127
161
|
if (mode === 'create') {
|
|
128
|
-
await createMut.mutateAsync(cleanData);
|
|
162
|
+
await createMut.mutation.mutateAsync({ variables: cleanData });
|
|
129
163
|
} else if (id != null) {
|
|
130
|
-
await updateMut.mutateAsync({ id, variables: cleanData });
|
|
164
|
+
await updateMut.mutation.mutateAsync({ id, variables: cleanData });
|
|
131
165
|
}
|
|
132
166
|
|
|
133
167
|
isDirty = false;
|
|
@@ -144,32 +178,50 @@
|
|
|
144
178
|
isDirty = true;
|
|
145
179
|
// Clear field error when user starts typing
|
|
146
180
|
if (fieldErrors[key]) {
|
|
147
|
-
|
|
148
|
-
delete next[key];
|
|
149
|
-
fieldErrors = next;
|
|
181
|
+
delete fieldErrors[key];
|
|
150
182
|
}
|
|
151
183
|
}
|
|
152
184
|
|
|
153
|
-
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery
|
|
185
|
+
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery.query.isLoading : false);
|
|
154
186
|
|
|
155
187
|
const pageTitle = $derived(
|
|
156
188
|
mode === 'create'
|
|
157
189
|
? `${t('common.create')}${resource.label}`
|
|
158
190
|
: `${t('common.edit')}${resource.label}`
|
|
159
191
|
);
|
|
192
|
+
// Unsaved changes dialog state
|
|
193
|
+
let confirmOpen = $state(false);
|
|
194
|
+
let pendingNavigation: (() => void) | null = null;
|
|
195
|
+
|
|
196
|
+
function guardNavigate(fn: () => void) {
|
|
197
|
+
if (isDirty) {
|
|
198
|
+
pendingNavigation = fn;
|
|
199
|
+
confirmOpen = true;
|
|
200
|
+
} else {
|
|
201
|
+
fn();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function confirmNavigate() {
|
|
206
|
+
confirmOpen = false;
|
|
207
|
+
pendingNavigation?.();
|
|
208
|
+
pendingNavigation = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cancelNavigate() {
|
|
212
|
+
confirmOpen = false;
|
|
213
|
+
pendingNavigation = null;
|
|
214
|
+
}
|
|
160
215
|
</script>
|
|
161
216
|
|
|
162
217
|
<div class="space-y-6">
|
|
163
218
|
<div class="flex items-center gap-4">
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
onclick={() => {
|
|
167
|
-
if (isDirty && !confirm(t('common.unsavedChanges'))) return;
|
|
168
|
-
navigate(`/${resourceName}`);
|
|
169
|
-
}}
|
|
219
|
+
<TooltipButton
|
|
220
|
+
tooltip={t('common.back')}
|
|
221
|
+
onclick={() => guardNavigate(() => navigate(`/${resourceName}`))}
|
|
170
222
|
>
|
|
171
223
|
<ArrowLeft class="h-5 w-5" />
|
|
172
|
-
</
|
|
224
|
+
</TooltipButton>
|
|
173
225
|
<h1 class="text-2xl font-bold text-foreground">{pageTitle}</h1>
|
|
174
226
|
{#if isDirty}
|
|
175
227
|
<Badge variant="outline" class="border-amber-200 bg-amber-50 text-amber-700">{t('common.unsaved')}</Badge>
|
|
@@ -177,68 +229,105 @@
|
|
|
177
229
|
</div>
|
|
178
230
|
|
|
179
231
|
{#if isLoading}
|
|
180
|
-
<div class="
|
|
181
|
-
<
|
|
232
|
+
<div class="max-w-3xl space-y-6">
|
|
233
|
+
<div class="rounded-xl border p-6 space-y-5">
|
|
234
|
+
{#each Array(4) as _}
|
|
235
|
+
<div class="space-y-2">
|
|
236
|
+
<Skeleton class="h-4 w-24" />
|
|
237
|
+
<Skeleton class="h-10 w-full" />
|
|
238
|
+
</div>
|
|
239
|
+
{/each}
|
|
240
|
+
</div>
|
|
182
241
|
</div>
|
|
183
242
|
{:else}
|
|
184
243
|
<form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-3xl space-y-6">
|
|
185
244
|
{#if error}
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
245
|
+
<Alert.Root variant="destructive">
|
|
246
|
+
<AlertCircle class="h-4 w-4" />
|
|
247
|
+
<Alert.Description>{error}</Alert.Description>
|
|
248
|
+
</Alert.Root>
|
|
189
249
|
{/if}
|
|
190
250
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
<
|
|
196
|
-
{
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
{#
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
251
|
+
{#if hasGroups}
|
|
252
|
+
{#each groups as group}
|
|
253
|
+
<Card.Root>
|
|
254
|
+
{#if group.name}
|
|
255
|
+
<Card.Header>
|
|
256
|
+
<Card.Title class="text-lg">{group.name}</Card.Title>
|
|
257
|
+
</Card.Header>
|
|
258
|
+
{/if}
|
|
259
|
+
<Card.Content class="space-y-5">
|
|
260
|
+
{#each group.fields as field (field.key)}
|
|
261
|
+
<div class:border-destructive={!!fieldErrors[field.key]}>
|
|
262
|
+
{#if fieldRenderer}
|
|
263
|
+
{@render fieldRenderer({ field, value: formData[field.key], onchange: (val: unknown) => handleFieldChange(field.key, val) })}
|
|
264
|
+
{:else}
|
|
265
|
+
<FieldRenderer
|
|
266
|
+
{field}
|
|
267
|
+
value={formData[field.key]}
|
|
268
|
+
onchange={(val: unknown) => handleFieldChange(field.key, val)}
|
|
269
|
+
/>
|
|
270
|
+
{/if}
|
|
271
|
+
{#if fieldErrors[field.key]}
|
|
272
|
+
<p class="text-destructive text-[0.8125rem] mt-1">{fieldErrors[field.key]}</p>
|
|
273
|
+
{/if}
|
|
274
|
+
</div>
|
|
275
|
+
{/each}
|
|
276
|
+
</Card.Content>
|
|
277
|
+
</Card.Root>
|
|
278
|
+
{/each}
|
|
279
|
+
{:else}
|
|
280
|
+
<Card.Root>
|
|
281
|
+
<Card.Content class="space-y-5">
|
|
282
|
+
{#each formFields as field (field.key)}
|
|
283
|
+
<div class:border-destructive={!!fieldErrors[field.key]}>
|
|
284
|
+
{#if fieldRenderer}
|
|
285
|
+
{@render fieldRenderer({ field, value: formData[field.key], onchange: (val: unknown) => handleFieldChange(field.key, val) })}
|
|
286
|
+
{:else}
|
|
287
|
+
<FieldRenderer
|
|
288
|
+
{field}
|
|
289
|
+
value={formData[field.key]}
|
|
290
|
+
onchange={(val: unknown) => handleFieldChange(field.key, val)}
|
|
291
|
+
/>
|
|
292
|
+
{/if}
|
|
293
|
+
{#if fieldErrors[field.key]}
|
|
294
|
+
<p class="text-destructive text-[0.8125rem] mt-1">{fieldErrors[field.key]}</p>
|
|
295
|
+
{/if}
|
|
296
|
+
</div>
|
|
297
|
+
{/each}
|
|
298
|
+
</Card.Content>
|
|
299
|
+
</Card.Root>
|
|
300
|
+
{/if}
|
|
207
301
|
|
|
208
302
|
<div class="flex items-center gap-3">
|
|
209
|
-
|
|
210
|
-
{
|
|
211
|
-
|
|
212
|
-
{
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
303
|
+
{#if formActions}
|
|
304
|
+
{@render formActions({ isLoading: submitting, onSubmit: handleSubmit })}
|
|
305
|
+
{:else}
|
|
306
|
+
<Button type="submit" disabled={submitting}>
|
|
307
|
+
{#if submitting}
|
|
308
|
+
<Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
|
|
309
|
+
{:else}
|
|
310
|
+
<Save class="h-4 w-4" data-icon="inline-start" />
|
|
311
|
+
{/if}
|
|
312
|
+
{t('common.save')}
|
|
313
|
+
</Button>
|
|
314
|
+
<Button
|
|
315
|
+
type="button"
|
|
316
|
+
variant="outline"
|
|
317
|
+
onclick={() => guardNavigate(() => navigate(`/${resourceName}`))}
|
|
318
|
+
>
|
|
319
|
+
{t('common.cancel')}
|
|
320
|
+
</Button>
|
|
321
|
+
{/if}
|
|
227
322
|
</div>
|
|
228
323
|
</form>
|
|
229
324
|
{/if}
|
|
230
325
|
</div>
|
|
231
326
|
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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>
|
|
327
|
+
<ConfirmDialog
|
|
328
|
+
open={confirmOpen}
|
|
329
|
+
message={t('common.unsavedChanges')}
|
|
330
|
+
confirmText={t('common.confirm')}
|
|
331
|
+
onconfirm={confirmNavigate}
|
|
332
|
+
oncancel={cancelNavigate}
|
|
333
|
+
/>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fade, fly } from 'svelte/transition';
|
|
3
|
+
import { Loader2, Check, X, Cloud } from 'lucide-svelte';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
status = 'idle',
|
|
8
|
+
error,
|
|
9
|
+
} = $props<{
|
|
10
|
+
/** The current auto-save status */
|
|
11
|
+
status?: 'idle' | 'loading' | 'success' | 'error';
|
|
12
|
+
/** Optional error message */
|
|
13
|
+
error?: string;
|
|
14
|
+
}>();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
18
|
+
{#key status}
|
|
19
|
+
<div class="flex items-center gap-2" in:fly={{ y: -8, duration: 200 }} out:fade={{ duration: 150 }}>
|
|
20
|
+
{#if status === 'idle'}
|
|
21
|
+
<Cloud class="h-4 w-4" />
|
|
22
|
+
<span>{t('autoSave.idle')}</span>
|
|
23
|
+
{:else if status === 'loading'}
|
|
24
|
+
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
|
25
|
+
<span>{t('autoSave.saving')}</span>
|
|
26
|
+
{:else if status === 'success'}
|
|
27
|
+
<Check class="h-4 w-4 text-green-500" />
|
|
28
|
+
<span class="text-green-500">{t('autoSave.saved')}</span>
|
|
29
|
+
{:else if status === 'error'}
|
|
30
|
+
<X class="h-4 w-4 text-destructive" />
|
|
31
|
+
<span class="text-destructive" title={error}>{t('autoSave.error')}</span>
|
|
32
|
+
{/if}
|
|
33
|
+
</div>
|
|
34
|
+
{/key}
|
|
35
|
+
</div>
|