@svadmin/ui 0.0.3 → 0.3.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 +3 -2
- package/src/components/AdminApp.svelte +31 -21
- package/src/components/Authenticated.svelte +57 -0
- package/src/components/AutoForm.svelte +5 -5
- package/src/components/AutoSaveIndicator.svelte +30 -0
- package/src/components/AutoTable.svelte +81 -10
- package/src/components/Breadcrumbs.svelte +2 -0
- package/src/components/CatchAllNavigate.svelte +21 -0
- package/src/components/ConfigErrorScreen.svelte +224 -0
- package/src/components/ConfirmDialog.svelte +1 -1
- package/src/components/CreatePage.svelte +42 -0
- package/src/components/DevTools.svelte +83 -34
- package/src/components/EditPage.svelte +52 -0
- package/src/components/EmptyState.svelte +17 -31
- package/src/components/ErrorComponent.svelte +35 -0
- package/src/components/ForgotPasswordPage.svelte +12 -22
- package/src/components/Header.svelte +28 -59
- package/src/components/InferencerPanel.svelte +166 -0
- package/src/components/ListPage.svelte +47 -0
- package/src/components/LiveIndicator.svelte +115 -0
- package/src/components/LoginPage.svelte +13 -22
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +70 -0
- package/src/components/RegisterPage.svelte +12 -23
- package/src/components/ShowPage.svelte +3 -3
- package/src/components/Sidebar.svelte +29 -10
- package/src/components/StatsCard.svelte +23 -2
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +21 -11
- package/src/components/UpdatePasswordPage.svelte +217 -0
- package/src/components/buttons/CloneButton.svelte +30 -0
- package/src/components/buttons/CreateButton.svelte +29 -0
- package/src/components/buttons/DeleteButton.svelte +59 -0
- package/src/components/buttons/EditButton.svelte +30 -0
- package/src/components/buttons/ExportButton.svelte +24 -0
- package/src/components/buttons/ImportButton.svelte +50 -0
- package/src/components/buttons/ListButton.svelte +23 -0
- package/src/components/buttons/RefreshButton.svelte +31 -0
- package/src/components/buttons/SaveButton.svelte +27 -0
- package/src/components/buttons/ShowButton.svelte +24 -0
- package/src/components/buttons/index.ts +10 -0
- package/src/components/fields/BooleanField.svelte +13 -0
- package/src/components/fields/DateField.svelte +6 -0
- package/src/components/fields/EmailField.svelte +15 -0
- package/src/components/fields/FileField.svelte +19 -0
- package/src/components/fields/ImageField.svelte +14 -0
- package/src/components/fields/MarkdownField.svelte +19 -0
- package/src/components/fields/NumberField.svelte +6 -0
- package/src/components/fields/TagField.svelte +17 -0
- package/src/components/fields/TextField.svelte +16 -0
- package/src/components/fields/UrlField.svelte +20 -0
- package/src/components/ui/avatar/avatar.svelte +2 -1
- package/src/index.ts +32 -0
- package/src/router-state.svelte.ts +33 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"svelte": "^5.0.0",
|
|
24
|
-
"@svadmin/core": "0.
|
|
24
|
+
"@svadmin/core": "^0.3.0",
|
|
25
|
+
"@tanstack/svelte-query": "^5.0.0",
|
|
25
26
|
"bits-ui": "^2.0.0",
|
|
26
27
|
"tailwind-variants": "^3.0.0",
|
|
27
28
|
"clsx": "^2.0.0",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
-
import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode } from '@svadmin/core';
|
|
4
|
-
import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme } from '@svadmin/core';
|
|
3
|
+
import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider } from '@svadmin/core';
|
|
4
|
+
import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider } from '@svadmin/core';
|
|
5
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
6
|
import { navigate } from '@svadmin/core/router';
|
|
6
7
|
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
|
7
8
|
import Layout from './Layout.svelte';
|
|
@@ -12,12 +13,15 @@
|
|
|
12
13
|
import LoginPage from './LoginPage.svelte';
|
|
13
14
|
import RegisterPage from './RegisterPage.svelte';
|
|
14
15
|
import ForgotPasswordPage from './ForgotPasswordPage.svelte';
|
|
16
|
+
import UpdatePasswordPage from './UpdatePasswordPage.svelte';
|
|
17
|
+
import ConfigErrorScreen from './ConfigErrorScreen.svelte';
|
|
15
18
|
import DevTools from './DevTools.svelte';
|
|
16
19
|
import { initRouter, getRoute, getParams } from '../router-state.svelte.js';
|
|
17
20
|
|
|
18
21
|
interface Props {
|
|
19
22
|
dataProvider: DataProvider;
|
|
20
23
|
authProvider?: AuthProvider;
|
|
24
|
+
routerProvider?: RouterProvider;
|
|
21
25
|
resources: ResourceDefinition[];
|
|
22
26
|
locale?: string;
|
|
23
27
|
title?: string;
|
|
@@ -29,6 +33,7 @@
|
|
|
29
33
|
let {
|
|
30
34
|
dataProvider,
|
|
31
35
|
authProvider,
|
|
36
|
+
routerProvider,
|
|
32
37
|
resources,
|
|
33
38
|
locale,
|
|
34
39
|
title = 'Admin',
|
|
@@ -37,10 +42,14 @@
|
|
|
37
42
|
loginPage,
|
|
38
43
|
}: Props = $props();
|
|
39
44
|
|
|
45
|
+
// Resolve router provider (default to hash)
|
|
46
|
+
const resolvedRouter = routerProvider ?? createHashRouterProvider();
|
|
47
|
+
|
|
40
48
|
// Set up context
|
|
41
49
|
setDataProvider(dataProvider);
|
|
42
50
|
if (authProvider) setAuthProvider(authProvider);
|
|
43
51
|
setResources(resources);
|
|
52
|
+
setRouterProvider(resolvedRouter);
|
|
44
53
|
if (locale) setLocale(locale);
|
|
45
54
|
if (defaultTheme) setTheme(defaultTheme);
|
|
46
55
|
|
|
@@ -50,23 +59,27 @@
|
|
|
50
59
|
},
|
|
51
60
|
});
|
|
52
61
|
|
|
53
|
-
// Initialize
|
|
54
|
-
initRouter();
|
|
62
|
+
// Initialize router with provider
|
|
63
|
+
initRouter(resolvedRouter);
|
|
55
64
|
|
|
56
65
|
// Reactive getters for route state
|
|
57
66
|
const route = $derived(getRoute());
|
|
58
67
|
const params = $derived(getParams());
|
|
59
68
|
|
|
60
69
|
// Auth check
|
|
61
|
-
let isAuthenticated = $state(
|
|
62
|
-
let authChecked = $state(
|
|
70
|
+
let isAuthenticated = $state(false);
|
|
71
|
+
let authChecked = $state(false);
|
|
63
72
|
|
|
64
73
|
$effect(() => {
|
|
65
|
-
if (!authProvider)
|
|
74
|
+
if (!authProvider) {
|
|
75
|
+
isAuthenticated = true;
|
|
76
|
+
authChecked = true;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
66
79
|
authProvider.check().then(result => {
|
|
67
80
|
isAuthenticated = result.authenticated;
|
|
68
81
|
authChecked = true;
|
|
69
|
-
if (!result.authenticated && route !== '/login' && route !== '/register' && route !== '/forgot-password') {
|
|
82
|
+
if (!result.authenticated && route !== '/login' && route !== '/register' && route !== '/forgot-password' && route !== '/update-password') {
|
|
70
83
|
navigate(result.redirectTo ?? '/login');
|
|
71
84
|
}
|
|
72
85
|
});
|
|
@@ -81,18 +94,15 @@
|
|
|
81
94
|
{:else if route === '/login' && loginPage}
|
|
82
95
|
{@render loginPage()}
|
|
83
96
|
{:else if route === '/login' && authProvider}
|
|
84
|
-
<LoginPage {
|
|
97
|
+
<LoginPage {title} onSuccess={() => { isAuthenticated = true; navigate('/'); }} />
|
|
85
98
|
{:else if route === '/register' && authProvider?.register}
|
|
86
|
-
<RegisterPage {
|
|
99
|
+
<RegisterPage {title} />
|
|
87
100
|
{:else if route === '/forgot-password' && authProvider?.forgotPassword}
|
|
88
|
-
<ForgotPasswordPage {
|
|
89
|
-
{:else if route === '/
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<p class="mt-2 text-sm text-gray-500">Please configure an authProvider.</p>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
101
|
+
<ForgotPasswordPage {title} />
|
|
102
|
+
{:else if route === '/update-password' && authProvider?.updatePassword}
|
|
103
|
+
<UpdatePasswordPage {title} />
|
|
104
|
+
{:else if route === '/login' || route === '/register' || route === '/forgot-password' || route === '/update-password'}
|
|
105
|
+
<ConfigErrorScreen title="{title} — Configuration Required" />
|
|
96
106
|
{:else if isAuthenticated || !authProvider}
|
|
97
107
|
<Layout {title}>
|
|
98
108
|
{#if route === '/'}
|
|
@@ -100,8 +110,8 @@
|
|
|
100
110
|
{@render dashboard()}
|
|
101
111
|
{:else}
|
|
102
112
|
<div class="space-y-4">
|
|
103
|
-
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
104
|
-
<p class="text-gray-500">
|
|
113
|
+
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('common.welcome', { title })}</h1>
|
|
114
|
+
<p class="text-gray-500">{t('common.dashboardHint')}</p>
|
|
105
115
|
</div>
|
|
106
116
|
{/if}
|
|
107
117
|
{:else if route === '/:resource'}
|
|
@@ -124,7 +134,7 @@
|
|
|
124
134
|
</Layout>
|
|
125
135
|
{:else}
|
|
126
136
|
<div class="flex h-screen items-center justify-center">
|
|
127
|
-
<p class="text-gray-500">
|
|
137
|
+
<p class="text-gray-500">{t('common.redirecting')}</p>
|
|
128
138
|
</div>
|
|
129
139
|
{/if}
|
|
130
140
|
<Toast />
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useIsAuthenticated } from '@svadmin/core';
|
|
3
|
+
import { onMount, type Snippet } from 'svelte';
|
|
4
|
+
import { navigate } from '@svadmin/core';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
children,
|
|
8
|
+
fallback,
|
|
9
|
+
loading,
|
|
10
|
+
redirectTo = '/login',
|
|
11
|
+
appendCurrentPathToQuery = true,
|
|
12
|
+
} = $props<{
|
|
13
|
+
children?: Snippet;
|
|
14
|
+
fallback?: Snippet;
|
|
15
|
+
loading?: Snippet;
|
|
16
|
+
redirectTo?: string;
|
|
17
|
+
appendCurrentPathToQuery?: boolean;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const isAuth = useIsAuthenticated();
|
|
21
|
+
|
|
22
|
+
function triggerRedirect() {
|
|
23
|
+
if (fallback) return;
|
|
24
|
+
let url = redirectTo;
|
|
25
|
+
if (appendCurrentPathToQuery && typeof window !== 'undefined') {
|
|
26
|
+
const current = encodeURIComponent(window.location.pathname + window.location.search);
|
|
27
|
+
url += `?to=${current}`;
|
|
28
|
+
}
|
|
29
|
+
navigate(url);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onMount(() => {
|
|
33
|
+
if (!isAuth.isLoading && !isAuth.isAuthenticated) {
|
|
34
|
+
triggerRedirect();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (!isAuth.isLoading && !isAuth.isAuthenticated) {
|
|
40
|
+
triggerRedirect();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
{#if isAuth.isLoading}
|
|
46
|
+
{#if loading}
|
|
47
|
+
{@render loading()}
|
|
48
|
+
{/if}
|
|
49
|
+
{:else if isAuth.isAuthenticated}
|
|
50
|
+
{#if children}
|
|
51
|
+
{@render children()}
|
|
52
|
+
{/if}
|
|
53
|
+
{:else}
|
|
54
|
+
{#if fallback}
|
|
55
|
+
{@render fallback()}
|
|
56
|
+
{/if}
|
|
57
|
+
{/if}
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
}
|
|
63
63
|
formData = defaults;
|
|
64
64
|
initialized = true;
|
|
65
|
-
} else if (existingQuery && existingQuery?.data) {
|
|
66
|
-
formData = { ...existingQuery.data as Record<string, unknown> };
|
|
65
|
+
} else if (existingQuery && existingQuery.query.data?.data) {
|
|
66
|
+
formData = { ...existingQuery.query.data.data as Record<string, unknown> };
|
|
67
67
|
initialized = true;
|
|
68
68
|
}
|
|
69
69
|
});
|
|
@@ -125,9 +125,9 @@
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
if (mode === 'create') {
|
|
128
|
-
await createMut.mutateAsync(cleanData);
|
|
128
|
+
await createMut.mutation.mutateAsync({ variables: cleanData });
|
|
129
129
|
} else if (id != null) {
|
|
130
|
-
await updateMut.mutateAsync({ id, variables: cleanData });
|
|
130
|
+
await updateMut.mutation.mutateAsync({ id, variables: cleanData });
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
isDirty = false;
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery
|
|
153
|
+
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery.query.isLoading : false);
|
|
154
154
|
|
|
155
155
|
const pageTitle = $derived(
|
|
156
156
|
mode === 'create'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Loader2, Check, X, Cloud } from 'lucide-svelte';
|
|
3
|
+
import { t } from '@svadmin/core/i18n';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
status = 'idle',
|
|
7
|
+
error,
|
|
8
|
+
} = $props<{
|
|
9
|
+
/** The current auto-save status */
|
|
10
|
+
status?: 'idle' | 'loading' | 'success' | 'error';
|
|
11
|
+
/** Optional error message */
|
|
12
|
+
error?: string;
|
|
13
|
+
}>();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<div class="flex items-center gap-2 text-sm text-muted-foreground transition-all duration-300">
|
|
17
|
+
{#if status === 'idle'}
|
|
18
|
+
<Cloud class="h-4 w-4" />
|
|
19
|
+
<span>{t('autoSave.idle')}</span>
|
|
20
|
+
{:else if status === 'loading'}
|
|
21
|
+
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
|
22
|
+
<span>{t('autoSave.saving')}</span>
|
|
23
|
+
{:else if status === 'success'}
|
|
24
|
+
<Check class="h-4 w-4 text-green-500" />
|
|
25
|
+
<span class="text-green-500">{t('autoSave.saved')}</span>
|
|
26
|
+
{:else if status === 'error'}
|
|
27
|
+
<X class="h-4 w-4 text-destructive" />
|
|
28
|
+
<span class="text-destructive" title={error}>{t('autoSave.error')}</span>
|
|
29
|
+
{/if}
|
|
30
|
+
</div>
|
|
@@ -10,10 +10,20 @@
|
|
|
10
10
|
import { Checkbox } from './ui/checkbox/index.js';
|
|
11
11
|
import { Badge } from './ui/badge/index.js';
|
|
12
12
|
import * as Table from './ui/table/index.js';
|
|
13
|
-
import { Plus, Pencil, Trash2, ChevronLeft, ChevronRight, Search, Loader2, Download, Upload } from 'lucide-svelte';
|
|
13
|
+
import { Plus, Pencil, Trash2, ChevronLeft, ChevronRight, Search, Loader2, Download, Upload, ChevronDown, ChevronUp, SlidersHorizontal } from 'lucide-svelte';
|
|
14
14
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
resourceName,
|
|
20
|
+
expandedRowRender,
|
|
21
|
+
selectable = true,
|
|
22
|
+
} = $props<{
|
|
23
|
+
resourceName: string;
|
|
24
|
+
expandedRowRender?: Snippet<[record: Record<string, unknown>]>;
|
|
25
|
+
selectable?: boolean;
|
|
26
|
+
}>();
|
|
17
27
|
|
|
18
28
|
const resource = getResource(resourceName);
|
|
19
29
|
const primaryKey = $derived(resource.primaryKey ?? 'id');
|
|
@@ -33,6 +43,25 @@
|
|
|
33
43
|
let filters = $state<Filter[]>([]);
|
|
34
44
|
let searchText = $state(urlState.search ?? '');
|
|
35
45
|
|
|
46
|
+
// Column visibility
|
|
47
|
+
let columnVisibility = $state<Record<string, boolean>>({});
|
|
48
|
+
function initColumnVisibility() {
|
|
49
|
+
const vis: Record<string, boolean> = {};
|
|
50
|
+
for (const f of resource.fields) vis[f.key] = f.showInList !== false;
|
|
51
|
+
return vis;
|
|
52
|
+
}
|
|
53
|
+
columnVisibility = initColumnVisibility();
|
|
54
|
+
const visibleFields = $derived(resource.fields.filter(f => columnVisibility[f.key] !== false && f.showInList !== false));
|
|
55
|
+
let showColumnPicker = $state(false);
|
|
56
|
+
|
|
57
|
+
// Expanded rows
|
|
58
|
+
let expandedRowIds = $state<Set<string | number>>(new Set());
|
|
59
|
+
function toggleExpand(id: string | number) {
|
|
60
|
+
const next = new Set(expandedRowIds);
|
|
61
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
62
|
+
expandedRowIds = next;
|
|
63
|
+
}
|
|
64
|
+
|
|
36
65
|
// Batch selection
|
|
37
66
|
let selectedIds = $state<Set<string | number>>(new Set());
|
|
38
67
|
let selectAll = $state(false);
|
|
@@ -54,7 +83,7 @@
|
|
|
54
83
|
});
|
|
55
84
|
|
|
56
85
|
const searchableFields = resource.fields.filter(f => f.searchable);
|
|
57
|
-
const listFields =
|
|
86
|
+
const listFields = $derived(visibleFields);
|
|
58
87
|
|
|
59
88
|
const activeFilters = $derived.by(() => {
|
|
60
89
|
const result: Filter[] = [...filters];
|
|
@@ -64,14 +93,16 @@
|
|
|
64
93
|
return result;
|
|
65
94
|
});
|
|
66
95
|
|
|
67
|
-
const
|
|
96
|
+
const listResult = useList({
|
|
68
97
|
resource: resourceName,
|
|
69
98
|
pagination,
|
|
70
99
|
sorters,
|
|
71
100
|
filters: activeFilters,
|
|
72
101
|
});
|
|
102
|
+
const query = listResult.query;
|
|
73
103
|
|
|
74
|
-
const
|
|
104
|
+
const deleteResult = useDelete({ resource: resourceName });
|
|
105
|
+
const deleteMutation = deleteResult.mutation;
|
|
75
106
|
|
|
76
107
|
function toggleSort(field: string) {
|
|
77
108
|
const existing = sorters.find(s => s.field === field);
|
|
@@ -111,7 +142,7 @@
|
|
|
111
142
|
function confirmDelete(id: string | number) {
|
|
112
143
|
confirmMessage = t('common.deleteConfirm');
|
|
113
144
|
confirmAction = async () => {
|
|
114
|
-
await deleteMutation.mutateAsync(id);
|
|
145
|
+
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
115
146
|
confirmOpen = false;
|
|
116
147
|
};
|
|
117
148
|
confirmOpen = true;
|
|
@@ -121,7 +152,7 @@
|
|
|
121
152
|
confirmMessage = t('common.batchDeleteConfirm', { count: selectedIds.size });
|
|
122
153
|
confirmAction = async () => {
|
|
123
154
|
for (const id of selectedIds) {
|
|
124
|
-
await deleteMutation.mutateAsync(id);
|
|
155
|
+
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
125
156
|
}
|
|
126
157
|
selectedIds = new Set();
|
|
127
158
|
selectAll = false;
|
|
@@ -194,6 +225,25 @@
|
|
|
194
225
|
<Trash2 class="h-4 w-4" data-icon="inline-start" /> {t('common.batchDelete', { count: selectedIds.size })}
|
|
195
226
|
</Button>
|
|
196
227
|
{/if}
|
|
228
|
+
<!-- Column Visibility Picker -->
|
|
229
|
+
<div class="relative">
|
|
230
|
+
<Button variant="outline" size="sm" onclick={() => showColumnPicker = !showColumnPicker}>
|
|
231
|
+
<SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {t('common.columns') || 'Columns'}
|
|
232
|
+
</Button>
|
|
233
|
+
{#if showColumnPicker}
|
|
234
|
+
<div class="absolute right-0 top-full mt-1 z-50 w-48 rounded-lg border bg-popover p-2 shadow-lg">
|
|
235
|
+
{#each resource.fields.filter(f => f.showInList !== false) as field}
|
|
236
|
+
<label class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent cursor-pointer">
|
|
237
|
+
<Checkbox
|
|
238
|
+
checked={columnVisibility[field.key] !== false}
|
|
239
|
+
onCheckedChange={(v) => { columnVisibility = { ...columnVisibility, [field.key]: !!v }; }}
|
|
240
|
+
/>
|
|
241
|
+
{field.label}
|
|
242
|
+
</label>
|
|
243
|
+
{/each}
|
|
244
|
+
</div>
|
|
245
|
+
{/if}
|
|
246
|
+
</div>
|
|
197
247
|
{#if canCreate}
|
|
198
248
|
<Button onclick={() => navigate(`/${resourceName}/create`)}>
|
|
199
249
|
<Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
|
|
@@ -230,11 +280,14 @@
|
|
|
230
280
|
<Table.Root>
|
|
231
281
|
<Table.Header>
|
|
232
282
|
<Table.Row class="bg-muted/50 hover:bg-muted/50">
|
|
233
|
-
{#if canDelete}
|
|
283
|
+
{#if selectable && canDelete}
|
|
234
284
|
<Table.Head class="w-10">
|
|
235
285
|
<Checkbox checked={selectAll} onCheckedChange={() => toggleSelectAll()} />
|
|
236
286
|
</Table.Head>
|
|
237
287
|
{/if}
|
|
288
|
+
{#if expandedRowRender}
|
|
289
|
+
<Table.Head class="w-10"></Table.Head>
|
|
290
|
+
{/if}
|
|
238
291
|
{#each listFields as field}
|
|
239
292
|
<Table.Head style={field.width ? `width:${field.width}` : undefined}>
|
|
240
293
|
<button class="flex items-center gap-1 hover:text-foreground" onclick={() => toggleSort(field.key)}>
|
|
@@ -250,11 +303,22 @@
|
|
|
250
303
|
{#each query.data?.data ?? [] as record}
|
|
251
304
|
{@const id = record[primaryKey] as string | number}
|
|
252
305
|
<Table.Row class="transition-colors {selectedIds.has(id) ? 'bg-accent' : ''}">
|
|
253
|
-
{#if canDelete}
|
|
306
|
+
{#if selectable && canDelete}
|
|
254
307
|
<Table.Cell>
|
|
255
308
|
<Checkbox checked={selectedIds.has(id)} onCheckedChange={() => toggleSelect(id)} />
|
|
256
309
|
</Table.Cell>
|
|
257
310
|
{/if}
|
|
311
|
+
{#if expandedRowRender}
|
|
312
|
+
<Table.Cell>
|
|
313
|
+
<button class="p-1 hover:bg-accent rounded" onclick={() => toggleExpand(id)}>
|
|
314
|
+
{#if expandedRowIds.has(id)}
|
|
315
|
+
<ChevronUp class="h-4 w-4" />
|
|
316
|
+
{:else}
|
|
317
|
+
<ChevronDown class="h-4 w-4" />
|
|
318
|
+
{/if}
|
|
319
|
+
</button>
|
|
320
|
+
</Table.Cell>
|
|
321
|
+
{/if}
|
|
258
322
|
{#each listFields as field}
|
|
259
323
|
<Table.Cell>
|
|
260
324
|
{#if field.type === 'boolean'}
|
|
@@ -299,9 +363,16 @@
|
|
|
299
363
|
</div>
|
|
300
364
|
</Table.Cell>
|
|
301
365
|
</Table.Row>
|
|
366
|
+
{#if expandedRowRender && expandedRowIds.has(id)}
|
|
367
|
+
<Table.Row class="bg-muted/30">
|
|
368
|
+
<Table.Cell colspan={listFields.length + (selectable && canDelete ? 1 : 0) + (expandedRowRender ? 1 : 0) + 1}>
|
|
369
|
+
{@render expandedRowRender(record)}
|
|
370
|
+
</Table.Cell>
|
|
371
|
+
</Table.Row>
|
|
372
|
+
{/if}
|
|
302
373
|
{:else}
|
|
303
374
|
<Table.Row>
|
|
304
|
-
<Table.Cell colspan={listFields.length + (canDelete ? 2 : 1)} class="h-24 text-center text-muted-foreground">
|
|
375
|
+
<Table.Cell colspan={listFields.length + (selectable && canDelete ? 2 : 1) + (expandedRowRender ? 1 : 0)} class="h-24 text-center text-muted-foreground">
|
|
305
376
|
{t('common.noData')}
|
|
306
377
|
</Table.Cell>
|
|
307
378
|
</Table.Row>
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
result.push({ label: t('common.create'), href: `#/${res.name}/create` });
|
|
30
30
|
} else if (segments[1] === 'edit' && segments[2]) {
|
|
31
31
|
result.push({ label: `${t('common.edit')} #${segments[2]}`, href: `#/${res.name}/edit/${segments[2]}` });
|
|
32
|
+
} else if (segments[1] === 'show' && segments[2]) {
|
|
33
|
+
result.push({ label: `${t('common.detail')} #${segments[2]}`, href: `#/${res.name}/show/${segments[2]}` });
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
return result;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useIsAuthenticated } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import NavigateToResource from './NavigateToResource.svelte';
|
|
5
|
+
|
|
6
|
+
const auth = useIsAuthenticated();
|
|
7
|
+
|
|
8
|
+
$effect(() => {
|
|
9
|
+
if (!auth.isLoading && !auth.isAuthenticated) {
|
|
10
|
+
navigate('/login');
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
{#if auth.isLoading}
|
|
16
|
+
<div class="flex h-screen w-screen items-center justify-center">
|
|
17
|
+
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"></div>
|
|
18
|
+
</div>
|
|
19
|
+
{:else if auth.isAuthenticated}
|
|
20
|
+
<NavigateToResource />
|
|
21
|
+
{/if}
|