@svadmin/ui 0.17.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/AutoTable.svelte +2 -1
- package/src/components/ProfilePage.svelte +139 -25
- package/src/components/SettingsPage.svelte +103 -0
- package/src/components/Sidebar.svelte +3 -0
- 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>
|
|
@@ -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 ?? {
|
|
@@ -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>
|
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';
|