@svadmin/ui 0.0.6 → 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 +4 -3
- package/src/components/Authenticated.svelte +44 -13
- 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/ConfirmDialog.svelte +1 -1
- package/src/components/CreatePage.svelte +42 -0
- package/src/components/EditPage.svelte +52 -0
- package/src/components/EmptyState.svelte +17 -31
- package/src/components/ErrorComponent.svelte +35 -0
- package/src/components/Header.svelte +28 -59
- package/src/components/ListPage.svelte +47 -0
- package/src/components/LiveIndicator.svelte +115 -0
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +70 -0
- package/src/components/ShowPage.svelte +3 -3
- package/src/components/Sidebar.svelte +29 -10
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +21 -11
- package/src/components/buttons/CloneButton.svelte +1 -1
- package/src/components/buttons/CreateButton.svelte +1 -1
- package/src/components/buttons/DeleteButton.svelte +3 -4
- package/src/components/buttons/EditButton.svelte +1 -1
- package/src/components/buttons/ImportButton.svelte +29 -7
- package/src/components/buttons/RefreshButton.svelte +5 -4
- 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 +22 -0
- package/src/router-state.svelte.ts +16 -9
|
@@ -1,66 +1,35 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { t } from '@svadmin/core/i18n';
|
|
6
|
-
import { Button } from './ui/button/index.js';
|
|
7
|
-
import { Sun, Moon, Bell, Search } from 'lucide-svelte';
|
|
8
|
-
|
|
9
|
-
interface Props {
|
|
10
|
-
identity?: Identity | null;
|
|
11
|
-
title?: string;
|
|
12
|
-
showSearch?: boolean;
|
|
13
|
-
actions?: Snippet;
|
|
14
|
-
class?: string;
|
|
15
|
-
}
|
|
2
|
+
import { Moon, Sun } from 'lucide-svelte';
|
|
3
|
+
import { Button } from './ui/button';
|
|
4
|
+
import { getTheme, toggleTheme } from '@svadmin/core';
|
|
16
5
|
|
|
17
6
|
let {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
7
|
+
showThemeToggle = true,
|
|
8
|
+
children,
|
|
9
|
+
} = $props<{
|
|
10
|
+
showThemeToggle?: boolean;
|
|
11
|
+
children?: import('svelte').Snippet;
|
|
12
|
+
}>();
|
|
24
13
|
</script>
|
|
25
14
|
|
|
26
|
-
<header class="sticky top-0 z-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{#if showSearch}
|
|
34
|
-
<div class="relative hidden md:block">
|
|
35
|
-
<Search class="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground" />
|
|
36
|
-
<input
|
|
37
|
-
type="search"
|
|
38
|
-
placeholder={t('common.search')}
|
|
39
|
-
class="h-8 w-64 rounded-lg border border-input bg-background pl-8 pr-3 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
40
|
-
/>
|
|
41
|
-
</div>
|
|
42
|
-
{/if}
|
|
43
|
-
|
|
44
|
-
{#if actions}
|
|
45
|
-
{@render actions()}
|
|
46
|
-
{/if}
|
|
47
|
-
|
|
48
|
-
<!-- Theme toggle -->
|
|
49
|
-
<Button variant="ghost" size="icon-sm" onclick={toggleTheme} title={t('common.toggleTheme')}>
|
|
50
|
-
{#if getResolvedTheme() === 'dark'}
|
|
51
|
-
<Sun class="h-4 w-4" />
|
|
52
|
-
{:else}
|
|
53
|
-
<Moon class="h-4 w-4" />
|
|
15
|
+
<header class="sticky top-0 z-30 flex h-16 w-full shrink-0 items-center justify-between border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6">
|
|
16
|
+
<div class="flex items-center gap-4">
|
|
17
|
+
<!-- Mobile sidebar toggle can go here -->
|
|
18
|
+
</div>
|
|
19
|
+
<div class="ml-auto flex items-center gap-4">
|
|
20
|
+
{#if children}
|
|
21
|
+
{@render children()}
|
|
54
22
|
{/if}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
23
|
+
|
|
24
|
+
{#if showThemeToggle}
|
|
25
|
+
<Button variant="ghost" size="icon" onclick={() => toggleTheme()} class="rounded-full">
|
|
26
|
+
{#if getTheme() === 'dark'}
|
|
27
|
+
<Moon class="h-5 w-5 transition-all" />
|
|
28
|
+
{:else}
|
|
29
|
+
<Sun class="h-5 w-5 transition-all" />
|
|
30
|
+
{/if}
|
|
31
|
+
<span class="sr-only">Toggle theme</span>
|
|
32
|
+
</Button>
|
|
33
|
+
{/if}
|
|
34
|
+
</div>
|
|
66
35
|
</header>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResource } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
import PageHeader from './PageHeader.svelte';
|
|
7
|
+
import AutoTable from './AutoTable.svelte';
|
|
8
|
+
import { Button } from './ui/button/index.js';
|
|
9
|
+
import { Plus } from 'lucide-svelte';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
resourceName: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
canCreate?: boolean;
|
|
15
|
+
headerActions?: Snippet;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
resourceName,
|
|
21
|
+
title,
|
|
22
|
+
canCreate,
|
|
23
|
+
headerActions,
|
|
24
|
+
class: className = '',
|
|
25
|
+
}: Props = $props();
|
|
26
|
+
|
|
27
|
+
const resource = getResource(resourceName);
|
|
28
|
+
const pageTitle = title ?? resource.label;
|
|
29
|
+
const showCreate = canCreate ?? resource.canCreate !== false;
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class="space-y-6 {className}">
|
|
33
|
+
<PageHeader title={pageTitle}>
|
|
34
|
+
{#snippet actions()}
|
|
35
|
+
{#if showCreate}
|
|
36
|
+
<Button onclick={() => navigate(`/${resourceName}/create`)}>
|
|
37
|
+
<Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
|
|
38
|
+
</Button>
|
|
39
|
+
{/if}
|
|
40
|
+
{#if headerActions}
|
|
41
|
+
{@render headerActions()}
|
|
42
|
+
{/if}
|
|
43
|
+
{/snippet}
|
|
44
|
+
</PageHeader>
|
|
45
|
+
|
|
46
|
+
<AutoTable {resourceName} />
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Wifi, WifiOff, Loader2 } from 'lucide-svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
status = 'disconnected',
|
|
6
|
+
resource = '',
|
|
7
|
+
lastEvent = null,
|
|
8
|
+
showDetails = false,
|
|
9
|
+
} = $props<{
|
|
10
|
+
status: 'connecting' | 'connected' | 'disconnected';
|
|
11
|
+
resource?: string;
|
|
12
|
+
lastEvent?: { type: string; timestamp?: number } | null;
|
|
13
|
+
showDetails?: boolean;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const statusConfigs = {
|
|
17
|
+
connected: { color: 'var(--live-connected, #22c55e)', label: 'Live', icon: Wifi },
|
|
18
|
+
connecting: { color: 'var(--live-connecting, #f59e0b)', label: 'Connecting...', icon: Loader2 as typeof Wifi },
|
|
19
|
+
disconnected: { color: 'var(--live-disconnected, #ef4444)', label: 'Offline', icon: WifiOff },
|
|
20
|
+
} as const;
|
|
21
|
+
type StatusKey = 'connected' | 'connecting' | 'disconnected';
|
|
22
|
+
const statusConfig = $derived(statusConfigs[status as StatusKey]);
|
|
23
|
+
|
|
24
|
+
const timeSinceEvent = $derived(
|
|
25
|
+
lastEvent?.timestamp
|
|
26
|
+
? Math.floor((Date.now() - lastEvent.timestamp) / 1000)
|
|
27
|
+
: null
|
|
28
|
+
);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<div class="live-indicator" title={statusConfig.label}>
|
|
32
|
+
<span class="live-dot" style="--dot-color: {statusConfig.color}">
|
|
33
|
+
{#if status === 'connected'}
|
|
34
|
+
<span class="live-pulse"></span>
|
|
35
|
+
{/if}
|
|
36
|
+
</span>
|
|
37
|
+
<span class="live-label" style="color: {statusConfig.color}">
|
|
38
|
+
{statusConfig.label}
|
|
39
|
+
</span>
|
|
40
|
+
|
|
41
|
+
{#if showDetails && resource}
|
|
42
|
+
<span class="live-resource">{resource}</span>
|
|
43
|
+
{/if}
|
|
44
|
+
|
|
45
|
+
{#if showDetails && lastEvent}
|
|
46
|
+
<span class="live-event">
|
|
47
|
+
{lastEvent.type}
|
|
48
|
+
{#if timeSinceEvent !== null && timeSinceEvent < 60}
|
|
49
|
+
<span class="live-time">{timeSinceEvent}s ago</span>
|
|
50
|
+
{/if}
|
|
51
|
+
</span>
|
|
52
|
+
{/if}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.live-indicator {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 0.375rem;
|
|
60
|
+
font-size: 0.75rem;
|
|
61
|
+
font-weight: 500;
|
|
62
|
+
padding: 0.25rem 0.5rem;
|
|
63
|
+
border-radius: 9999px;
|
|
64
|
+
background: hsl(var(--muted) / 0.3);
|
|
65
|
+
user-select: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.live-dot {
|
|
69
|
+
position: relative;
|
|
70
|
+
width: 8px;
|
|
71
|
+
height: 8px;
|
|
72
|
+
border-radius: 50%;
|
|
73
|
+
background: var(--dot-color);
|
|
74
|
+
flex-shrink: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.live-pulse {
|
|
78
|
+
position: absolute;
|
|
79
|
+
inset: -3px;
|
|
80
|
+
border-radius: 50%;
|
|
81
|
+
background: var(--dot-color);
|
|
82
|
+
opacity: 0.4;
|
|
83
|
+
animation: live-pulse 2s ease-in-out infinite;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@keyframes live-pulse {
|
|
87
|
+
0%, 100% { transform: scale(1); opacity: 0.4; }
|
|
88
|
+
50% { transform: scale(1.8); opacity: 0; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.live-label {
|
|
92
|
+
font-size: 0.6875rem;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
text-transform: uppercase;
|
|
95
|
+
letter-spacing: 0.05em;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.live-resource {
|
|
99
|
+
font-size: 0.625rem;
|
|
100
|
+
color: hsl(var(--muted-foreground));
|
|
101
|
+
font-family: monospace;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.live-event {
|
|
105
|
+
font-size: 0.625rem;
|
|
106
|
+
color: hsl(var(--muted-foreground));
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
gap: 0.25rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.live-time {
|
|
113
|
+
opacity: 0.6;
|
|
114
|
+
}
|
|
115
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useMenu } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
|
|
5
|
+
const { menuItems } = useMenu();
|
|
6
|
+
|
|
7
|
+
$effect(() => {
|
|
8
|
+
// Automatically navigate to the first defined resource
|
|
9
|
+
if (menuItems.length > 0) {
|
|
10
|
+
navigate(menuItems[0].route);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<div class="flex h-screen w-screen items-center justify-center p-4 text-muted-foreground">
|
|
16
|
+
<div class="text-center">
|
|
17
|
+
<div class="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"></div>
|
|
18
|
+
<p>Navigating to default resource...</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Skeleton } from './ui/skeleton';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
type = 'list',
|
|
6
|
+
rows = 5,
|
|
7
|
+
} = $props<{
|
|
8
|
+
type?: 'list' | 'show' | 'form';
|
|
9
|
+
rows?: number;
|
|
10
|
+
}>();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="w-full space-y-6">
|
|
14
|
+
<!-- Header Skeleton -->
|
|
15
|
+
<div class="flex items-center justify-between">
|
|
16
|
+
<div class="space-y-2">
|
|
17
|
+
<Skeleton class="h-8 w-[200px]" />
|
|
18
|
+
<Skeleton class="h-4 w-[300px]" />
|
|
19
|
+
</div>
|
|
20
|
+
{#if type === 'list'}
|
|
21
|
+
<Skeleton class="h-10 w-[120px]" />
|
|
22
|
+
{/if}
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="rounded-md border p-4 space-y-4">
|
|
26
|
+
{#if type === 'list'}
|
|
27
|
+
<!-- Table Header -->
|
|
28
|
+
<div class="flex gap-4 border-b pb-4">
|
|
29
|
+
{#each Array(4) as _}
|
|
30
|
+
<Skeleton class="h-6 flex-1" />
|
|
31
|
+
{/each}
|
|
32
|
+
</div>
|
|
33
|
+
<!-- Table Rows -->
|
|
34
|
+
<div class="space-y-4">
|
|
35
|
+
{#each Array(rows) as _}
|
|
36
|
+
<div class="flex gap-4">
|
|
37
|
+
<Skeleton class="h-6 flex-1" />
|
|
38
|
+
<Skeleton class="h-6 flex-1" />
|
|
39
|
+
<Skeleton class="h-6 flex-1" />
|
|
40
|
+
<Skeleton class="h-6 flex-1" />
|
|
41
|
+
</div>
|
|
42
|
+
{/each}
|
|
43
|
+
</div>
|
|
44
|
+
{:else if type === 'show'}
|
|
45
|
+
<!-- Key-Value pairs -->
|
|
46
|
+
<div class="space-y-6">
|
|
47
|
+
{#each Array(rows) as _}
|
|
48
|
+
<div class="space-y-2">
|
|
49
|
+
<Skeleton class="h-4 w-[100px]" />
|
|
50
|
+
<Skeleton class="h-6 w-[250px]" />
|
|
51
|
+
</div>
|
|
52
|
+
{/each}
|
|
53
|
+
</div>
|
|
54
|
+
{:else if type === 'form'}
|
|
55
|
+
<!-- Form Inputs -->
|
|
56
|
+
<div class="space-y-6">
|
|
57
|
+
{#each Array(rows) as _}
|
|
58
|
+
<div class="space-y-2">
|
|
59
|
+
<Skeleton class="h-4 w-[100px]" />
|
|
60
|
+
<Skeleton class="h-10 w-full max-w-md" />
|
|
61
|
+
</div>
|
|
62
|
+
{/each}
|
|
63
|
+
<div class="pt-4 flex gap-2">
|
|
64
|
+
<Skeleton class="h-10 w-[100px]" />
|
|
65
|
+
<Skeleton class="h-10 w-[100px]" />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
{/if}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
@@ -43,15 +43,15 @@
|
|
|
43
43
|
{/if}
|
|
44
44
|
</div>
|
|
45
45
|
|
|
46
|
-
{#if query.isLoading}
|
|
46
|
+
{#if query.query.isLoading}
|
|
47
47
|
<div class="flex h-64 items-center justify-center">
|
|
48
48
|
<Loader2 class="h-6 w-6 animate-spin text-primary" />
|
|
49
49
|
</div>
|
|
50
|
-
{:else if query.data}
|
|
50
|
+
{:else if query.query.data?.data}
|
|
51
51
|
<Card.Root>
|
|
52
52
|
<Card.Content class="divide-y divide-border p-0">
|
|
53
53
|
{#each showFields as field}
|
|
54
|
-
{@const value = (query.data as Record<string, unknown>)[field.key]}
|
|
54
|
+
{@const value = (query.query.data!.data as Record<string, unknown>)[field.key]}
|
|
55
55
|
<div class="flex px-6 py-4">
|
|
56
56
|
<div class="w-1/3 text-sm font-medium text-muted-foreground">{field.label}</div>
|
|
57
57
|
<div class="w-2/3 text-sm text-foreground">
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { Identity } from '@svadmin/core';
|
|
4
4
|
import { currentPath, navigate } from '@svadmin/core/router';
|
|
5
5
|
import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
|
|
6
|
-
import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
|
|
6
|
+
import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme, canAccessAsync } from '@svadmin/core';
|
|
7
7
|
import { Button } from './ui/button/index.js';
|
|
8
8
|
import * as Tooltip from './ui/tooltip/index.js';
|
|
9
9
|
import { Separator } from './ui/separator/index.js';
|
|
@@ -36,15 +36,34 @@
|
|
|
36
36
|
Icon: typeof LayoutDashboard;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// Use
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
// Use effect to build nav items, taking access control into account
|
|
40
|
+
let navItems = $state<NavItem[]>([]);
|
|
41
|
+
|
|
42
|
+
$effect(() => {
|
|
43
|
+
// Re-run when locale or resources change
|
|
44
|
+
const localeVal = getLocale();
|
|
45
|
+
|
|
46
|
+
Promise.all(resources.map(async (r) => {
|
|
47
|
+
try {
|
|
48
|
+
const { can } = await canAccessAsync(r.name, 'list');
|
|
49
|
+
return { r, can };
|
|
50
|
+
} catch {
|
|
51
|
+
return { r, can: false };
|
|
52
|
+
}
|
|
53
|
+
})).then(results => {
|
|
54
|
+
const items: NavItem[] = [{ path: '/', label: t('common.home'), Icon: LayoutDashboard }];
|
|
55
|
+
for (const { r, can } of results) {
|
|
56
|
+
if (can) {
|
|
57
|
+
items.push({
|
|
58
|
+
path: `/${r.name}`,
|
|
59
|
+
label: r.label,
|
|
60
|
+
Icon: iconMap[r.name] ?? Settings,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
navItems = items;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
48
67
|
|
|
49
68
|
/** Toggle between available locales */
|
|
50
69
|
function toggleLocale() {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getAdminOptions } from '@svadmin/core';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
collapsed = false,
|
|
6
|
+
icon,
|
|
7
|
+
text,
|
|
8
|
+
wrapperStyles = '',
|
|
9
|
+
} = $props<{
|
|
10
|
+
collapsed?: boolean;
|
|
11
|
+
icon?: string;
|
|
12
|
+
text?: string;
|
|
13
|
+
wrapperStyles?: string;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const adminOptions = getAdminOptions();
|
|
17
|
+
const titleText = $derived(text ?? adminOptions.title?.text ?? 'Admin');
|
|
18
|
+
const titleIcon = $derived(icon ?? adminOptions.title?.icon);
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div class="flex items-center gap-2 {wrapperStyles}">
|
|
22
|
+
{#if titleIcon}
|
|
23
|
+
<img src={titleIcon} alt="" class="h-6 w-6 shrink-0" />
|
|
24
|
+
{:else}
|
|
25
|
+
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
|
26
|
+
{titleText.charAt(0).toUpperCase()}
|
|
27
|
+
</div>
|
|
28
|
+
{/if}
|
|
29
|
+
{#if !collapsed}
|
|
30
|
+
<span class="text-lg font-semibold text-foreground truncate">{titleText}</span>
|
|
31
|
+
{/if}
|
|
32
|
+
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { getToasts, removeToast } from '@svadmin/core/toast';
|
|
3
3
|
import { Button } from './ui/button/index.js';
|
|
4
4
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-svelte';
|
|
5
|
+
import UndoableNotification from './UndoableNotification.svelte';
|
|
5
6
|
|
|
6
7
|
const iconMap = {
|
|
7
8
|
success: CheckCircle,
|
|
@@ -22,17 +23,26 @@
|
|
|
22
23
|
{#if toasts.length > 0}
|
|
23
24
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
|
24
25
|
{#each toasts as t (t.id)}
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
{#if t.type === 'undoable'}
|
|
27
|
+
<UndoableNotification
|
|
28
|
+
message={t.message}
|
|
29
|
+
duration={t.duration}
|
|
30
|
+
onUndo={() => { t.onUndo?.(); removeToast(t.id); }}
|
|
31
|
+
onTimeout={() => { t.onTimeout?.(); removeToast(t.id); }}
|
|
32
|
+
/>
|
|
33
|
+
{:else}
|
|
34
|
+
{@const Icon = iconMap[t.type]}
|
|
35
|
+
<div
|
|
36
|
+
class="flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg animate-slide-in {colorMap[t.type]}"
|
|
37
|
+
role="alert"
|
|
38
|
+
>
|
|
39
|
+
<Icon class="h-5 w-5 flex-shrink-0 mt-0.5" />
|
|
40
|
+
<p class="flex-1 text-sm font-medium">{t.message}</p>
|
|
41
|
+
<Button variant="ghost" size="icon-xs" onclick={() => removeToast(t.id)} class="flex-shrink-0 -mr-2 -mt-1">
|
|
42
|
+
<X class="h-4 w-4" />
|
|
43
|
+
</Button>
|
|
44
|
+
</div>
|
|
45
|
+
{/if}
|
|
36
46
|
{/each}
|
|
37
47
|
</div>
|
|
38
48
|
{/if}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}>();
|
|
13
13
|
|
|
14
14
|
const nav = useNavigation();
|
|
15
|
-
const can = accessControl?.enabled ? useCan(
|
|
15
|
+
const can = accessControl?.enabled ? useCan(resource, 'create') : null;
|
|
16
16
|
const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
|
|
17
17
|
</script>
|
|
18
18
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
}>();
|
|
12
12
|
|
|
13
13
|
const nav = useNavigation();
|
|
14
|
-
const can = accessControl?.enabled ? useCan(
|
|
14
|
+
const can = accessControl?.enabled ? useCan(resource, 'create') : null;
|
|
15
15
|
const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
|
|
16
16
|
</script>
|
|
17
17
|
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
class?: string;
|
|
17
17
|
}>();
|
|
18
18
|
|
|
19
|
-
const deleteMut = useDelete(resource,
|
|
20
|
-
const can = accessControl?.enabled ? useCan(
|
|
19
|
+
const deleteMut = useDelete({ resource, mutationMode: undoable ? 'undoable' : 'pessimistic' });
|
|
20
|
+
const can = accessControl?.enabled ? useCan(resource, 'delete') : null;
|
|
21
21
|
const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
|
|
22
22
|
let confirming = $state(false);
|
|
23
23
|
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
confirming = false;
|
|
30
|
-
|
|
31
|
-
await $deleteMut.mutateAsync(recordItemId);
|
|
30
|
+
await deleteMut.mutation.mutateAsync({ id: recordItemId, resource });
|
|
32
31
|
onSuccess?.();
|
|
33
32
|
}
|
|
34
33
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}>();
|
|
13
13
|
|
|
14
14
|
const nav = useNavigation();
|
|
15
|
-
const can = accessControl?.enabled ? useCan(
|
|
15
|
+
const can = accessControl?.enabled ? useCan(resource, 'edit') : null;
|
|
16
16
|
const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
|
|
17
17
|
</script>
|
|
18
18
|
|
|
@@ -1,26 +1,48 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { useImport, t } from '@svadmin/core';
|
|
2
|
+
import { useImport, t, type UseImportOptions } from '@svadmin/core';
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Upload } from 'lucide-svelte';
|
|
5
5
|
|
|
6
|
-
let { resource, hideText = false,
|
|
6
|
+
let { resource, hideText = false, onFinish, class: className = '' } = $props<{
|
|
7
7
|
resource: string;
|
|
8
8
|
hideText?: boolean;
|
|
9
|
-
|
|
9
|
+
onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
|
|
10
10
|
class?: string;
|
|
11
11
|
}>();
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
let fileInput: HTMLInputElement | undefined = $state();
|
|
14
|
+
|
|
15
|
+
const importHook = useImport({
|
|
16
|
+
get resource() { return resource; },
|
|
17
|
+
onFinish: (result) => onFinish?.(result),
|
|
16
18
|
});
|
|
19
|
+
|
|
20
|
+
function triggerImport() {
|
|
21
|
+
fileInput?.click();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function handleFileChange(e: Event) {
|
|
25
|
+
const input = e.target as HTMLInputElement;
|
|
26
|
+
const file = input.files?.[0];
|
|
27
|
+
if (file) {
|
|
28
|
+
importHook.handleChange({ file });
|
|
29
|
+
input.value = ''; // reset for re-import
|
|
30
|
+
}
|
|
31
|
+
}
|
|
17
32
|
</script>
|
|
18
33
|
|
|
34
|
+
<input
|
|
35
|
+
bind:this={fileInput}
|
|
36
|
+
type="file"
|
|
37
|
+
accept=".csv"
|
|
38
|
+
style="display:none"
|
|
39
|
+
onchange={handleFileChange}
|
|
40
|
+
/>
|
|
19
41
|
<Button
|
|
20
42
|
variant="outline"
|
|
21
43
|
size={hideText ? 'icon' : 'sm'}
|
|
22
44
|
class={className}
|
|
23
|
-
disabled={isLoading}
|
|
45
|
+
disabled={importHook.isLoading}
|
|
24
46
|
onclick={triggerImport}
|
|
25
47
|
>
|
|
26
48
|
<Upload class="h-4 w-4" />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { useInvalidate, t } from '@svadmin/core';
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { RefreshCw } from 'lucide-svelte';
|
|
5
5
|
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
class?: string;
|
|
10
10
|
}>();
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const invalidate = useInvalidate();
|
|
13
13
|
let spinning = $state(false);
|
|
14
14
|
|
|
15
15
|
function refresh() {
|
|
16
16
|
spinning = true;
|
|
17
|
-
|
|
17
|
+
invalidate({ resource, invalidates: ['list', 'many'] });
|
|
18
18
|
setTimeout(() => { spinning = false; }, 600);
|
|
19
19
|
}
|
|
20
20
|
</script>
|
|
@@ -26,5 +26,6 @@
|
|
|
26
26
|
onclick={refresh}
|
|
27
27
|
>
|
|
28
28
|
<RefreshCw class="h-4 w-4 {spinning ? 'animate-spin' : ''}" />
|
|
29
|
-
{#if !hideText}<span class="ml-1">
|
|
29
|
+
{#if !hideText}<span class="ml-1">{t('common.retry')}</span>{/if}
|
|
30
30
|
</Button>
|
|
31
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props { value: boolean | null | undefined; trueIcon?: string; falseIcon?: string }
|
|
3
|
+
let { value, trueIcon = '✓', falseIcon = '✗' }: Props = $props();
|
|
4
|
+
</script>
|
|
5
|
+
<span class="field-boolean" class:is-true={!!value} class:is-false={!value}>
|
|
6
|
+
{value ? trueIcon : falseIcon}
|
|
7
|
+
</span>
|
|
8
|
+
|
|
9
|
+
<style>
|
|
10
|
+
.field-boolean { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; font-size: 14px; }
|
|
11
|
+
.is-true { background: var(--color-success-bg, #dcfce7); color: var(--color-success, #16a34a); }
|
|
12
|
+
.is-false { background: var(--color-error-bg, #fef2f2); color: var(--color-error, #dc2626); }
|
|
13
|
+
</style>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props { value: string | number | Date | null | undefined; locale?: string; options?: Intl.DateTimeFormatOptions }
|
|
3
|
+
let { value, locale = 'en-US', options = { dateStyle: 'medium' } }: Props = $props();
|
|
4
|
+
const formatted = $derived(value != null ? new Intl.DateTimeFormat(locale, options).format(new Date(value as string | number | Date)) : '—');
|
|
5
|
+
</script>
|
|
6
|
+
<span class="field-date">{formatted}</span>
|