@svadmin/ui 0.0.1
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 +44 -0
- package/src/app.css +113 -0
- package/src/components/AdminApp.svelte +127 -0
- package/src/components/AutoForm.svelte +192 -0
- package/src/components/AutoTable.svelte +343 -0
- package/src/components/Breadcrumbs.svelte +51 -0
- package/src/components/ConfirmDialog.svelte +45 -0
- package/src/components/EmptyState.svelte +41 -0
- package/src/components/ErrorBoundary.svelte +51 -0
- package/src/components/FieldRenderer.svelte +154 -0
- package/src/components/Header.svelte +66 -0
- package/src/components/Layout.svelte +66 -0
- package/src/components/PageHeader.svelte +39 -0
- package/src/components/ShowPage.svelte +71 -0
- package/src/components/Sidebar.svelte +169 -0
- package/src/components/StatsCard.svelte +49 -0
- package/src/components/Toast.svelte +48 -0
- package/src/components/ui/alert/alert-description.svelte +17 -0
- package/src/components/ui/alert/alert-title.svelte +17 -0
- package/src/components/ui/alert/alert.svelte +44 -0
- package/src/components/ui/alert/index.ts +14 -0
- package/src/components/ui/avatar/avatar.svelte +62 -0
- package/src/components/ui/avatar/index.ts +2 -0
- package/src/components/ui/badge/badge.svelte +49 -0
- package/src/components/ui/badge/index.ts +2 -0
- package/src/components/ui/button/button.svelte +82 -0
- package/src/components/ui/button/index.ts +17 -0
- package/src/components/ui/card/card-action.svelte +23 -0
- package/src/components/ui/card/card-content.svelte +20 -0
- package/src/components/ui/card/card-description.svelte +20 -0
- package/src/components/ui/card/card-footer.svelte +20 -0
- package/src/components/ui/card/card-header.svelte +23 -0
- package/src/components/ui/card/card-title.svelte +15 -0
- package/src/components/ui/card/card.svelte +22 -0
- package/src/components/ui/card/index.ts +25 -0
- package/src/components/ui/checkbox/checkbox.svelte +39 -0
- package/src/components/ui/checkbox/index.ts +6 -0
- package/src/components/ui/dialog/dialog-close.svelte +11 -0
- package/src/components/ui/dialog/dialog-content.svelte +48 -0
- package/src/components/ui/dialog/dialog-description.svelte +17 -0
- package/src/components/ui/dialog/dialog-footer.svelte +32 -0
- package/src/components/ui/dialog/dialog-header.svelte +20 -0
- package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
- package/src/components/ui/dialog/dialog-portal.svelte +7 -0
- package/src/components/ui/dialog/dialog-title.svelte +17 -0
- package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
- package/src/components/ui/dialog/dialog.svelte +7 -0
- package/src/components/ui/dialog/index.ts +34 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +30 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +36 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +10 -0
- package/src/components/ui/dropdown-menu/dropdown-menu.svelte +41 -0
- package/src/components/ui/dropdown-menu/index.ts +15 -0
- package/src/components/ui/input/index.ts +7 -0
- package/src/components/ui/input/input.svelte +48 -0
- package/src/components/ui/select/index.ts +2 -0
- package/src/components/ui/select/select.svelte +34 -0
- package/src/components/ui/separator/index.ts +7 -0
- package/src/components/ui/separator/separator.svelte +23 -0
- package/src/components/ui/sheet/index.ts +2 -0
- package/src/components/ui/sheet/sheet.svelte +77 -0
- package/src/components/ui/skeleton/index.ts +2 -0
- package/src/components/ui/skeleton/skeleton.svelte +21 -0
- package/src/components/ui/switch/index.ts +2 -0
- package/src/components/ui/switch/switch.svelte +49 -0
- package/src/components/ui/table/index.ts +28 -0
- package/src/components/ui/table/table-body.svelte +15 -0
- package/src/components/ui/table/table-caption.svelte +20 -0
- package/src/components/ui/table/table-cell.svelte +15 -0
- package/src/components/ui/table/table-footer.svelte +20 -0
- package/src/components/ui/table/table-head.svelte +15 -0
- package/src/components/ui/table/table-header.svelte +20 -0
- package/src/components/ui/table/table-row.svelte +15 -0
- package/src/components/ui/table/table.svelte +17 -0
- package/src/components/ui/tabs/index.ts +15 -0
- package/src/components/ui/tabs/tabs-content.svelte +30 -0
- package/src/components/ui/tabs/tabs-list.svelte +26 -0
- package/src/components/ui/tabs/tabs-trigger.svelte +37 -0
- package/src/components/ui/tabs/tabs.svelte +27 -0
- package/src/components/ui/textarea/index.ts +2 -0
- package/src/components/ui/textarea/textarea.svelte +24 -0
- package/src/components/ui/tooltip/index.ts +19 -0
- package/src/components/ui/tooltip/tooltip-content.svelte +52 -0
- package/src/components/ui/tooltip/tooltip-portal.svelte +7 -0
- package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
- package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
- package/src/components/ui/tooltip/tooltip.svelte +10 -0
- package/src/index.ts +43 -0
- package/src/utils.ts +28 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { Identity } from '@svadmin/core';
|
|
4
|
+
import { toggleTheme, getResolvedTheme } from '@svadmin/core';
|
|
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
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
identity,
|
|
19
|
+
title = '',
|
|
20
|
+
showSearch = false,
|
|
21
|
+
actions,
|
|
22
|
+
class: className = '',
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<header class="sticky top-0 z-20 flex h-14 items-center gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-[backdrop-filter]:bg-background/60 {className}">
|
|
27
|
+
{#if title}
|
|
28
|
+
<h2 class="text-lg font-semibold text-foreground">{title}</h2>
|
|
29
|
+
{/if}
|
|
30
|
+
|
|
31
|
+
<div class="flex-1"></div>
|
|
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" />
|
|
54
|
+
{/if}
|
|
55
|
+
</Button>
|
|
56
|
+
|
|
57
|
+
<!-- User info -->
|
|
58
|
+
{#if identity}
|
|
59
|
+
<div class="flex items-center gap-2">
|
|
60
|
+
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
|
|
61
|
+
{identity.name?.charAt(0).toUpperCase() ?? 'U'}
|
|
62
|
+
</div>
|
|
63
|
+
<span class="hidden text-sm font-medium text-foreground md:inline">{identity.name}</span>
|
|
64
|
+
</div>
|
|
65
|
+
{/if}
|
|
66
|
+
</header>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { onMount } from 'svelte';
|
|
4
|
+
import Sidebar from './Sidebar.svelte';
|
|
5
|
+
import Toast from './Toast.svelte';
|
|
6
|
+
import Breadcrumbs from './Breadcrumbs.svelte';
|
|
7
|
+
import { getAuthProvider } from '@svadmin/core';
|
|
8
|
+
import type { Identity } from '@svadmin/core';
|
|
9
|
+
import { navigate } from '@svadmin/core/router';
|
|
10
|
+
import { Loader2 } from 'lucide-svelte';
|
|
11
|
+
|
|
12
|
+
let { children, title = 'Admin' }: { children: Snippet; title?: string } = $props();
|
|
13
|
+
|
|
14
|
+
let hasAuth = true;
|
|
15
|
+
let auth: ReturnType<typeof getAuthProvider> | null = null;
|
|
16
|
+
try {
|
|
17
|
+
auth = getAuthProvider();
|
|
18
|
+
} catch {
|
|
19
|
+
hasAuth = false;
|
|
20
|
+
}
|
|
21
|
+
let loading = $state(true);
|
|
22
|
+
let identity = $state<Identity | null>(null);
|
|
23
|
+
|
|
24
|
+
onMount(async () => {
|
|
25
|
+
if (!auth) {
|
|
26
|
+
loading = false;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const { authenticated, redirectTo } = await auth.check();
|
|
30
|
+
if (!authenticated) {
|
|
31
|
+
navigate(redirectTo ?? '/login');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
identity = await auth.getIdentity();
|
|
35
|
+
loading = false;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async function handleLogout() {
|
|
39
|
+
if (!auth) return;
|
|
40
|
+
const result = await auth.logout();
|
|
41
|
+
if (result.success) {
|
|
42
|
+
navigate(result.redirectTo ?? '/login');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let collapsed = $state(false);
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
{#if loading}
|
|
50
|
+
<div class="flex h-screen items-center justify-center">
|
|
51
|
+
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
|
52
|
+
</div>
|
|
53
|
+
{:else}
|
|
54
|
+
<div class="flex h-screen">
|
|
55
|
+
<Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
|
|
56
|
+
<main
|
|
57
|
+
class="flex-1 overflow-y-auto p-6 transition-all duration-300"
|
|
58
|
+
class:ml-64={!collapsed}
|
|
59
|
+
class:ml-16={collapsed}
|
|
60
|
+
>
|
|
61
|
+
<Breadcrumbs />
|
|
62
|
+
{@render children()}
|
|
63
|
+
</main>
|
|
64
|
+
</div>
|
|
65
|
+
<Toast />
|
|
66
|
+
{/if}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Breadcrumbs from './Breadcrumbs.svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
actions?: Snippet;
|
|
9
|
+
showBreadcrumbs?: boolean;
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let {
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
actions,
|
|
17
|
+
showBreadcrumbs = true,
|
|
18
|
+
class: className = '',
|
|
19
|
+
}: Props = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<div class="space-y-4 {className}">
|
|
23
|
+
{#if showBreadcrumbs}
|
|
24
|
+
<Breadcrumbs />
|
|
25
|
+
{/if}
|
|
26
|
+
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
27
|
+
<div>
|
|
28
|
+
<h1 class="text-2xl font-bold tracking-tight text-foreground">{title}</h1>
|
|
29
|
+
{#if description}
|
|
30
|
+
<p class="text-sm text-muted-foreground">{description}</p>
|
|
31
|
+
{/if}
|
|
32
|
+
</div>
|
|
33
|
+
{#if actions}
|
|
34
|
+
<div class="flex items-center gap-2">
|
|
35
|
+
{@render actions()}
|
|
36
|
+
</div>
|
|
37
|
+
{/if}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useShow, getResource } from '@svadmin/core';
|
|
3
|
+
import type { FieldDefinition } from '@svadmin/core';
|
|
4
|
+
import { navigate } from '@svadmin/core/router';
|
|
5
|
+
import { t, getLocale } from '@svadmin/core/i18n';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import * as Card from './ui/card/index.js';
|
|
8
|
+
import { ArrowLeft, Pencil, Loader2 } from 'lucide-svelte';
|
|
9
|
+
|
|
10
|
+
let { resourceName, id } = $props<{ resourceName: string; id: string | number }>();
|
|
11
|
+
|
|
12
|
+
const resource = getResource(resourceName);
|
|
13
|
+
const showFields = resource.fields.filter(f => f.showInShow !== false);
|
|
14
|
+
|
|
15
|
+
const query = useShow({ resource: resourceName, id });
|
|
16
|
+
|
|
17
|
+
function formatValue(field: FieldDefinition, value: unknown): string {
|
|
18
|
+
if (value == null) return '—';
|
|
19
|
+
if (field.type === 'boolean') return value ? t('common.yes') : t('common.no');
|
|
20
|
+
if (field.type === 'date') return new Date(value as string).toLocaleDateString(getLocale());
|
|
21
|
+
if (field.type === 'json') return JSON.stringify(value, null, 2);
|
|
22
|
+
if (field.type === 'tags' && Array.isArray(value)) return (value as string[]).join(', ');
|
|
23
|
+
if (field.type === 'select' && field.options) {
|
|
24
|
+
const opt = field.options.find(o => o.value === value);
|
|
25
|
+
return opt?.label ?? String(value);
|
|
26
|
+
}
|
|
27
|
+
return String(value);
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<div class="space-y-6">
|
|
32
|
+
<div class="flex items-center justify-between">
|
|
33
|
+
<div class="flex items-center gap-4">
|
|
34
|
+
<Button variant="ghost" size="icon" onclick={() => navigate(`/${resourceName}`)}>
|
|
35
|
+
<ArrowLeft class="h-5 w-5" />
|
|
36
|
+
</Button>
|
|
37
|
+
<h1 class="text-2xl font-bold text-foreground">{resource.label} {t('common.detail')}</h1>
|
|
38
|
+
</div>
|
|
39
|
+
{#if resource.canEdit !== false}
|
|
40
|
+
<Button onclick={() => navigate(`/${resourceName}/edit/${id}`)}>
|
|
41
|
+
<Pencil class="h-4 w-4" data-icon="inline-start" /> {t('common.edit')}
|
|
42
|
+
</Button>
|
|
43
|
+
{/if}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{#if $query.isLoading}
|
|
47
|
+
<div class="flex h-64 items-center justify-center">
|
|
48
|
+
<Loader2 class="h-6 w-6 animate-spin text-primary" />
|
|
49
|
+
</div>
|
|
50
|
+
{:else if $query.data}
|
|
51
|
+
<Card.Root>
|
|
52
|
+
<Card.Content class="divide-y divide-border p-0">
|
|
53
|
+
{#each showFields as field}
|
|
54
|
+
{@const value = ($query.data as Record<string, unknown>)[field.key]}
|
|
55
|
+
<div class="flex px-6 py-4">
|
|
56
|
+
<div class="w-1/3 text-sm font-medium text-muted-foreground">{field.label}</div>
|
|
57
|
+
<div class="w-2/3 text-sm text-foreground">
|
|
58
|
+
{#if field.type === 'image' && value}
|
|
59
|
+
<img src={value as string} alt={field.label} class="h-20 w-20 rounded-lg object-cover" />
|
|
60
|
+
{:else if field.type === 'json' && value}
|
|
61
|
+
<pre class="rounded-lg bg-muted p-3 text-xs overflow-auto max-h-40">{formatValue(field, value)}</pre>
|
|
62
|
+
{:else}
|
|
63
|
+
{formatValue(field, value)}
|
|
64
|
+
{/if}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
{/each}
|
|
68
|
+
</Card.Content>
|
|
69
|
+
</Card.Root>
|
|
70
|
+
{/if}
|
|
71
|
+
</div>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResources } from '@svadmin/core';
|
|
3
|
+
import type { Identity } from '@svadmin/core';
|
|
4
|
+
import { currentPath, navigate } from '@svadmin/core/router';
|
|
5
|
+
import { t } from '@svadmin/core/i18n';
|
|
6
|
+
import { toggleTheme, getResolvedTheme } from '@svadmin/core';
|
|
7
|
+
import { Button } from './ui/button/index.js';
|
|
8
|
+
import * as Tooltip from './ui/tooltip/index.js';
|
|
9
|
+
import { Separator } from './ui/separator/index.js';
|
|
10
|
+
import {
|
|
11
|
+
LayoutDashboard, FileText, Users, Settings, Home,
|
|
12
|
+
ChevronLeft, ChevronRight, LogOut, Sun, Moon
|
|
13
|
+
} from 'lucide-svelte';
|
|
14
|
+
|
|
15
|
+
let { collapsed, identity, title, onToggle, onLogout } = $props<{
|
|
16
|
+
collapsed: boolean;
|
|
17
|
+
identity: Identity | null;
|
|
18
|
+
title: string;
|
|
19
|
+
onToggle: () => void;
|
|
20
|
+
onLogout: () => void;
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const resources = getResources();
|
|
24
|
+
|
|
25
|
+
const iconMap: Record<string, typeof LayoutDashboard> = {
|
|
26
|
+
dashboard: LayoutDashboard,
|
|
27
|
+
posts: FileText,
|
|
28
|
+
users: Users,
|
|
29
|
+
settings: Settings,
|
|
30
|
+
home: Home,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface NavItem {
|
|
34
|
+
path: string;
|
|
35
|
+
label: string;
|
|
36
|
+
Icon: typeof LayoutDashboard;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const navItems: NavItem[] = [
|
|
40
|
+
{ path: '/', label: t('common.home'), Icon: LayoutDashboard },
|
|
41
|
+
...resources.map(r => ({
|
|
42
|
+
path: `/${r.name}`,
|
|
43
|
+
label: r.label,
|
|
44
|
+
Icon: iconMap[r.name] ?? Settings,
|
|
45
|
+
})),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Track current hash for active state
|
|
49
|
+
let path = $state(currentPath());
|
|
50
|
+
$effect(() => {
|
|
51
|
+
function onHash() { path = currentPath(); }
|
|
52
|
+
window.addEventListener('hashchange', onHash);
|
|
53
|
+
return () => window.removeEventListener('hashchange', onHash);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function isActive(itemPath: string): boolean {
|
|
57
|
+
if (itemPath === '/') return path === '/';
|
|
58
|
+
return path.startsWith(itemPath);
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<aside
|
|
63
|
+
class="fixed inset-y-0 left-0 z-30 flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-300"
|
|
64
|
+
class:w-64={!collapsed}
|
|
65
|
+
class:w-16={collapsed}
|
|
66
|
+
>
|
|
67
|
+
<!-- Logo -->
|
|
68
|
+
<div class="flex h-16 items-center justify-between border-b border-sidebar-border px-4">
|
|
69
|
+
{#if !collapsed}
|
|
70
|
+
<span class="text-lg font-bold text-sidebar-primary">{title}</span>
|
|
71
|
+
{:else}
|
|
72
|
+
<span class="text-lg font-bold text-sidebar-primary">{title.charAt(0)}</span>
|
|
73
|
+
{/if}
|
|
74
|
+
<Button variant="ghost" size="icon-sm" onclick={onToggle}>
|
|
75
|
+
{#if collapsed}
|
|
76
|
+
<ChevronRight class="h-4 w-4" />
|
|
77
|
+
{:else}
|
|
78
|
+
<ChevronLeft class="h-4 w-4" />
|
|
79
|
+
{/if}
|
|
80
|
+
</Button>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- Nav -->
|
|
84
|
+
<nav class="flex-1 overflow-y-auto py-4 px-2 space-y-1">
|
|
85
|
+
{#each navItems as item}
|
|
86
|
+
{@const active = isActive(item.path)}
|
|
87
|
+
{#if collapsed}
|
|
88
|
+
<Tooltip.Root>
|
|
89
|
+
<Tooltip.Trigger>
|
|
90
|
+
{#snippet child({ props })}
|
|
91
|
+
<a
|
|
92
|
+
{...props}
|
|
93
|
+
href={`#${item.path}`}
|
|
94
|
+
class="flex items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
|
|
95
|
+
class:bg-sidebar-accent={active}
|
|
96
|
+
class:text-sidebar-accent-foreground={active}
|
|
97
|
+
class:text-sidebar-foreground={!active}
|
|
98
|
+
class:hover:bg-sidebar-accent={!active}
|
|
99
|
+
>
|
|
100
|
+
<item.Icon class="h-5 w-5 flex-shrink-0" />
|
|
101
|
+
</a>
|
|
102
|
+
{/snippet}
|
|
103
|
+
</Tooltip.Trigger>
|
|
104
|
+
<Tooltip.Content side="right">
|
|
105
|
+
{item.label}
|
|
106
|
+
</Tooltip.Content>
|
|
107
|
+
</Tooltip.Root>
|
|
108
|
+
{:else}
|
|
109
|
+
<a
|
|
110
|
+
href={`#${item.path}`}
|
|
111
|
+
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
|
|
112
|
+
class:bg-sidebar-accent={active}
|
|
113
|
+
class:text-sidebar-accent-foreground={active}
|
|
114
|
+
class:text-sidebar-foreground={!active}
|
|
115
|
+
class:hover:bg-sidebar-accent={!active}
|
|
116
|
+
>
|
|
117
|
+
<item.Icon class="h-5 w-5 flex-shrink-0" />
|
|
118
|
+
<span>{item.label}</span>
|
|
119
|
+
</a>
|
|
120
|
+
{/if}
|
|
121
|
+
{/each}
|
|
122
|
+
</nav>
|
|
123
|
+
|
|
124
|
+
<!-- Footer -->
|
|
125
|
+
<Separator class="bg-sidebar-border" />
|
|
126
|
+
<div class="p-3 space-y-1">
|
|
127
|
+
{#if !collapsed && identity}
|
|
128
|
+
<div class="flex items-center gap-3 rounded-lg px-2 py-2">
|
|
129
|
+
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground">
|
|
130
|
+
{identity.name?.charAt(0).toUpperCase() ?? 'U'}
|
|
131
|
+
</div>
|
|
132
|
+
<div class="flex-1 min-w-0">
|
|
133
|
+
<p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
|
|
134
|
+
</div>
|
|
135
|
+
<Button variant="ghost" size="icon-sm" onclick={toggleTheme} class="text-sidebar-foreground" title={t('common.toggleTheme')}>
|
|
136
|
+
{#if getResolvedTheme() === 'dark'}
|
|
137
|
+
<Sun class="h-4 w-4" />
|
|
138
|
+
{:else}
|
|
139
|
+
<Moon class="h-4 w-4" />
|
|
140
|
+
{/if}
|
|
141
|
+
</Button>
|
|
142
|
+
<Button variant="ghost" size="icon-sm" onclick={onLogout} class="text-sidebar-foreground hover:text-destructive" title={t('common.logout')}>
|
|
143
|
+
<LogOut class="h-4 w-4" />
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
{:else if collapsed}
|
|
147
|
+
<Button variant="ghost" size="icon" onclick={toggleTheme} class="w-full text-sidebar-foreground" title={t('common.toggleTheme')}>
|
|
148
|
+
{#if getResolvedTheme() === 'dark'}
|
|
149
|
+
<Sun class="h-5 w-5" />
|
|
150
|
+
{:else}
|
|
151
|
+
<Moon class="h-5 w-5" />
|
|
152
|
+
{/if}
|
|
153
|
+
</Button>
|
|
154
|
+
<Button variant="ghost" size="icon" onclick={onLogout} class="w-full text-sidebar-foreground hover:text-destructive" title={t('common.logout')}>
|
|
155
|
+
<LogOut class="h-5 w-5" />
|
|
156
|
+
</Button>
|
|
157
|
+
{:else}
|
|
158
|
+
<div class="flex gap-1">
|
|
159
|
+
<Button variant="ghost" size="icon" onclick={toggleTheme} class="flex-1 text-sidebar-foreground" title={t('common.toggleTheme')}>
|
|
160
|
+
{#if getResolvedTheme() === 'dark'}
|
|
161
|
+
<Sun class="h-5 w-5" />
|
|
162
|
+
{:else}
|
|
163
|
+
<Moon class="h-5 w-5" />
|
|
164
|
+
{/if}
|
|
165
|
+
</Button>
|
|
166
|
+
</div>
|
|
167
|
+
{/if}
|
|
168
|
+
</div>
|
|
169
|
+
</aside>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Loader2 } from 'lucide-svelte';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
label: string;
|
|
6
|
+
value: string | number;
|
|
7
|
+
icon?: typeof Loader2;
|
|
8
|
+
trend?: { value: number; label?: string };
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let {
|
|
14
|
+
label,
|
|
15
|
+
value,
|
|
16
|
+
icon: Icon,
|
|
17
|
+
trend,
|
|
18
|
+
loading = false,
|
|
19
|
+
class: className = '',
|
|
20
|
+
}: Props = $props();
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<div class="flex items-center gap-4 rounded-xl border border-border bg-card p-5 shadow-sm {className}">
|
|
24
|
+
{#if Icon}
|
|
25
|
+
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
26
|
+
<Icon class="h-6 w-6" />
|
|
27
|
+
</div>
|
|
28
|
+
{/if}
|
|
29
|
+
<div class="flex-1 min-w-0">
|
|
30
|
+
<p class="text-sm text-muted-foreground">{label}</p>
|
|
31
|
+
{#if loading}
|
|
32
|
+
<Loader2 class="mt-1 h-5 w-5 animate-spin text-muted-foreground" />
|
|
33
|
+
{:else}
|
|
34
|
+
<div class="flex items-baseline gap-2">
|
|
35
|
+
<p class="text-2xl font-bold text-foreground">{value}</p>
|
|
36
|
+
{#if trend}
|
|
37
|
+
<span
|
|
38
|
+
class="text-xs font-medium {trend.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}"
|
|
39
|
+
>
|
|
40
|
+
{trend.value >= 0 ? '↑' : '↓'}{Math.abs(trend.value)}%
|
|
41
|
+
{#if trend.label}
|
|
42
|
+
<span class="text-muted-foreground">{trend.label}</span>
|
|
43
|
+
{/if}
|
|
44
|
+
</span>
|
|
45
|
+
{/if}
|
|
46
|
+
</div>
|
|
47
|
+
{/if}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getToasts, removeToast } from '@svadmin/core/toast';
|
|
3
|
+
import { Button } from './ui/button/index.js';
|
|
4
|
+
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-svelte';
|
|
5
|
+
|
|
6
|
+
const iconMap = {
|
|
7
|
+
success: CheckCircle,
|
|
8
|
+
error: AlertCircle,
|
|
9
|
+
info: Info,
|
|
10
|
+
warning: AlertTriangle,
|
|
11
|
+
};
|
|
12
|
+
const colorMap = {
|
|
13
|
+
success: 'bg-green-50 border-green-200 text-green-800',
|
|
14
|
+
error: 'bg-red-50 border-red-200 text-red-800',
|
|
15
|
+
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
|
16
|
+
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const toasts = $derived(getToasts());
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
{#if toasts.length > 0}
|
|
23
|
+
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
|
24
|
+
{#each toasts as t (t.id)}
|
|
25
|
+
{@const Icon = iconMap[t.type]}
|
|
26
|
+
<div
|
|
27
|
+
class="flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg animate-slide-in {colorMap[t.type]}"
|
|
28
|
+
role="alert"
|
|
29
|
+
>
|
|
30
|
+
<Icon class="h-5 w-5 flex-shrink-0 mt-0.5" />
|
|
31
|
+
<p class="flex-1 text-sm font-medium">{t.message}</p>
|
|
32
|
+
<Button variant="ghost" size="icon-xs" onclick={() => removeToast(t.id)} class="flex-shrink-0 -mr-2 -mt-1">
|
|
33
|
+
<X class="h-4 w-4" />
|
|
34
|
+
</Button>
|
|
35
|
+
</div>
|
|
36
|
+
{/each}
|
|
37
|
+
</div>
|
|
38
|
+
{/if}
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
@keyframes slide-in {
|
|
42
|
+
from { transform: translateX(100%); opacity: 0; }
|
|
43
|
+
to { transform: translateX(0); opacity: 1; }
|
|
44
|
+
}
|
|
45
|
+
.animate-slide-in {
|
|
46
|
+
animation: slide-in 0.2s ease-out;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn, type WithElementRef } from "../../../utils.js";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
|
|
5
|
+
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>>;
|
|
6
|
+
|
|
7
|
+
let { ref = $bindable(null), class: className, children, ...restProps }: Props = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<div
|
|
11
|
+
bind:this={ref}
|
|
12
|
+
data-slot="alert-description"
|
|
13
|
+
class={cn("col-start-2 text-sm [&_p]:leading-relaxed text-muted-foreground", className)}
|
|
14
|
+
{...restProps}
|
|
15
|
+
>
|
|
16
|
+
{@render children?.()}
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn, type WithElementRef } from "../../../utils.js";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
|
|
5
|
+
type Props = WithElementRef<HTMLAttributes<HTMLHeadingElement>>;
|
|
6
|
+
|
|
7
|
+
let { ref = $bindable(null), class: className, children, ...restProps }: Props = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<h5
|
|
11
|
+
bind:this={ref}
|
|
12
|
+
data-slot="alert-title"
|
|
13
|
+
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
|
14
|
+
{...restProps}
|
|
15
|
+
>
|
|
16
|
+
{@render children?.()}
|
|
17
|
+
</h5>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { cn, type WithElementRef } from "../../../utils.js";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
import { type VariantProps, tv } from "tailwind-variants";
|
|
5
|
+
|
|
6
|
+
export const alertVariants = tv({
|
|
7
|
+
base: "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-card text-foreground",
|
|
11
|
+
destructive: "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
|
12
|
+
warning: "border-amber-500/50 text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/20 [&>svg]:text-current",
|
|
13
|
+
success: "border-emerald-500/50 text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/20 [&>svg]:text-current",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: { variant: "default" },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
24
|
+
variant?: AlertVariant;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
ref = $bindable(null),
|
|
29
|
+
variant = "default",
|
|
30
|
+
class: className,
|
|
31
|
+
children,
|
|
32
|
+
...restProps
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<div
|
|
37
|
+
bind:this={ref}
|
|
38
|
+
data-slot="alert"
|
|
39
|
+
role="alert"
|
|
40
|
+
class={cn(alertVariants({ variant }), className)}
|
|
41
|
+
{...restProps}
|
|
42
|
+
>
|
|
43
|
+
{@render children?.()}
|
|
44
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Alert, { type AlertVariant, alertVariants } from "./alert.svelte";
|
|
2
|
+
import AlertTitle from "./alert-title.svelte";
|
|
3
|
+
import AlertDescription from "./alert-description.svelte";
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
Alert,
|
|
7
|
+
AlertTitle,
|
|
8
|
+
AlertDescription,
|
|
9
|
+
Alert as Root,
|
|
10
|
+
AlertTitle as Title,
|
|
11
|
+
AlertDescription as Description,
|
|
12
|
+
alertVariants,
|
|
13
|
+
type AlertVariant,
|
|
14
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn, type WithElementRef } from "../../../utils.js";
|
|
3
|
+
import type { HTMLImgAttributes } from "svelte/elements";
|
|
4
|
+
import { type VariantProps, tv } from "tailwind-variants";
|
|
5
|
+
|
|
6
|
+
export const avatarVariants = tv({
|
|
7
|
+
base: "relative flex shrink-0 overflow-hidden rounded-full",
|
|
8
|
+
variants: {
|
|
9
|
+
size: {
|
|
10
|
+
default: "size-10",
|
|
11
|
+
sm: "size-8",
|
|
12
|
+
lg: "size-12",
|
|
13
|
+
xl: "size-16",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: { size: "default" },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type AvatarSize = VariantProps<typeof avatarVariants>["size"];
|
|
20
|
+
|
|
21
|
+
type Props = WithElementRef<HTMLImgAttributes> & {
|
|
22
|
+
size?: AvatarSize;
|
|
23
|
+
fallback?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
ref = $bindable(null),
|
|
28
|
+
src,
|
|
29
|
+
alt = "",
|
|
30
|
+
size = "default",
|
|
31
|
+
fallback,
|
|
32
|
+
class: className,
|
|
33
|
+
...restProps
|
|
34
|
+
}: Props = $props();
|
|
35
|
+
|
|
36
|
+
let imgError = $state(false);
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
{#if src && !imgError}
|
|
40
|
+
<span class={cn(avatarVariants({ size }), className)}>
|
|
41
|
+
<img
|
|
42
|
+
bind:this={ref}
|
|
43
|
+
data-slot="avatar"
|
|
44
|
+
{src}
|
|
45
|
+
{alt}
|
|
46
|
+
class="aspect-square h-full w-full object-cover"
|
|
47
|
+
onerror={() => { imgError = true; }}
|
|
48
|
+
{...restProps}
|
|
49
|
+
/>
|
|
50
|
+
</span>
|
|
51
|
+
{:else}
|
|
52
|
+
<span
|
|
53
|
+
data-slot="avatar-fallback"
|
|
54
|
+
class={cn(
|
|
55
|
+
avatarVariants({ size }),
|
|
56
|
+
"flex items-center justify-center bg-muted text-muted-foreground font-medium text-sm",
|
|
57
|
+
className
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{fallback ?? alt?.charAt(0).toUpperCase() ?? "?"}
|
|
61
|
+
</span>
|
|
62
|
+
{/if}
|