@svadmin/ui 0.17.0 → 0.19.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.
Files changed (30) hide show
  1. package/package.json +20 -2
  2. package/src/components/AboutSettings.svelte +106 -0
  3. package/src/components/AdminApp.svelte +5 -1
  4. package/src/components/AppearanceSettings.svelte +183 -0
  5. package/src/components/AuditLogViewer.svelte +226 -0
  6. package/src/components/AutoTable.svelte +12 -6
  7. package/src/components/CanAccess.svelte +1 -1
  8. package/src/components/DraggableGrid.svelte +90 -0
  9. package/src/components/FieldRenderer.svelte +4 -0
  10. package/src/components/MarkdownRenderer.svelte +0 -4
  11. package/src/components/PermissionMatrix.svelte +190 -0
  12. package/src/components/ProfilePage.svelte +139 -25
  13. package/src/components/RolesSettings.svelte +144 -0
  14. package/src/components/SettingsPage.svelte +113 -0
  15. package/src/components/Sidebar.svelte +3 -0
  16. package/src/components/TaskQueueDrawer.svelte +170 -0
  17. package/src/components/TenantSwitcher.svelte +90 -0
  18. package/src/components/buttons/CloneButton.svelte +3 -3
  19. package/src/components/buttons/CreateButton.svelte +3 -3
  20. package/src/components/buttons/DeleteButton.svelte +3 -3
  21. package/src/components/buttons/EditButton.svelte +3 -3
  22. package/src/components/buttons/ExportButton.svelte +3 -3
  23. package/src/components/buttons/ImportButton.svelte +3 -3
  24. package/src/components/buttons/ShowButton.svelte +3 -3
  25. package/src/components/fields/ArrayField.svelte +86 -0
  26. package/src/index.ts +14 -0
  27. package/src/markdown-deps.d.ts +21 -0
  28. package/src/components/stories/BarChart.stories.ts +0 -43
  29. package/src/components/stories/LineChart.stories.ts +0 -48
  30. package/src/components/stories/PieChart.stories.ts +0 -41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -26,7 +26,25 @@
26
26
  "bits-ui": "^2.0.0",
27
27
  "tailwind-variants": "^3.0.0",
28
28
  "clsx": "^2.0.0",
29
- "tailwind-merge": "^3.0.0"
29
+ "tailwind-merge": "^3.0.0",
30
+ "marked": "^15.0.0",
31
+ "marked-highlight": "^2.2.0",
32
+ "highlight.js": "^11.0.0",
33
+ "isomorphic-dompurify": "^2.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "marked": {
37
+ "optional": true
38
+ },
39
+ "marked-highlight": {
40
+ "optional": true
41
+ },
42
+ "highlight.js": {
43
+ "optional": true
44
+ },
45
+ "isomorphic-dompurify": {
46
+ "optional": true
47
+ }
30
48
  },
31
49
  "dependencies": {
32
50
  "@lucide/svelte": "^0.577.0",
@@ -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 route === '/'}
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>
@@ -0,0 +1,226 @@
1
+ <script lang="ts">
2
+ import { t } from '@svadmin/core/i18n';
3
+ import { getAuthProvider } from '@svadmin/core';
4
+ import type { AuditLog } from '@svadmin/core';
5
+ import { toast } from '@svadmin/core/toast';
6
+ import * as Table from './ui/table/index.js';
7
+ import { Badge } from './ui/badge/index.js';
8
+ import { Button } from './ui/button/index.js';
9
+ import { Input } from './ui/input/index.js';
10
+ import { Loader2, ChevronLeft, ChevronRight, Search, FileSearch, Eye, X } from 'lucide-svelte';
11
+
12
+ const authProvider = getAuthProvider();
13
+
14
+ let logs = $state<AuditLog[]>([]);
15
+ let total = $state(0);
16
+ let page = $state(1);
17
+ let pageSize = 20;
18
+ let loading = $state(true);
19
+ let error = $state<string | null>(null);
20
+ let searchQuery = $state('');
21
+
22
+ // Snapshot drawer
23
+ let drawerOpen = $state(false);
24
+ let drawerLog = $state<AuditLog | null>(null);
25
+
26
+ async function loadLogs() {
27
+ if (!authProvider?.getAuditLogs) {
28
+ error = t('settings.auditNotSupported') ?? 'Audit logs are not supported by the current AuthProvider';
29
+ loading = false;
30
+ return;
31
+ }
32
+ loading = true;
33
+ try {
34
+ const result = await authProvider.getAuditLogs({ page, pageSize });
35
+ logs = result.data;
36
+ total = result.total;
37
+ } catch (e) {
38
+ toast.error((e as Error).message);
39
+ } finally {
40
+ loading = false;
41
+ }
42
+ }
43
+
44
+ $effect(() => {
45
+ loadLogs();
46
+ });
47
+
48
+ let totalPages = $derived(Math.ceil(total / pageSize) || 1);
49
+
50
+ let filteredLogs = $derived(
51
+ searchQuery.trim()
52
+ ? logs.filter(l =>
53
+ (l.userName ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
54
+ l.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
55
+ (l.resource ?? '').toLowerCase().includes(searchQuery.toLowerCase())
56
+ )
57
+ : logs
58
+ );
59
+
60
+ function openSnapshot(log: AuditLog) {
61
+ drawerLog = log;
62
+ drawerOpen = true;
63
+ }
64
+
65
+ function formatDate(d: string | Date) {
66
+ return new Date(d).toLocaleString();
67
+ }
68
+
69
+ function getActionColor(action: string): 'default' | 'secondary' | 'destructive' | 'outline' {
70
+ const a = action.toLowerCase();
71
+ if (a.includes('delete') || a.includes('remove')) return 'destructive';
72
+ if (a.includes('create') || a.includes('add')) return 'default';
73
+ if (a.includes('update') || a.includes('edit')) return 'secondary';
74
+ return 'outline';
75
+ }
76
+ </script>
77
+
78
+ <div class="h-full flex flex-col">
79
+ <!-- Header -->
80
+ <div class="mb-6">
81
+ <h2 class="text-xl font-semibold text-foreground flex items-center gap-2">
82
+ <FileSearch class="h-5 w-5 text-muted-foreground" />
83
+ {t('settings.auditLogs') ?? 'Audit Logs'}
84
+ </h2>
85
+ <p class="text-sm text-muted-foreground mt-1">
86
+ {t('settings.auditDescription') ?? 'Track and review all system operations for compliance and debugging.'}
87
+ </p>
88
+ </div>
89
+
90
+ {#if error}
91
+ <div class="p-6 border border-destructive/20 bg-destructive/5 text-destructive rounded-lg text-center">
92
+ <p>{error}</p>
93
+ </div>
94
+ {:else}
95
+ <!-- Search Bar -->
96
+ <div class="mb-4 relative">
97
+ <Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
98
+ <Input
99
+ bind:value={searchQuery}
100
+ placeholder={t('settings.searchLogs') ?? 'Search by user, action, or resource...'}
101
+ class="pl-9"
102
+ />
103
+ </div>
104
+
105
+ <!-- Table -->
106
+ <div class="flex-1 border rounded-lg bg-card overflow-hidden">
107
+ {#if loading}
108
+ <div class="flex items-center justify-center h-48 text-muted-foreground">
109
+ <Loader2 class="h-5 w-5 animate-spin mr-2" />
110
+ {t('common.loading')}
111
+ </div>
112
+ {:else}
113
+ <div class="overflow-auto">
114
+ <Table.Root>
115
+ <Table.Header>
116
+ <Table.Row>
117
+ <Table.Head class="w-[160px]">{t('settings.auditTime') ?? 'Time'}</Table.Head>
118
+ <Table.Head>{t('settings.auditUser') ?? 'User'}</Table.Head>
119
+ <Table.Head>{t('settings.auditAction') ?? 'Action'}</Table.Head>
120
+ <Table.Head>{t('settings.auditResource') ?? 'Resource'}</Table.Head>
121
+ <Table.Head class="w-[120px]">{t('settings.auditIp') ?? 'IP Address'}</Table.Head>
122
+ <Table.Head class="w-[60px] text-center">{t('common.detail') ?? 'Detail'}</Table.Head>
123
+ </Table.Row>
124
+ </Table.Header>
125
+ <Table.Body>
126
+ {#each filteredLogs as log}
127
+ <Table.Row class="hover:bg-muted/50 transition-colors">
128
+ <Table.Cell class="text-xs text-muted-foreground font-mono">
129
+ {formatDate(log.createdAt)}
130
+ </Table.Cell>
131
+ <Table.Cell class="text-sm font-medium">{log.userName ?? log.userId ?? '—'}</Table.Cell>
132
+ <Table.Cell>
133
+ <Badge variant={getActionColor(log.action)}>{log.action}</Badge>
134
+ </Table.Cell>
135
+ <Table.Cell class="text-sm text-muted-foreground">{log.resource ?? '—'}</Table.Cell>
136
+ <Table.Cell class="text-xs text-muted-foreground font-mono">{log.ipAddress ?? '—'}</Table.Cell>
137
+ <Table.Cell class="text-center">
138
+ {#if log.details}
139
+ <Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => openSnapshot(log)}>
140
+ <Eye class="h-3.5 w-3.5" />
141
+ </Button>
142
+ {/if}
143
+ </Table.Cell>
144
+ </Table.Row>
145
+ {:else}
146
+ <Table.Row>
147
+ <Table.Cell colspan={6} class="h-24 text-center text-muted-foreground">
148
+ {t('common.noData')}
149
+ </Table.Cell>
150
+ </Table.Row>
151
+ {/each}
152
+ </Table.Body>
153
+ </Table.Root>
154
+ </div>
155
+ {/if}
156
+ </div>
157
+
158
+ <!-- Pagination -->
159
+ <div class="mt-4 flex items-center justify-between text-sm text-muted-foreground">
160
+ <span>{total} {t('settings.auditTotal') ?? 'total records'}</span>
161
+ <div class="flex items-center gap-2">
162
+ <Button
163
+ variant="outline" size="sm"
164
+ disabled={page <= 1}
165
+ onclick={() => { page--; loadLogs(); }}
166
+ >
167
+ <ChevronLeft class="h-4 w-4" />
168
+ </Button>
169
+ <span>{page} / {totalPages}</span>
170
+ <Button
171
+ variant="outline" size="sm"
172
+ disabled={page >= totalPages}
173
+ onclick={() => { page++; loadLogs(); }}
174
+ >
175
+ <ChevronRight class="h-4 w-4" />
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ {/if}
180
+ </div>
181
+
182
+ <!-- Snapshot Drawer -->
183
+ {#if drawerOpen && drawerLog}
184
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
185
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
186
+ <div class="fixed inset-0 z-[100] flex justify-end bg-black/40 backdrop-blur-sm" onclick={() => drawerOpen = false}>
187
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
188
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
189
+ <div class="bg-card shadow-2xl w-full max-w-2xl h-full flex flex-col animate-in slide-in-from-right duration-300" onclick={(e) => e.stopPropagation()}>
190
+ <!-- Drawer Header -->
191
+ <div class="p-6 border-b border-border flex items-center justify-between bg-muted/30">
192
+ <div>
193
+ <h2 class="text-lg font-bold text-foreground flex items-center gap-2">
194
+ <FileSearch class="h-5 w-5 text-primary" />
195
+ {t('settings.auditSnapshot') ?? 'Change Snapshot'}
196
+ </h2>
197
+ <p class="text-xs text-muted-foreground mt-1">{formatDate(drawerLog.createdAt)} · {drawerLog.userName ?? drawerLog.userId}</p>
198
+ </div>
199
+ <button onclick={() => drawerOpen = false} class="text-muted-foreground hover:text-foreground transition p-2 rounded-lg hover:bg-muted">
200
+ <X class="w-5 h-5" />
201
+ </button>
202
+ </div>
203
+
204
+ <!-- Meta Tags -->
205
+ <div class="px-6 py-3 border-b border-border flex items-center gap-2 flex-wrap">
206
+ <Badge variant="outline">{drawerLog.action}</Badge>
207
+ {#if drawerLog.resource}
208
+ <Badge variant="secondary">{drawerLog.resource}</Badge>
209
+ {/if}
210
+ {#if drawerLog.ipAddress}
211
+ <Badge variant="outline" class="font-mono text-xs">{drawerLog.ipAddress}</Badge>
212
+ {/if}
213
+ </div>
214
+
215
+ <!-- JSON Diff Viewer -->
216
+ <div class="flex-1 p-6 overflow-y-auto bg-foreground/[0.03] dark:bg-background/50">
217
+ <pre class="whitespace-pre-wrap text-xs font-mono leading-relaxed text-foreground/80 bg-muted/30 rounded-lg p-4 border border-border">{typeof drawerLog.details === 'object' ? JSON.stringify(drawerLog.details, null, 2) : drawerLog.details}</pre>
218
+ </div>
219
+
220
+ <!-- Footer -->
221
+ <div class="p-4 border-t border-border bg-muted/20 text-right text-[11px] text-muted-foreground font-medium">
222
+ System audit snapshot · Read-only
223
+ </div>
224
+ </div>
225
+ </div>
226
+ {/if}
@@ -13,7 +13,7 @@
13
13
  import { useList, useDelete, getResource } from '@svadmin/core';
14
14
  import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
15
15
  import { navigate } from '@svadmin/core/router';
16
- import { canAccess } from '@svadmin/core/permissions';
16
+ import { useCan, getAccessControlProvider } from '@svadmin/core';
17
17
  import { readURLState, writeURLState } from '@svadmin/core';
18
18
  import { t } from '@svadmin/core/i18n';
19
19
  import { fade } from 'svelte/transition';
@@ -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 initPageSize = untrack(() => resource.pageSize ?? 10);
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 ?? {
@@ -135,10 +136,15 @@
135
136
  const deleteMutation = deleteResult.mutation;
136
137
 
137
138
  // ─── Permissions ──────────────────────────────────────────────
138
- const canCreate = $derived(canAccess(resourceName, 'create').can && resource.canCreate !== false);
139
- const canEdit = $derived(canAccess(resourceName, 'edit').can && resource.canEdit !== false);
140
- const canDelete = $derived(canAccess(resourceName, 'delete').can && resource.canDelete !== false);
141
- const canExport = $derived(canAccess(resourceName, 'export').can);
139
+ const acEnabled = $derived(!!getAccessControlProvider());
140
+ const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
141
+ const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
142
+ const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
143
+ const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
144
+ const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
145
+ const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
146
+ const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
147
+ const canExport = $derived(canExportPerm.allowed);
142
148
 
143
149
  // ─── TanStack Table state ────────────────────────────────────
144
150
  let sorting = $state<SortingState>(untrack(() =>
@@ -11,7 +11,7 @@
11
11
  fallback?: Snippet;
12
12
  }>();
13
13
 
14
- const can = $derived(useCan(resource, action, params));
14
+ const can = useCan(() => ({ resource, action, params }));
15
15
  </script>
16
16
 
17
17
  {#if can.isLoading}