@svadmin/ui 0.15.0 → 0.18.0
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/components/AboutSettings.svelte +106 -0
- package/src/components/AdminApp.svelte +5 -1
- package/src/components/AppearanceSettings.svelte +183 -0
- package/src/components/AutoForm.svelte +46 -137
- package/src/components/AutoTable.svelte +4 -1
- package/src/components/CommandPalette.svelte +1 -1
- package/src/components/Header.svelte +1 -1
- package/src/components/KeyboardShortcuts.svelte +1 -1
- package/src/components/ProfilePage.svelte +139 -25
- package/src/components/SettingsPage.svelte +103 -0
- package/src/components/Sidebar.svelte +3 -0
- package/src/components/StatsCard.svelte +1 -1
- package/src/components/StepsForm.svelte +59 -84
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { getResources, getDataProviderNames } from '@svadmin/core';
|
|
4
|
+
import { getLocale } from '@svadmin/core/i18n';
|
|
5
|
+
import { getResolvedTheme, getColorTheme } from '@svadmin/core';
|
|
6
|
+
import * as Card from './ui/card/index.js';
|
|
7
|
+
import { Badge } from './ui/badge/index.js';
|
|
8
|
+
import { Info, Database, Globe, Palette, Layers } from 'lucide-svelte';
|
|
9
|
+
|
|
10
|
+
const resources = getResources();
|
|
11
|
+
const providerNames = getDataProviderNames();
|
|
12
|
+
|
|
13
|
+
const version = '__SVADMIN_VERSION__'; // replaced at build time or shown as-is
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<div class="space-y-6">
|
|
17
|
+
<div>
|
|
18
|
+
<h2 class="text-xl font-semibold text-foreground">{t('settings.about')}</h2>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- Version -->
|
|
22
|
+
<Card.Card>
|
|
23
|
+
<Card.CardHeader class="pb-3">
|
|
24
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
25
|
+
<Info class="h-4 w-4 text-muted-foreground" />
|
|
26
|
+
svadmin
|
|
27
|
+
</Card.CardTitle>
|
|
28
|
+
</Card.CardHeader>
|
|
29
|
+
<Card.CardContent>
|
|
30
|
+
<div class="grid gap-4 text-sm">
|
|
31
|
+
<div class="flex items-center justify-between">
|
|
32
|
+
<span class="text-muted-foreground">{t('settings.version')}</span>
|
|
33
|
+
<Badge variant="secondary" class="font-mono text-xs">{version}</Badge>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</Card.CardContent>
|
|
37
|
+
</Card.Card>
|
|
38
|
+
|
|
39
|
+
<!-- Registered Resources -->
|
|
40
|
+
<Card.Card>
|
|
41
|
+
<Card.CardHeader class="pb-3">
|
|
42
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
43
|
+
<Layers class="h-4 w-4 text-muted-foreground" />
|
|
44
|
+
{t('settings.registeredResources')}
|
|
45
|
+
</Card.CardTitle>
|
|
46
|
+
</Card.CardHeader>
|
|
47
|
+
<Card.CardContent>
|
|
48
|
+
<div class="flex flex-wrap gap-2">
|
|
49
|
+
{#each resources as resource}
|
|
50
|
+
<Badge variant="outline" class="font-mono text-xs">{resource.name}</Badge>
|
|
51
|
+
{/each}
|
|
52
|
+
{#if resources.length === 0}
|
|
53
|
+
<p class="text-sm text-muted-foreground">—</p>
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
</Card.CardContent>
|
|
57
|
+
</Card.Card>
|
|
58
|
+
|
|
59
|
+
<!-- Data Providers -->
|
|
60
|
+
<Card.Card>
|
|
61
|
+
<Card.CardHeader class="pb-3">
|
|
62
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
63
|
+
<Database class="h-4 w-4 text-muted-foreground" />
|
|
64
|
+
{t('settings.dataProvider')}
|
|
65
|
+
</Card.CardTitle>
|
|
66
|
+
</Card.CardHeader>
|
|
67
|
+
<Card.CardContent>
|
|
68
|
+
<div class="flex flex-wrap gap-2">
|
|
69
|
+
{#each providerNames as name}
|
|
70
|
+
<Badge variant="outline" class="font-mono text-xs">{name}</Badge>
|
|
71
|
+
{/each}
|
|
72
|
+
</div>
|
|
73
|
+
</Card.CardContent>
|
|
74
|
+
</Card.Card>
|
|
75
|
+
|
|
76
|
+
<!-- Current Environment -->
|
|
77
|
+
<Card.Card>
|
|
78
|
+
<Card.CardHeader class="pb-3">
|
|
79
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
80
|
+
<Globe class="h-4 w-4 text-muted-foreground" />
|
|
81
|
+
{t('settings.currentEnvironment')}
|
|
82
|
+
</Card.CardTitle>
|
|
83
|
+
</Card.CardHeader>
|
|
84
|
+
<Card.CardContent>
|
|
85
|
+
<div class="grid gap-3 text-sm">
|
|
86
|
+
<div class="flex items-center justify-between">
|
|
87
|
+
<span class="text-muted-foreground">{t('settings.language')}</span>
|
|
88
|
+
<span class="font-mono text-xs">{getLocale()}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="h-px bg-border"></div>
|
|
91
|
+
<div class="flex items-center justify-between">
|
|
92
|
+
<span class="text-muted-foreground">{t('settings.themeMode')}</span>
|
|
93
|
+
<span class="font-mono text-xs">{getResolvedTheme()}</span>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="h-px bg-border"></div>
|
|
96
|
+
<div class="flex items-center justify-between">
|
|
97
|
+
<span class="text-muted-foreground flex items-center gap-1.5">
|
|
98
|
+
<Palette class="h-3.5 w-3.5" />
|
|
99
|
+
{t('settings.colorAccent')}
|
|
100
|
+
</span>
|
|
101
|
+
<span class="font-mono text-xs">{getColorTheme()}</span>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</Card.CardContent>
|
|
105
|
+
</Card.Card>
|
|
106
|
+
</div>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider, configureTheme } from '@svadmin/core';
|
|
7
7
|
import { t } from '@svadmin/core/i18n';
|
|
8
8
|
import { navigate } from '@svadmin/core/router';
|
|
9
|
+
import { currentPath } from '@svadmin/core/router';
|
|
9
10
|
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
|
10
11
|
import { setComponentRegistry, type ComponentRegistry } from '../component-registry.svelte.js';
|
|
11
12
|
import Layout from './Layout.svelte';
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
import DevTools from './DevTools.svelte';
|
|
22
23
|
import Sidebar from './Sidebar.svelte';
|
|
23
24
|
import Header from './Header.svelte';
|
|
25
|
+
import SettingsPage from './SettingsPage.svelte';
|
|
24
26
|
import { Button } from './ui/button/index.js';
|
|
25
27
|
import { Input } from './ui/input/index.js';
|
|
26
28
|
import { Badge } from './ui/badge/index.js';
|
|
@@ -149,7 +151,9 @@
|
|
|
149
151
|
<Layout {title} {menu}>
|
|
150
152
|
{#key route + (params.resource ?? '') + (params.id ?? '')}
|
|
151
153
|
<div class="svadmin-page-enter">
|
|
152
|
-
{#if
|
|
154
|
+
{#if currentPath().startsWith('/settings')}
|
|
155
|
+
<SettingsPage />
|
|
156
|
+
{:else if route === '/' || route === ''}
|
|
153
157
|
{#if dashboard}
|
|
154
158
|
{@render dashboard()}
|
|
155
159
|
{:else}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { getTheme, setTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme, getColorPresets } from '@svadmin/core';
|
|
4
|
+
import { getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
|
|
5
|
+
import * as Card from './ui/card/index.js';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import { Sun, Moon, Monitor, Check } from 'lucide-svelte';
|
|
8
|
+
|
|
9
|
+
// Theme mode
|
|
10
|
+
const themeOptions = [
|
|
11
|
+
{ value: 'light' as const, icon: Sun, label: 'settings.light' },
|
|
12
|
+
{ value: 'dark' as const, icon: Moon, label: 'settings.dark' },
|
|
13
|
+
{ value: 'system' as const, icon: Monitor, label: 'settings.systemTheme' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
let currentTheme = $derived(getTheme());
|
|
17
|
+
let currentColor = $derived(getColorTheme());
|
|
18
|
+
let currentLocale = $derived(getLocale());
|
|
19
|
+
const availableLocales = getAvailableLocales();
|
|
20
|
+
const presets = getColorPresets();
|
|
21
|
+
|
|
22
|
+
// Sidebar density
|
|
23
|
+
const DENSITY_KEY = 'svadmin-sidebar-density';
|
|
24
|
+
let density = $state<'compact' | 'standard'>(
|
|
25
|
+
(typeof localStorage !== 'undefined' ? localStorage.getItem(DENSITY_KEY) : null) as 'compact' | 'standard' ?? 'standard'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
function setDensity(d: 'compact' | 'standard') {
|
|
29
|
+
density = d;
|
|
30
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(DENSITY_KEY, d);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Page size
|
|
34
|
+
const PAGE_SIZE_KEY = 'svadmin-default-page-size';
|
|
35
|
+
const pageSizeOptions = [10, 20, 50];
|
|
36
|
+
let pageSize = $state<number>(
|
|
37
|
+
typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem(PAGE_SIZE_KEY) ?? '10', 10) : 10
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function setPageSize(size: number) {
|
|
41
|
+
pageSize = size;
|
|
42
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(PAGE_SIZE_KEY, String(size));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Locale display names
|
|
46
|
+
const localeNames: Record<string, string> = {
|
|
47
|
+
'zh-CN': '中文(简体)',
|
|
48
|
+
'en': 'English',
|
|
49
|
+
'ja': '日本語',
|
|
50
|
+
'ko': '한국어',
|
|
51
|
+
};
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="space-y-6">
|
|
55
|
+
<div>
|
|
56
|
+
<h2 class="text-xl font-semibold text-foreground">{t('settings.appearance')}</h2>
|
|
57
|
+
<p class="text-sm text-muted-foreground mt-1">{t('settings.settingsDescription')}</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Theme Mode -->
|
|
61
|
+
<Card.Card>
|
|
62
|
+
<Card.CardHeader class="pb-3">
|
|
63
|
+
<Card.CardTitle class="text-base">{t('settings.themeMode')}</Card.CardTitle>
|
|
64
|
+
</Card.CardHeader>
|
|
65
|
+
<Card.CardContent>
|
|
66
|
+
<div class="grid grid-cols-3 gap-3">
|
|
67
|
+
{#each themeOptions as opt}
|
|
68
|
+
{@const active = currentTheme === opt.value}
|
|
69
|
+
<button
|
|
70
|
+
class="relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all duration-200
|
|
71
|
+
{active
|
|
72
|
+
? 'border-primary bg-primary/5 shadow-sm'
|
|
73
|
+
: 'border-border hover:border-muted-foreground/30 hover:bg-accent/50'}"
|
|
74
|
+
onclick={() => setTheme(opt.value)}
|
|
75
|
+
>
|
|
76
|
+
{#if active}
|
|
77
|
+
<div class="absolute top-2 right-2">
|
|
78
|
+
<Check class="h-3.5 w-3.5 text-primary" />
|
|
79
|
+
</div>
|
|
80
|
+
{/if}
|
|
81
|
+
<div class="h-10 w-10 rounded-lg flex items-center justify-center
|
|
82
|
+
{active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}">
|
|
83
|
+
<opt.icon class="h-5 w-5" />
|
|
84
|
+
</div>
|
|
85
|
+
<span class="text-xs font-medium {active ? 'text-primary' : 'text-muted-foreground'}">
|
|
86
|
+
{t(opt.label)}
|
|
87
|
+
</span>
|
|
88
|
+
</button>
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|
|
91
|
+
</Card.CardContent>
|
|
92
|
+
</Card.Card>
|
|
93
|
+
|
|
94
|
+
<!-- Color Accent -->
|
|
95
|
+
<Card.Card>
|
|
96
|
+
<Card.CardHeader class="pb-3">
|
|
97
|
+
<Card.CardTitle class="text-base">{t('settings.colorAccent')}</Card.CardTitle>
|
|
98
|
+
</Card.CardHeader>
|
|
99
|
+
<Card.CardContent>
|
|
100
|
+
<div class="flex flex-wrap gap-3">
|
|
101
|
+
{#each presets as preset}
|
|
102
|
+
{@const active = currentColor === preset.name}
|
|
103
|
+
<button
|
|
104
|
+
class="group relative h-9 w-9 rounded-full transition-all duration-200 hover:scale-110
|
|
105
|
+
{active ? 'ring-2 ring-offset-2 ring-offset-background scale-110' : 'opacity-70 hover:opacity-100'}"
|
|
106
|
+
style="background-color: {preset.color}; {active ? `--tw-ring-color: ${preset.color}` : ''}"
|
|
107
|
+
onclick={() => setColorTheme(preset.name as Parameters<typeof setColorTheme>[0])}
|
|
108
|
+
title={preset.label}
|
|
109
|
+
>
|
|
110
|
+
{#if active}
|
|
111
|
+
<Check class="h-4 w-4 text-white absolute inset-0 m-auto drop-shadow-sm" />
|
|
112
|
+
{/if}
|
|
113
|
+
</button>
|
|
114
|
+
{/each}
|
|
115
|
+
</div>
|
|
116
|
+
</Card.CardContent>
|
|
117
|
+
</Card.Card>
|
|
118
|
+
|
|
119
|
+
<!-- Interface -->
|
|
120
|
+
<Card.Card>
|
|
121
|
+
<Card.CardHeader class="pb-3">
|
|
122
|
+
<Card.CardTitle class="text-base">{t('settings.interface')}</Card.CardTitle>
|
|
123
|
+
</Card.CardHeader>
|
|
124
|
+
<Card.CardContent class="space-y-5">
|
|
125
|
+
<!-- Language -->
|
|
126
|
+
<div class="flex items-center justify-between">
|
|
127
|
+
<div>
|
|
128
|
+
<p class="text-sm font-medium text-foreground">{t('settings.language')}</p>
|
|
129
|
+
<p class="text-xs text-muted-foreground">{localeNames[currentLocale] ?? currentLocale}</p>
|
|
130
|
+
</div>
|
|
131
|
+
<select
|
|
132
|
+
class="h-9 rounded-md border border-input bg-background px-3 text-sm ring-offset-background
|
|
133
|
+
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
134
|
+
value={currentLocale}
|
|
135
|
+
onchange={(e) => setLocale((e.target as HTMLSelectElement).value)}
|
|
136
|
+
>
|
|
137
|
+
{#each availableLocales as loc}
|
|
138
|
+
<option value={loc}>{localeNames[loc] ?? loc}</option>
|
|
139
|
+
{/each}
|
|
140
|
+
</select>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div class="h-px bg-border"></div>
|
|
144
|
+
|
|
145
|
+
<!-- Sidebar Density -->
|
|
146
|
+
<div class="flex items-center justify-between">
|
|
147
|
+
<div>
|
|
148
|
+
<p class="text-sm font-medium text-foreground">{t('settings.sidebarDensity')}</p>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="flex rounded-lg border border-input overflow-hidden">
|
|
151
|
+
<button
|
|
152
|
+
class="px-3 py-1.5 text-xs font-medium transition-colors
|
|
153
|
+
{density === 'compact' ? 'bg-primary text-primary-foreground' : 'bg-background text-muted-foreground hover:bg-accent'}"
|
|
154
|
+
onclick={() => setDensity('compact')}
|
|
155
|
+
>{t('settings.compact')}</button>
|
|
156
|
+
<button
|
|
157
|
+
class="px-3 py-1.5 text-xs font-medium transition-colors
|
|
158
|
+
{density === 'standard' ? 'bg-primary text-primary-foreground' : 'bg-background text-muted-foreground hover:bg-accent'}"
|
|
159
|
+
onclick={() => setDensity('standard')}
|
|
160
|
+
>{t('settings.standard')}</button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="h-px bg-border"></div>
|
|
165
|
+
|
|
166
|
+
<!-- Default Page Size -->
|
|
167
|
+
<div class="flex items-center justify-between">
|
|
168
|
+
<div>
|
|
169
|
+
<p class="text-sm font-medium text-foreground">{t('settings.defaultPageSize')}</p>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="flex rounded-lg border border-input overflow-hidden">
|
|
172
|
+
{#each pageSizeOptions as size}
|
|
173
|
+
<button
|
|
174
|
+
class="px-3 py-1.5 text-xs font-mono font-medium transition-colors
|
|
175
|
+
{pageSize === size ? 'bg-primary text-primary-foreground' : 'bg-background text-muted-foreground hover:bg-accent'}"
|
|
176
|
+
onclick={() => setPageSize(size)}
|
|
177
|
+
>{size}</button>
|
|
178
|
+
{/each}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</Card.CardContent>
|
|
182
|
+
</Card.Card>
|
|
183
|
+
</div>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { useForm, getResource, deriveValidator } from '@svadmin/core';
|
|
3
3
|
import type { FieldDefinition } from '@svadmin/core';
|
|
4
|
-
import { navigate } from '@svadmin/core/router';
|
|
5
|
-
import { canAccess } from '@svadmin/core/permissions';
|
|
6
4
|
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import { navigate } from '@svadmin/core/router';
|
|
7
6
|
import { Button } from './ui/button/index.js';
|
|
8
7
|
import TooltipButton from './TooltipButton.svelte';
|
|
9
8
|
import * as Card from './ui/card/index.js';
|
|
@@ -13,30 +12,20 @@
|
|
|
13
12
|
import * as Alert from './ui/alert/index.js';
|
|
14
13
|
import { Skeleton } from './ui/skeleton/index.js';
|
|
15
14
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
16
|
-
|
|
17
15
|
import type { Snippet } from 'svelte';
|
|
18
16
|
|
|
19
17
|
interface Props {
|
|
20
18
|
resourceName: string;
|
|
21
19
|
id?: string | number;
|
|
22
20
|
mode?: 'create' | 'edit';
|
|
23
|
-
/** Custom field renderer — overrides default FieldRenderer */
|
|
24
21
|
fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
|
|
25
|
-
/** Custom form action buttons */
|
|
26
22
|
formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
|
|
27
|
-
/** Custom header content (after title) */
|
|
28
23
|
headerContent?: Snippet;
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
let {
|
|
32
|
-
resourceName,
|
|
33
|
-
id = undefined,
|
|
34
|
-
mode = 'create',
|
|
35
|
-
fieldRenderer,
|
|
36
|
-
formActions,
|
|
37
|
-
headerContent,
|
|
38
|
-
}: Props = $props();
|
|
26
|
+
let { resourceName, id, mode = 'create', fieldRenderer, formActions, headerContent }: Props = $props();
|
|
39
27
|
|
|
28
|
+
// ─── Resource metadata ────────────────────────────────────────────
|
|
40
29
|
const resource = $derived(getResource(resourceName));
|
|
41
30
|
const primaryKey = $derived(resource.primaryKey ?? 'id');
|
|
42
31
|
|
|
@@ -61,19 +50,7 @@
|
|
|
61
50
|
return order.map(g => ({ name: g, fields: map.get(g)! }));
|
|
62
51
|
})());
|
|
63
52
|
|
|
64
|
-
//
|
|
65
|
-
const existingQuery = $derived.by(() => mode === 'edit' && id != null
|
|
66
|
-
? useOne({ get resource() { return resourceName; }, get id() { return id; } })
|
|
67
|
-
: null);
|
|
68
|
-
|
|
69
|
-
// Form state
|
|
70
|
-
let formData = $state<Record<string, unknown>>({});
|
|
71
|
-
let fieldErrors = $state<Record<string, string>>({});
|
|
72
|
-
let submitting = $state(false);
|
|
73
|
-
let error = $state<string | null>(null);
|
|
74
|
-
let initialized = $state(false);
|
|
75
|
-
let isDirty = $state(false);
|
|
76
|
-
|
|
53
|
+
// ─── Default values from field metadata ───────────────────────────
|
|
77
54
|
function getDefaultForType(field: FieldDefinition): unknown {
|
|
78
55
|
switch (field.type) {
|
|
79
56
|
case 'text': case 'textarea': case 'richtext': case 'image': return '';
|
|
@@ -86,115 +63,47 @@
|
|
|
86
63
|
}
|
|
87
64
|
}
|
|
88
65
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
for (const field of formFields) {
|
|
95
|
-
defaults[field.key] = field.defaultValue ?? getDefaultForType(field);
|
|
96
|
-
}
|
|
97
|
-
formData = defaults;
|
|
98
|
-
initialized = true;
|
|
99
|
-
} else if (existingQuery && existingQuery.query.data?.data) {
|
|
100
|
-
formData = { ...existingQuery.query.data.data as Record<string, unknown> };
|
|
101
|
-
initialized = true;
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// Unsaved changes warning
|
|
106
|
-
$effect(() => {
|
|
107
|
-
if (!isDirty) return;
|
|
108
|
-
|
|
109
|
-
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
|
110
|
-
e.preventDefault();
|
|
111
|
-
}
|
|
66
|
+
const defaults = $derived((() => {
|
|
67
|
+
const d: Record<string, unknown> = {};
|
|
68
|
+
for (const f of formFields) d[f.key] = f.defaultValue ?? getDefaultForType(f);
|
|
69
|
+
return d;
|
|
70
|
+
})());
|
|
112
71
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
72
|
+
// ─── useForm: single source of truth for values, errors, tainted ──
|
|
73
|
+
const form = useForm({
|
|
74
|
+
get resource() { return resourceName; },
|
|
75
|
+
get action() { return mode; },
|
|
76
|
+
get id() { return id; },
|
|
77
|
+
defaultValues: defaults,
|
|
78
|
+
redirect: 'list',
|
|
79
|
+
warnWhenUnsavedChanges: true,
|
|
80
|
+
validate: deriveValidator(formFields),
|
|
117
81
|
});
|
|
118
82
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
function validateFields(): boolean {
|
|
123
|
-
const errors: Record<string, string> = {};
|
|
124
|
-
for (const field of formFields) {
|
|
125
|
-
const value = formData[field.key];
|
|
126
|
-
// Required check
|
|
127
|
-
if (field.required) {
|
|
128
|
-
if (value === undefined || value === null || value === '') {
|
|
129
|
-
errors[field.key] = t('validation.required');
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
// Custom per-field validator
|
|
134
|
-
if (field.validate) {
|
|
135
|
-
const msg = field.validate(value);
|
|
136
|
-
if (msg) { errors[field.key] = msg; }
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
fieldErrors = errors;
|
|
140
|
-
return Object.keys(errors).length === 0;
|
|
141
|
-
}
|
|
83
|
+
// ─── Submission error (non-field, e.g. network error) ─────────────
|
|
84
|
+
let submitError = $state<string | null>(null);
|
|
142
85
|
|
|
143
86
|
async function handleSubmit() {
|
|
144
|
-
|
|
145
|
-
error = null;
|
|
146
|
-
|
|
147
|
-
if (!validateFields()) {
|
|
148
|
-
submitting = false;
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
87
|
+
submitError = null;
|
|
152
88
|
try {
|
|
153
|
-
|
|
154
|
-
for (const field of formFields) {
|
|
155
|
-
const value = formData[field.key];
|
|
156
|
-
if (value !== undefined) {
|
|
157
|
-
cleanData[field.key] = value;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (mode === 'create') {
|
|
162
|
-
await createMut.mutation.mutateAsync({ variables: cleanData });
|
|
163
|
-
} else if (id != null) {
|
|
164
|
-
await updateMut.mutation.mutateAsync({ id, variables: cleanData });
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
isDirty = false;
|
|
168
|
-
navigate(`/${resourceName}`);
|
|
89
|
+
await form.submit();
|
|
169
90
|
} catch (e) {
|
|
170
|
-
|
|
171
|
-
} finally {
|
|
172
|
-
submitting = false;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function handleFieldChange(key: string, val: unknown) {
|
|
177
|
-
formData[key] = val;
|
|
178
|
-
isDirty = true;
|
|
179
|
-
// Clear field error when user starts typing
|
|
180
|
-
if (fieldErrors[key]) {
|
|
181
|
-
delete fieldErrors[key];
|
|
91
|
+
submitError = e instanceof Error ? e.message : t('common.operationFailed');
|
|
182
92
|
}
|
|
183
93
|
}
|
|
184
94
|
|
|
185
|
-
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery.query.isLoading : false);
|
|
186
|
-
|
|
187
95
|
const pageTitle = $derived(
|
|
188
96
|
mode === 'create'
|
|
189
97
|
? `${t('common.create')}${resource.label}`
|
|
190
98
|
: `${t('common.edit')}${resource.label}`
|
|
191
99
|
);
|
|
192
|
-
|
|
100
|
+
|
|
101
|
+
// ─── Unsaved changes guard ────────────────────────────────────────
|
|
193
102
|
let confirmOpen = $state(false);
|
|
194
103
|
let pendingNavigation: (() => void) | null = null;
|
|
195
104
|
|
|
196
105
|
function guardNavigate(fn: () => void) {
|
|
197
|
-
if (
|
|
106
|
+
if (form.isTainted()) {
|
|
198
107
|
pendingNavigation = fn;
|
|
199
108
|
confirmOpen = true;
|
|
200
109
|
} else {
|
|
@@ -223,12 +132,12 @@
|
|
|
223
132
|
<ArrowLeft class="h-5 w-5" />
|
|
224
133
|
</TooltipButton>
|
|
225
134
|
<h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
|
|
226
|
-
{#if
|
|
135
|
+
{#if form.isTainted()}
|
|
227
136
|
<Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{t('common.unsaved')}</Badge>
|
|
228
137
|
{/if}
|
|
229
138
|
</div>
|
|
230
139
|
|
|
231
|
-
{#if
|
|
140
|
+
{#if form.loading}
|
|
232
141
|
<div class="max-w-3xl space-y-6">
|
|
233
142
|
<div class="rounded-lg border p-6 space-y-5">
|
|
234
143
|
{#each Array(4) as _}
|
|
@@ -241,10 +150,10 @@
|
|
|
241
150
|
</div>
|
|
242
151
|
{:else}
|
|
243
152
|
<form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-3xl space-y-6">
|
|
244
|
-
{#if
|
|
153
|
+
{#if submitError}
|
|
245
154
|
<Alert.Root variant="destructive">
|
|
246
155
|
<AlertCircle class="h-4 w-4" />
|
|
247
|
-
<Alert.Description>{
|
|
156
|
+
<Alert.Description>{submitError}</Alert.Description>
|
|
248
157
|
</Alert.Root>
|
|
249
158
|
{/if}
|
|
250
159
|
|
|
@@ -258,18 +167,18 @@
|
|
|
258
167
|
{/if}
|
|
259
168
|
<Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
|
|
260
169
|
{#each group.fields as field (field.key)}
|
|
261
|
-
<div class:border-destructive={!!
|
|
170
|
+
<div class:border-destructive={!!form.errors[field.key]}>
|
|
262
171
|
{#if fieldRenderer}
|
|
263
|
-
{@render fieldRenderer({ field, value:
|
|
172
|
+
{@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
|
|
264
173
|
{:else}
|
|
265
174
|
<FieldRenderer
|
|
266
175
|
{field}
|
|
267
|
-
value={
|
|
268
|
-
onchange={(val: unknown) =>
|
|
176
|
+
value={form.values[field.key]}
|
|
177
|
+
onchange={(val: unknown) => form.setFieldValue(field.key, val)}
|
|
269
178
|
/>
|
|
270
179
|
{/if}
|
|
271
|
-
{#if
|
|
272
|
-
<p class="text-destructive text-[0.8125rem] mt-1">{
|
|
180
|
+
{#if form.errors[field.key]}
|
|
181
|
+
<p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
|
|
273
182
|
{/if}
|
|
274
183
|
</div>
|
|
275
184
|
{/each}
|
|
@@ -280,18 +189,18 @@
|
|
|
280
189
|
<Card.Root>
|
|
281
190
|
<Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
|
|
282
191
|
{#each formFields as field (field.key)}
|
|
283
|
-
<div class:border-destructive={!!
|
|
192
|
+
<div class:border-destructive={!!form.errors[field.key]}>
|
|
284
193
|
{#if fieldRenderer}
|
|
285
|
-
{@render fieldRenderer({ field, value:
|
|
194
|
+
{@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
|
|
286
195
|
{:else}
|
|
287
196
|
<FieldRenderer
|
|
288
197
|
{field}
|
|
289
|
-
value={
|
|
290
|
-
onchange={(val: unknown) =>
|
|
198
|
+
value={form.values[field.key]}
|
|
199
|
+
onchange={(val: unknown) => form.setFieldValue(field.key, val)}
|
|
291
200
|
/>
|
|
292
201
|
{/if}
|
|
293
|
-
{#if
|
|
294
|
-
<p class="text-destructive text-[0.8125rem] mt-1">{
|
|
202
|
+
{#if form.errors[field.key]}
|
|
203
|
+
<p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
|
|
295
204
|
{/if}
|
|
296
205
|
</div>
|
|
297
206
|
{/each}
|
|
@@ -301,10 +210,10 @@
|
|
|
301
210
|
|
|
302
211
|
<div class="flex items-center gap-3">
|
|
303
212
|
{#if formActions}
|
|
304
|
-
{@render formActions({ isLoading: submitting, onSubmit: handleSubmit })}
|
|
213
|
+
{@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
|
|
305
214
|
{:else}
|
|
306
|
-
<Button type="submit" disabled={submitting}>
|
|
307
|
-
{#if submitting}
|
|
215
|
+
<Button type="submit" disabled={form.submitting}>
|
|
216
|
+
{#if form.submitting}
|
|
308
217
|
<Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
|
|
309
218
|
{:else}
|
|
310
219
|
<Save class="h-4 w-4" data-icon="inline-start" />
|
|
@@ -77,7 +77,8 @@
|
|
|
77
77
|
const urlState = readURLState();
|
|
78
78
|
|
|
79
79
|
// Snapshot resource values for initial state (untrack to avoid reactive tracking)
|
|
80
|
-
const
|
|
80
|
+
const storedPageSize = typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('svadmin-default-page-size') ?? '', 10) : NaN;
|
|
81
|
+
const initPageSize = untrack(() => resource.pageSize ?? (isNaN(storedPageSize) ? 10 : storedPageSize));
|
|
81
82
|
const initDefaultSort = untrack(() => resource.defaultSort);
|
|
82
83
|
|
|
83
84
|
let pagination = $state<{ current: number; pageSize: number }>(untrack(() => externalPagination ?? {
|
|
@@ -575,6 +576,8 @@
|
|
|
575
576
|
value={cell.getValue()}
|
|
576
577
|
onSave={() => listResult.query.refetch()}
|
|
577
578
|
/>
|
|
579
|
+
{:else if field?.key === primaryKey}
|
|
580
|
+
<span class="font-mono text-xs">{cell.getValue() ?? '—'}</span>
|
|
578
581
|
{:else}
|
|
579
582
|
{cell.getValue() ?? '—'}
|
|
580
583
|
{/if}
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
placeholder={t('common.search')}
|
|
36
36
|
class="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
37
37
|
/>
|
|
38
|
-
<kbd class="ml-2 text-[10px] font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border">ESC</kbd>
|
|
38
|
+
<kbd class="ml-2 text-[10px] font-mono font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border">ESC</kbd>
|
|
39
39
|
</div>
|
|
40
40
|
<Command.List class="max-h-[320px] overflow-y-auto overflow-x-hidden p-1">
|
|
41
41
|
<Command.Empty class="py-6 text-center text-sm text-muted-foreground">
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
<Button variant="outline" size="sm" onclick={onSearchClick} class="gap-2 text-muted-foreground h-8 px-3">
|
|
59
59
|
<Search class="h-3.5 w-3.5" />
|
|
60
60
|
<span class="hidden sm:inline text-xs">Search...</span>
|
|
61
|
-
<kbd class="hidden sm:inline-flex items-center gap-0.5 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
61
|
+
<kbd class="hidden sm:inline-flex items-center gap-0.5 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium font-mono text-muted-foreground">
|
|
62
62
|
<span class="text-xs">⌘</span>K
|
|
63
63
|
</kbd>
|
|
64
64
|
</Button>
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
<span class="text-sm text-foreground">{shortcut.label}</span>
|
|
49
49
|
<div class="flex items-center gap-1">
|
|
50
50
|
{#each shortcut.keys as key}
|
|
51
|
-
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground">{key}</kbd>
|
|
51
|
+
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1.5 text-[10px] font-medium font-mono text-muted-foreground">{key}</kbd>
|
|
52
52
|
{/each}
|
|
53
53
|
</div>
|
|
54
54
|
</div>
|
|
@@ -1,16 +1,72 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { useGetIdentity, useUpdatePassword } from '@svadmin/core';
|
|
2
|
+
import { useGetIdentity, useUpdatePassword, getAuthProvider } from '@svadmin/core';
|
|
3
3
|
import { t } from '@svadmin/core/i18n';
|
|
4
4
|
import { Button } from './ui/button/index.js';
|
|
5
5
|
import { Label } from './ui/label/index.js';
|
|
6
|
+
import { Input } from './ui/input/index.js';
|
|
6
7
|
import * as Card from './ui/card/index.js';
|
|
7
8
|
import * as Alert from './ui/alert/index.js';
|
|
8
9
|
import PasswordInput from './PasswordInput.svelte';
|
|
9
|
-
import { User, Mail, Lock, Loader2, AlertCircle, CheckCircle } from 'lucide-svelte';
|
|
10
|
+
import { User, Mail, Lock, Loader2, AlertCircle, CheckCircle, Camera } from 'lucide-svelte';
|
|
10
11
|
|
|
11
12
|
const identity = useGetIdentity();
|
|
12
13
|
const updatePw = useUpdatePassword();
|
|
13
14
|
|
|
15
|
+
// Check if auth provider supports updateProfile
|
|
16
|
+
let authProvider: ReturnType<typeof getAuthProvider> | null = null;
|
|
17
|
+
try { authProvider = getAuthProvider(); } catch { /* no auth */ }
|
|
18
|
+
const canUpdateProfile = !!authProvider?.updateProfile;
|
|
19
|
+
|
|
20
|
+
// Profile edit state
|
|
21
|
+
let editingProfile = $state(false);
|
|
22
|
+
let editName = $state('');
|
|
23
|
+
let profileSaving = $state(false);
|
|
24
|
+
let profileSuccess = $state(false);
|
|
25
|
+
let profileError = $state('');
|
|
26
|
+
|
|
27
|
+
function startEditProfile() {
|
|
28
|
+
editName = identity.data?.name ?? '';
|
|
29
|
+
editingProfile = true;
|
|
30
|
+
profileSuccess = false;
|
|
31
|
+
profileError = '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function saveProfile() {
|
|
35
|
+
if (!authProvider?.updateProfile) return;
|
|
36
|
+
profileSaving = true;
|
|
37
|
+
profileError = '';
|
|
38
|
+
try {
|
|
39
|
+
const result = await authProvider.updateProfile({ name: editName });
|
|
40
|
+
if (result.success) {
|
|
41
|
+
profileSuccess = true;
|
|
42
|
+
editingProfile = false;
|
|
43
|
+
identity.refetch();
|
|
44
|
+
} else {
|
|
45
|
+
profileError = result.error?.message ?? t('common.operationFailed');
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
profileError = (e as Error).message;
|
|
49
|
+
} finally {
|
|
50
|
+
profileSaving = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Avatar upload
|
|
55
|
+
let avatarUploading = $state(false);
|
|
56
|
+
let fileInput = $state<HTMLInputElement>(null!);
|
|
57
|
+
|
|
58
|
+
async function handleAvatarChange(e: Event) {
|
|
59
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
60
|
+
if (!file || !authProvider?.updateProfile) return;
|
|
61
|
+
avatarUploading = true;
|
|
62
|
+
try {
|
|
63
|
+
const result = await authProvider.updateProfile({ avatar: file });
|
|
64
|
+
if (result.success) identity.refetch();
|
|
65
|
+
} finally {
|
|
66
|
+
avatarUploading = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
14
70
|
// Password change form
|
|
15
71
|
let currentPassword = $state('');
|
|
16
72
|
let newPassword = $state('');
|
|
@@ -49,12 +105,16 @@
|
|
|
49
105
|
);
|
|
50
106
|
</script>
|
|
51
107
|
|
|
52
|
-
<div class="
|
|
108
|
+
<div class="space-y-6">
|
|
109
|
+
<div>
|
|
110
|
+
<h2 class="text-xl font-semibold text-foreground">{t('profile.title')}</h2>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
53
113
|
<!-- Profile Info -->
|
|
54
114
|
<Card.Card>
|
|
55
115
|
<Card.CardHeader>
|
|
56
|
-
<Card.CardTitle class="flex items-center gap-2">
|
|
57
|
-
<User class="h-
|
|
116
|
+
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
117
|
+
<User class="h-4 w-4 text-muted-foreground" />
|
|
58
118
|
{t('profile.title')}
|
|
59
119
|
</Card.CardTitle>
|
|
60
120
|
</Card.CardHeader>
|
|
@@ -65,9 +125,16 @@
|
|
|
65
125
|
{t('common.loading')}
|
|
66
126
|
</div>
|
|
67
127
|
{:else if identity.data}
|
|
128
|
+
{#if profileSuccess}
|
|
129
|
+
<Alert.Root class="border-success/30 bg-success/5 text-success mb-4">
|
|
130
|
+
<CheckCircle class="h-4 w-4" />
|
|
131
|
+
<Alert.Description>{t('common.updateSuccess')}</Alert.Description>
|
|
132
|
+
</Alert.Root>
|
|
133
|
+
{/if}
|
|
134
|
+
|
|
68
135
|
<div class="flex items-start gap-6">
|
|
69
136
|
<!-- Avatar -->
|
|
70
|
-
<div class="shrink-0">
|
|
137
|
+
<div class="shrink-0 relative group">
|
|
71
138
|
{#if identity.data.avatar}
|
|
72
139
|
<img
|
|
73
140
|
src={identity.data.avatar}
|
|
@@ -79,30 +146,77 @@
|
|
|
79
146
|
{initials}
|
|
80
147
|
</div>
|
|
81
148
|
{/if}
|
|
149
|
+
{#if canUpdateProfile}
|
|
150
|
+
<button
|
|
151
|
+
class="absolute inset-0 rounded-lg bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
152
|
+
onclick={() => fileInput.click()}
|
|
153
|
+
disabled={avatarUploading}
|
|
154
|
+
>
|
|
155
|
+
{#if avatarUploading}
|
|
156
|
+
<Loader2 class="h-5 w-5 text-white animate-spin" />
|
|
157
|
+
{:else}
|
|
158
|
+
<Camera class="h-5 w-5 text-white" />
|
|
159
|
+
{/if}
|
|
160
|
+
</button>
|
|
161
|
+
<input
|
|
162
|
+
bind:this={fileInput}
|
|
163
|
+
type="file"
|
|
164
|
+
accept="image/*"
|
|
165
|
+
class="hidden"
|
|
166
|
+
onchange={handleAvatarChange}
|
|
167
|
+
/>
|
|
168
|
+
{/if}
|
|
82
169
|
</div>
|
|
83
170
|
|
|
84
171
|
<!-- Info -->
|
|
85
172
|
<div class="space-y-3 flex-1 min-w-0">
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<Mail class="h-4 w-4 shrink-0" />
|
|
95
|
-
{:else}
|
|
96
|
-
<User class="h-4 w-4 shrink-0" />
|
|
173
|
+
{#if editingProfile}
|
|
174
|
+
<div class="space-y-3 max-w-sm">
|
|
175
|
+
<div>
|
|
176
|
+
<Label for="edit-name" class="text-xs text-muted-foreground">{t('profile.name')}</Label>
|
|
177
|
+
<Input id="edit-name" bind:value={editName} class="mt-1" />
|
|
178
|
+
</div>
|
|
179
|
+
{#if profileError}
|
|
180
|
+
<p class="text-xs text-destructive">{profileError}</p>
|
|
97
181
|
{/if}
|
|
98
|
-
<
|
|
182
|
+
<div class="flex gap-2">
|
|
183
|
+
<Button size="sm" onclick={saveProfile} disabled={profileSaving}>
|
|
184
|
+
{#if profileSaving}<Loader2 class="h-3 w-3 animate-spin mr-1" />{/if}
|
|
185
|
+
{t('common.save')}
|
|
186
|
+
</Button>
|
|
187
|
+
<Button size="sm" variant="outline" onclick={() => { editingProfile = false; }}>
|
|
188
|
+
{t('common.cancel')}
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
99
191
|
</div>
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
ID: {identity.data.id}
|
|
192
|
+
{:else}
|
|
193
|
+
<div>
|
|
194
|
+
<Label class="text-xs text-muted-foreground">{t('profile.name')}</Label>
|
|
195
|
+
<p class="text-lg font-semibold truncate">{identity.data.name ?? '—'}</p>
|
|
105
196
|
</div>
|
|
197
|
+
|
|
198
|
+
{#if identity.data.email || identity.data.username}
|
|
199
|
+
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
200
|
+
{#if identity.data.email}
|
|
201
|
+
<Mail class="h-4 w-4 shrink-0" />
|
|
202
|
+
{:else}
|
|
203
|
+
<User class="h-4 w-4 shrink-0" />
|
|
204
|
+
{/if}
|
|
205
|
+
<span class="truncate">{identity.data.email || identity.data.username}</span>
|
|
206
|
+
</div>
|
|
207
|
+
{/if}
|
|
208
|
+
|
|
209
|
+
{#if identity.data.id}
|
|
210
|
+
<div class="text-xs text-muted-foreground/60 font-mono">
|
|
211
|
+
ID: {identity.data.id}
|
|
212
|
+
</div>
|
|
213
|
+
{/if}
|
|
214
|
+
|
|
215
|
+
{#if canUpdateProfile}
|
|
216
|
+
<Button size="sm" variant="outline" onclick={startEditProfile}>
|
|
217
|
+
{t('common.edit')}
|
|
218
|
+
</Button>
|
|
219
|
+
{/if}
|
|
106
220
|
{/if}
|
|
107
221
|
</div>
|
|
108
222
|
</div>
|
|
@@ -115,8 +229,8 @@
|
|
|
115
229
|
<!-- Change Password -->
|
|
116
230
|
<Card.Card>
|
|
117
231
|
<Card.CardHeader>
|
|
118
|
-
<Card.CardTitle class="flex items-center gap-2">
|
|
119
|
-
<Lock class="h-
|
|
232
|
+
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
233
|
+
<Lock class="h-4 w-4 text-muted-foreground" />
|
|
120
234
|
{t('profile.changePassword')}
|
|
121
235
|
</Card.CardTitle>
|
|
122
236
|
</Card.CardHeader>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import { currentPath } from '@svadmin/core/router';
|
|
5
|
+
import { User, Palette, Info } from 'lucide-svelte';
|
|
6
|
+
import ProfilePage from './ProfilePage.svelte';
|
|
7
|
+
import AppearanceSettings from './AppearanceSettings.svelte';
|
|
8
|
+
import AboutSettings from './AboutSettings.svelte';
|
|
9
|
+
|
|
10
|
+
const sections = [
|
|
11
|
+
{
|
|
12
|
+
group: 'settings.general',
|
|
13
|
+
items: [
|
|
14
|
+
{ key: 'profile', path: '/settings/profile', icon: User, label: 'settings.profile' },
|
|
15
|
+
{ key: 'appearance', path: '/settings/appearance', icon: Palette, label: 'settings.appearance' },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
group: 'settings.system',
|
|
20
|
+
items: [
|
|
21
|
+
{ key: 'about', path: '/settings/about', icon: Info, label: 'settings.about' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
let activeKey = $derived.by(() => {
|
|
27
|
+
const path = currentPath();
|
|
28
|
+
if (path.includes('/settings/appearance')) return 'appearance';
|
|
29
|
+
if (path.includes('/settings/about')) return 'about';
|
|
30
|
+
return 'profile';
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Default redirect to profile
|
|
34
|
+
$effect(() => {
|
|
35
|
+
const path = currentPath();
|
|
36
|
+
if (path === '/settings') {
|
|
37
|
+
navigate('/settings/profile');
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<div class="flex flex-col lg:flex-row gap-0 min-h-full">
|
|
43
|
+
<!-- Left sidebar navigation -->
|
|
44
|
+
<nav class="w-full lg:w-56 shrink-0 border-b lg:border-b-0 lg:border-r border-border bg-muted/30">
|
|
45
|
+
<!-- Mobile: horizontal scroll tabs -->
|
|
46
|
+
<div class="flex lg:hidden overflow-x-auto px-4 py-2 gap-1">
|
|
47
|
+
{#each sections as section}
|
|
48
|
+
{#each section.items as item}
|
|
49
|
+
{@const active = activeKey === item.key}
|
|
50
|
+
<button
|
|
51
|
+
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md whitespace-nowrap transition-colors
|
|
52
|
+
{active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
|
|
53
|
+
onclick={() => navigate(item.path)}
|
|
54
|
+
>
|
|
55
|
+
<item.icon class="h-4 w-4" />
|
|
56
|
+
{t(item.label)}
|
|
57
|
+
</button>
|
|
58
|
+
{/each}
|
|
59
|
+
{/each}
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Desktop: vertical nav with groups -->
|
|
63
|
+
<div class="hidden lg:block py-6 px-3 space-y-6">
|
|
64
|
+
<div class="px-3">
|
|
65
|
+
<h2 class="text-lg font-semibold text-foreground">{t('settings.title')}</h2>
|
|
66
|
+
<p class="text-xs text-muted-foreground mt-1">{t('settings.settingsDescription')}</p>
|
|
67
|
+
</div>
|
|
68
|
+
{#each sections as section}
|
|
69
|
+
<div>
|
|
70
|
+
<h3 class="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
|
71
|
+
{t(section.group)}
|
|
72
|
+
</h3>
|
|
73
|
+
<div class="space-y-0.5">
|
|
74
|
+
{#each section.items as item}
|
|
75
|
+
{@const active = activeKey === item.key}
|
|
76
|
+
<button
|
|
77
|
+
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all
|
|
78
|
+
{active
|
|
79
|
+
? 'bg-primary/10 text-primary font-medium'
|
|
80
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
|
81
|
+
onclick={() => navigate(item.path)}
|
|
82
|
+
>
|
|
83
|
+
<item.icon class="h-4 w-4 shrink-0" />
|
|
84
|
+
{t(item.label)}
|
|
85
|
+
</button>
|
|
86
|
+
{/each}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|
|
91
|
+
</nav>
|
|
92
|
+
|
|
93
|
+
<!-- Right content area -->
|
|
94
|
+
<div class="flex-1 p-6 lg:p-8 max-w-3xl">
|
|
95
|
+
{#if activeKey === 'profile'}
|
|
96
|
+
<ProfilePage />
|
|
97
|
+
{:else if activeKey === 'appearance'}
|
|
98
|
+
<AppearanceSettings />
|
|
99
|
+
{:else if activeKey === 'about'}
|
|
100
|
+
<AboutSettings />
|
|
101
|
+
{/if}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
@@ -325,6 +325,9 @@
|
|
|
325
325
|
<Moon class="h-4 w-4" />
|
|
326
326
|
{/if}
|
|
327
327
|
</TooltipButton>
|
|
328
|
+
<TooltipButton tooltip={t('settings.title')} variant="ghost" size="icon-sm" onclick={() => navigate('/settings')} class="text-sidebar-foreground/60 hover:text-sidebar-foreground">
|
|
329
|
+
<Settings class="h-4 w-4" />
|
|
330
|
+
</TooltipButton>
|
|
328
331
|
<TooltipButton tooltip={t('common.logout')} variant="ghost" size="icon-sm" onclick={onLogout} class="text-sidebar-foreground/60 hover:text-destructive">
|
|
329
332
|
<LogOut class="h-4 w-4" />
|
|
330
333
|
</TooltipButton>
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
<Skeleton class="mt-1 h-7 w-20" />
|
|
58
58
|
{:else}
|
|
59
59
|
<div class="flex items-baseline gap-2">
|
|
60
|
-
<p class="text-xl font-semibold text-foreground">{value}</p>
|
|
60
|
+
<p class="text-xl font-mono font-semibold text-foreground tracking-tight">{value}</p>
|
|
61
61
|
{#if trend}
|
|
62
62
|
<span
|
|
63
63
|
class="text-xs font-medium {trend.value >= 0 ? 'text-success' : 'text-destructive'}"
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { fly } from 'svelte/transition';
|
|
3
|
-
import { useStepsForm } from '@svadmin/core';
|
|
3
|
+
import { useStepsForm, deriveValidator } from '@svadmin/core';
|
|
4
4
|
import type { FieldDefinition } from '@svadmin/core';
|
|
5
|
-
import { getResource
|
|
5
|
+
import { getResource } from '@svadmin/core';
|
|
6
6
|
import { navigate } from '@svadmin/core/router';
|
|
7
7
|
import { t } from '@svadmin/core/i18n';
|
|
8
8
|
import { Button } from './ui/button/index.js';
|
|
9
9
|
import * as Card from './ui/card/index.js';
|
|
10
10
|
import { Badge } from './ui/badge/index.js';
|
|
11
11
|
import { Progress } from './ui/progress/index.js';
|
|
12
|
-
import { Skeleton } from './ui/skeleton/index.js';
|
|
13
12
|
import { Save, ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-svelte';
|
|
14
13
|
import FieldRenderer from './FieldRenderer.svelte';
|
|
15
14
|
|
|
@@ -30,37 +29,12 @@
|
|
|
30
29
|
const resource = $derived(getResource(resourceName));
|
|
31
30
|
const primaryKey = $derived(resource.primaryKey ?? 'id');
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
get resource() { return resourceName; },
|
|
35
|
-
get action() { return mode; },
|
|
36
|
-
get id() { return id; },
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const { currentStep, next, prev, gotoStep } = stepsForm;
|
|
40
|
-
|
|
41
|
-
const totalSteps = $derived(steps.length);
|
|
42
|
-
const progressValue = $derived(((currentStep + 1) / totalSteps) * 100);
|
|
43
|
-
const isLastStep = $derived(currentStep >= totalSteps - 1);
|
|
44
|
-
|
|
45
|
-
// Get fields for current step
|
|
46
|
-
const currentFields = $derived(
|
|
47
|
-
steps[currentStep]?.fields
|
|
48
|
-
.map(key => resource.fields.find(f => f.key === key))
|
|
49
|
-
.filter((f): f is FieldDefinition => f != null && f.key !== primaryKey) ?? []
|
|
50
|
-
);
|
|
51
|
-
|
|
32
|
+
// All fields used across all steps
|
|
52
33
|
const flatStepsFields = $derived(steps.flatMap(s => s.fields)
|
|
53
34
|
.map(key => resource.fields.find(f => f.key === key))
|
|
54
35
|
.filter((f): f is FieldDefinition => f != null));
|
|
55
36
|
|
|
56
|
-
|
|
57
|
-
? stepsForm.query as { data?: { data: Record<string, unknown> }, isLoading: boolean }
|
|
58
|
-
: null);
|
|
59
|
-
|
|
60
|
-
let formData = $state<Record<string, unknown>>({});
|
|
61
|
-
let fieldErrors = $state<Record<string, string>>({});
|
|
62
|
-
let initialized = $state(false);
|
|
63
|
-
|
|
37
|
+
// Default values from field metadata
|
|
64
38
|
function getDefaultForType(field: FieldDefinition): unknown {
|
|
65
39
|
switch (field.type) {
|
|
66
40
|
case 'text': case 'textarea': case 'richtext': case 'image': return '';
|
|
@@ -73,64 +47,65 @@
|
|
|
73
47
|
}
|
|
74
48
|
}
|
|
75
49
|
|
|
76
|
-
$
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
50
|
+
const defaults = $derived((() => {
|
|
51
|
+
const d: Record<string, unknown> = {};
|
|
52
|
+
for (const f of flatStepsFields) d[f.key] = f.defaultValue ?? getDefaultForType(f);
|
|
53
|
+
return d;
|
|
54
|
+
})());
|
|
55
|
+
|
|
56
|
+
// ─── useStepsForm: form state + step navigation ──────────────────
|
|
57
|
+
const form = useStepsForm({
|
|
58
|
+
get resource() { return resourceName; },
|
|
59
|
+
get action() { return mode; },
|
|
60
|
+
get id() { return id; },
|
|
61
|
+
defaultValues: defaults,
|
|
62
|
+
redirect: 'list',
|
|
63
|
+
warnWhenUnsavedChanges: true,
|
|
64
|
+
validate: deriveValidator(flatStepsFields),
|
|
89
65
|
});
|
|
90
66
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
67
|
+
const totalSteps = $derived(steps.length);
|
|
68
|
+
const progressValue = $derived(((form.currentStep + 1) / totalSteps) * 100);
|
|
69
|
+
const isLastStep = $derived(form.currentStep >= totalSteps - 1);
|
|
70
|
+
|
|
71
|
+
// Fields for the current step
|
|
72
|
+
const currentFields = $derived(
|
|
73
|
+
steps[form.currentStep]?.fields
|
|
74
|
+
.map(key => resource.fields.find(f => f.key === key))
|
|
75
|
+
.filter((f): f is FieldDefinition => f != null && f.key !== primaryKey) ?? []
|
|
76
|
+
);
|
|
95
77
|
|
|
78
|
+
// Validate only fields in the current step
|
|
96
79
|
function validateCurrentStep(): boolean {
|
|
97
|
-
const errors: Record<string, string> = {};
|
|
98
80
|
let valid = true;
|
|
81
|
+
form.clearErrors();
|
|
99
82
|
for (const field of currentFields) {
|
|
100
|
-
const value =
|
|
83
|
+
const value = form.values[field.key];
|
|
101
84
|
if (field.required && (value === undefined || value === null || value === '')) {
|
|
102
|
-
|
|
85
|
+
form.setFieldError(field.key, t('validation.required'));
|
|
103
86
|
valid = false;
|
|
104
87
|
} else if (field.validate) {
|
|
105
88
|
const msg = field.validate(value);
|
|
106
89
|
if (msg) {
|
|
107
|
-
|
|
90
|
+
form.setFieldError(field.key, msg);
|
|
108
91
|
valid = false;
|
|
109
92
|
}
|
|
110
93
|
}
|
|
111
94
|
}
|
|
112
|
-
fieldErrors = errors;
|
|
113
95
|
return valid;
|
|
114
96
|
}
|
|
115
97
|
|
|
116
|
-
function handleSubmit() {
|
|
98
|
+
async function handleSubmit() {
|
|
117
99
|
if (!validateCurrentStep()) return;
|
|
118
|
-
|
|
100
|
+
|
|
119
101
|
if (!isLastStep) {
|
|
120
|
-
next();
|
|
102
|
+
form.next();
|
|
121
103
|
return;
|
|
122
104
|
}
|
|
123
|
-
|
|
124
|
-
const cleanData: Record<string, unknown> = {};
|
|
125
|
-
for (const field of flatStepsFields) {
|
|
126
|
-
const value = formData[field.key];
|
|
127
|
-
if (value !== undefined) cleanData[field.key] = value;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
stepsForm.onFinish(cleanData, { redirect: 'list' });
|
|
131
|
-
}
|
|
132
105
|
|
|
133
|
-
|
|
106
|
+
// Last step — submit the full form
|
|
107
|
+
await form.submit({ redirect: 'list' });
|
|
108
|
+
}
|
|
134
109
|
</script>
|
|
135
110
|
|
|
136
111
|
<div class="space-y-6">
|
|
@@ -141,17 +116,17 @@
|
|
|
141
116
|
<Button
|
|
142
117
|
variant="ghost"
|
|
143
118
|
type="button"
|
|
144
|
-
class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
|
|
145
|
-
onclick={() => gotoStep(i)}
|
|
119
|
+
class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === form.currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
|
|
120
|
+
onclick={() => form.gotoStep(i)}
|
|
146
121
|
>
|
|
147
122
|
<span
|
|
148
123
|
class="flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors"
|
|
149
|
-
class:bg-primary={i <= currentStep}
|
|
150
|
-
class:text-primary-foreground={i <= currentStep}
|
|
151
|
-
class:bg-muted={i > currentStep}
|
|
152
|
-
class:text-muted-foreground={i > currentStep}
|
|
124
|
+
class:bg-primary={i <= form.currentStep}
|
|
125
|
+
class:text-primary-foreground={i <= form.currentStep}
|
|
126
|
+
class:bg-muted={i > form.currentStep}
|
|
127
|
+
class:text-muted-foreground={i > form.currentStep}
|
|
153
128
|
>
|
|
154
|
-
{#if i < currentStep}
|
|
129
|
+
{#if i < form.currentStep}
|
|
155
130
|
<Check class="h-3.5 w-3.5" />
|
|
156
131
|
{:else}
|
|
157
132
|
{i + 1}
|
|
@@ -160,7 +135,7 @@
|
|
|
160
135
|
<span class="hidden sm:inline">{step.title}</span>
|
|
161
136
|
</Button>
|
|
162
137
|
{#if i < steps.length - 1}
|
|
163
|
-
<div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < currentStep}></div>
|
|
138
|
+
<div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < form.currentStep}></div>
|
|
164
139
|
{/if}
|
|
165
140
|
{/each}
|
|
166
141
|
</div>
|
|
@@ -170,22 +145,22 @@
|
|
|
170
145
|
<!-- Step content -->
|
|
171
146
|
<Card.Root>
|
|
172
147
|
<Card.CardHeader>
|
|
173
|
-
<Card.CardTitle>{steps[currentStep]?.title ?? ''}</Card.CardTitle>
|
|
148
|
+
<Card.CardTitle>{steps[form.currentStep]?.title ?? ''}</Card.CardTitle>
|
|
174
149
|
<Badge variant="outline" class="w-fit">
|
|
175
|
-
{t('common.step')} {currentStep + 1} / {totalSteps}
|
|
150
|
+
{t('common.step')} {form.currentStep + 1} / {totalSteps}
|
|
176
151
|
</Badge>
|
|
177
152
|
</Card.CardHeader>
|
|
178
153
|
<Card.CardContent>
|
|
179
|
-
{#key currentStep}
|
|
154
|
+
{#key form.currentStep}
|
|
180
155
|
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} in:fly={{ x: 30, duration: 250 }}>
|
|
181
156
|
{#each currentFields as field (field.key)}
|
|
182
157
|
<FieldRenderer
|
|
183
158
|
{field}
|
|
184
|
-
value={
|
|
185
|
-
onchange={(v) =>
|
|
159
|
+
value={form.values[field.key]}
|
|
160
|
+
onchange={(v) => form.setFieldValue(field.key, v)}
|
|
186
161
|
/>
|
|
187
|
-
{#if
|
|
188
|
-
<p class="text-destructive text-[0.8125rem] mt-1">{
|
|
162
|
+
{#if form.errors[field.key]}
|
|
163
|
+
<p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
|
|
189
164
|
{/if}
|
|
190
165
|
{/each}
|
|
191
166
|
|
|
@@ -193,14 +168,14 @@
|
|
|
193
168
|
<Button
|
|
194
169
|
type="button"
|
|
195
170
|
variant="outline"
|
|
196
|
-
onclick={() => currentStep === 0 ? navigate(`/${resourceName}`) : prev()}
|
|
171
|
+
onclick={() => form.currentStep === 0 ? navigate(`/${resourceName}`) : form.prev()}
|
|
197
172
|
>
|
|
198
173
|
<ArrowLeft class="h-4 w-4" data-icon="inline-start" />
|
|
199
|
-
{currentStep === 0 ? t('common.cancel') : t('common.back')}
|
|
174
|
+
{form.currentStep === 0 ? t('common.cancel') : t('common.back')}
|
|
200
175
|
</Button>
|
|
201
176
|
|
|
202
|
-
<Button type="submit" disabled={
|
|
203
|
-
{#if
|
|
177
|
+
<Button type="submit" disabled={form.submitting}>
|
|
178
|
+
{#if form.submitting}
|
|
204
179
|
<Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
|
|
205
180
|
{:else if isLastStep}
|
|
206
181
|
<Save class="h-4 w-4" data-icon="inline-start" />
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,9 @@ export { default as KeyboardShortcuts } from "./components/KeyboardShortcuts.sve
|
|
|
59
59
|
export { default as InlineEdit } from "./components/InlineEdit.svelte";
|
|
60
60
|
export { default as VirtualTable } from "./components/VirtualTable.svelte";
|
|
61
61
|
export { default as DraggableHeader } from "./components/DraggableHeader.svelte";
|
|
62
|
+
export { default as SettingsPage } from "./components/SettingsPage.svelte";
|
|
63
|
+
export { default as AppearanceSettings } from "./components/AppearanceSettings.svelte";
|
|
64
|
+
export { default as AboutSettings } from "./components/AboutSettings.svelte";
|
|
62
65
|
|
|
63
66
|
// Dashboard Charts (zero-dependency SVG)
|
|
64
67
|
export { BarChart, LineChart, PieChart } from './components/charts/index.js';
|