@svadmin/ui 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app.css +41 -4
- package/src/components/AdminApp.svelte +5 -2
- package/src/components/AutoForm.svelte +2 -2
- package/src/components/AutoTable.svelte +5 -5
- package/src/components/ChatDialog.svelte +6 -4
- package/src/components/Layout.svelte +38 -8
- package/src/components/MarkdownRenderer.svelte +43 -37
- package/src/components/ShowPage.svelte +6 -6
- package/src/components/Sidebar.svelte +14 -4
- package/src/components/SidebarItem.svelte +129 -0
- package/src/components/TooltipButton.svelte +4 -4
- package/src/components/ui/breadcrumb/breadcrumb-list.svelte +1 -1
package/package.json
CHANGED
package/src/app.css
CHANGED
|
@@ -154,7 +154,16 @@ button:active:not(:disabled) {
|
|
|
154
154
|
border-color: var(--color-primary);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/*
|
|
157
|
+
/* Base scrollbar styling (Firefox + Standard) */
|
|
158
|
+
* {
|
|
159
|
+
scrollbar-width: thin;
|
|
160
|
+
scrollbar-color: oklch(0.8 0 0) transparent;
|
|
161
|
+
}
|
|
162
|
+
.dark * {
|
|
163
|
+
scrollbar-color: oklch(0.35 0 0) transparent;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* Scrollbar styling (Webkit) */
|
|
158
167
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
159
168
|
::-webkit-scrollbar-track { background: transparent; }
|
|
160
169
|
::-webkit-scrollbar-thumb { background: oklch(0.8 0 0); border-radius: 3px; }
|
|
@@ -166,9 +175,11 @@ button:active:not(:disabled) {
|
|
|
166
175
|
[data-slot="card"] {
|
|
167
176
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
|
168
177
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
178
|
+
@media (hover: hover) {
|
|
179
|
+
[data-slot="card"]:hover {
|
|
180
|
+
box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 8px 24px -6px rgb(0 0 0 / 0.04);
|
|
181
|
+
transform: translateY(-1px);
|
|
182
|
+
}
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
/* ② Table Row Hover — primary color tint instead of plain gray */
|
|
@@ -302,6 +313,13 @@ button:active:not(:disabled) {
|
|
|
302
313
|
}
|
|
303
314
|
}
|
|
304
315
|
|
|
316
|
+
/* ⑫b Safe Area Inset — iPhone notch / Home Bar support */
|
|
317
|
+
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
|
318
|
+
main {
|
|
319
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
305
323
|
/* ⑫ Optimistic Delete — row fade-out visual feedback */
|
|
306
324
|
@keyframes svadmin-row-delete {
|
|
307
325
|
from {
|
|
@@ -372,6 +390,25 @@ main {
|
|
|
372
390
|
margin-right: 4rem;
|
|
373
391
|
}
|
|
374
392
|
|
|
393
|
+
/* ⑰ Accessibility Minimum Touch Target — Apple HIG 44px minimum */
|
|
394
|
+
@media (max-width: 640px) and (pointer: coarse) {
|
|
395
|
+
[data-slot="button"] {
|
|
396
|
+
position: relative;
|
|
397
|
+
}
|
|
398
|
+
[data-slot="button"]::after {
|
|
399
|
+
content: '';
|
|
400
|
+
position: absolute;
|
|
401
|
+
top: 50%;
|
|
402
|
+
left: 50%;
|
|
403
|
+
transform: translate(-50%, -50%);
|
|
404
|
+
min-width: 44px;
|
|
405
|
+
min-height: 44px;
|
|
406
|
+
width: 100%;
|
|
407
|
+
height: 100%;
|
|
408
|
+
z-index: 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
375
412
|
/* Tailwind CSS v4 — Map design tokens to utility classes */
|
|
376
413
|
@theme {
|
|
377
414
|
--color-background: var(--background);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Auto-inject design tokens so consumers don't need to manually import
|
|
3
3
|
import '../app.css';
|
|
4
4
|
import type { Snippet } from 'svelte';
|
|
5
|
-
import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider, ThemeConfig } from '@svadmin/core';
|
|
5
|
+
import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider, ThemeConfig, MenuItem } from '@svadmin/core';
|
|
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';
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
loginPage?: Snippet;
|
|
42
42
|
/** Override default components via DI */
|
|
43
43
|
components?: Partial<ComponentRegistry>;
|
|
44
|
+
/** Custom multi-level menu configuration */
|
|
45
|
+
menu?: MenuItem[];
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
let {
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
dashboard,
|
|
56
58
|
loginPage,
|
|
57
59
|
components: userComponents,
|
|
60
|
+
menu,
|
|
58
61
|
}: Props = $props();
|
|
59
62
|
|
|
60
63
|
// Default component registry
|
|
@@ -143,7 +146,7 @@
|
|
|
143
146
|
{:else if route === '/login' || route === '/register' || route === '/forgot-password' || route === '/update-password'}
|
|
144
147
|
<ConfigErrorScreen title="{title} — {t('common.configRequired')}" />
|
|
145
148
|
{:else if isAuthenticated || !authProvider}
|
|
146
|
-
<Layout {title}>
|
|
149
|
+
<Layout {title} {menu}>
|
|
147
150
|
{#key route + (params.resource ?? '') + (params.id ?? '')}
|
|
148
151
|
<div class="svadmin-page-enter">
|
|
149
152
|
{#if route === '/'}
|
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
<Card.Title class="text-lg">{group.name}</Card.Title>
|
|
257
257
|
</Card.Header>
|
|
258
258
|
{/if}
|
|
259
|
-
<Card.Content class="space-y-5">
|
|
259
|
+
<Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
|
|
260
260
|
{#each group.fields as field (field.key)}
|
|
261
261
|
<div class:border-destructive={!!fieldErrors[field.key]}>
|
|
262
262
|
{#if fieldRenderer}
|
|
@@ -278,7 +278,7 @@
|
|
|
278
278
|
{/each}
|
|
279
279
|
{:else}
|
|
280
280
|
<Card.Root>
|
|
281
|
-
<Card.Content class="space-y-5">
|
|
281
|
+
<Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
|
|
282
282
|
{#each formFields as field (field.key)}
|
|
283
283
|
<div class:border-destructive={!!fieldErrors[field.key]}>
|
|
284
284
|
{#if fieldRenderer}
|
|
@@ -335,9 +335,9 @@
|
|
|
335
335
|
|
|
336
336
|
<div class="space-y-4">
|
|
337
337
|
<!-- Header -->
|
|
338
|
-
<div class="flex items-center justify-between">
|
|
339
|
-
<h1 class="text-xl font-semibold text-foreground">{resource.label}</h1>
|
|
340
|
-
<div class="flex items-center gap-2">
|
|
338
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
339
|
+
<h1 class="text-lg sm:text-xl font-semibold text-foreground">{resource.label}</h1>
|
|
340
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
341
341
|
{#if canExport}
|
|
342
342
|
<Button variant="outline" size="sm" onclick={exportCSV}>
|
|
343
343
|
<Download class="h-4 w-4" data-icon="inline-start" /> {t('common.export')}
|
|
@@ -723,11 +723,11 @@
|
|
|
723
723
|
|
|
724
724
|
<!-- Pagination (shadcn) -->
|
|
725
725
|
{#if totalPages > 0}
|
|
726
|
-
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
|
726
|
+
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
|
|
727
727
|
<div class="flex items-center gap-2">
|
|
728
728
|
<span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
729
729
|
<Select
|
|
730
|
-
class="h-8 w-auto text-xs"
|
|
730
|
+
class="hidden sm:inline-block h-8 w-auto text-xs"
|
|
731
731
|
value={String(pagination.pageSize ?? 10)}
|
|
732
732
|
onchange={(e: Event) => {
|
|
733
733
|
const size = Number((e.target as HTMLSelectElement).value);
|
|
@@ -260,7 +260,7 @@
|
|
|
260
260
|
tooltip={t('chat.title') || 'AI Assistant'}
|
|
261
261
|
variant="default"
|
|
262
262
|
size="icon"
|
|
263
|
-
class="fixed bottom-6 right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
|
|
263
|
+
class="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
|
|
264
264
|
onclick={() => { open = true; minimized = false; }}
|
|
265
265
|
>
|
|
266
266
|
<MessageCircle class="h-5 w-5" />
|
|
@@ -276,9 +276,11 @@
|
|
|
276
276
|
<!-- Chat Panel -->
|
|
277
277
|
{#if open}
|
|
278
278
|
<div
|
|
279
|
-
class="fixed
|
|
280
|
-
class:w-[
|
|
281
|
-
class:
|
|
279
|
+
class="fixed z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden bottom-3 right-3 sm:bottom-6 sm:right-6"
|
|
280
|
+
class:w-[calc(100vw-1.5rem)]={!minimized}
|
|
281
|
+
class:sm:w-[400px]={!minimized}
|
|
282
|
+
class:h-[calc(100dvh-6rem)]={!minimized}
|
|
283
|
+
class:sm:h-[560px]={!minimized}
|
|
282
284
|
class:w-[280px]={minimized}
|
|
283
285
|
class:h-auto={minimized}
|
|
284
286
|
transition:fly={{ y: 300, duration: 250 }}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import TooltipButton from './TooltipButton.svelte';
|
|
11
11
|
import { t } from '@svadmin/core/i18n';
|
|
12
12
|
import { getAuthProvider } from '@svadmin/core';
|
|
13
|
-
import type { Identity } from '@svadmin/core';
|
|
13
|
+
import type { Identity, MenuItem } from '@svadmin/core';
|
|
14
14
|
import { navigate } from '@svadmin/core/router';
|
|
15
15
|
import { currentPath } from '@svadmin/core/router';
|
|
16
16
|
import { Skeleton } from './ui/skeleton/index.js';
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
let shortcutsOpen = $state(false);
|
|
23
23
|
let mobileMenuOpen = $state(false);
|
|
24
24
|
|
|
25
|
-
let { children, title = 'Admin' }: { children: Snippet; title?: string } = $props();
|
|
25
|
+
let { children, title = 'Admin', menu }: { children: Snippet; title?: string; menu?: MenuItem[] } = $props();
|
|
26
26
|
|
|
27
27
|
let hasAuth = true;
|
|
28
28
|
let auth: ReturnType<typeof getAuthProvider> | null = null;
|
|
@@ -62,12 +62,42 @@
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
let collapsed = $state(false);
|
|
65
|
+
|
|
66
|
+
// Swipe gesture for mobile menu
|
|
67
|
+
let touchStartX = $state(0);
|
|
68
|
+
let touchEndX = $state(0);
|
|
69
|
+
|
|
70
|
+
function handleTouchStart(e: TouchEvent) {
|
|
71
|
+
if (e.touches.length === 1) {
|
|
72
|
+
touchStartX = e.touches[0].clientX;
|
|
73
|
+
touchEndX = e.touches[0].clientX;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleTouchMove(e: TouchEvent) {
|
|
78
|
+
if (e.touches.length === 1) {
|
|
79
|
+
touchEndX = e.touches[0].clientX;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleTouchEnd() {
|
|
84
|
+
// Only trigger swipe-to-open if starting near the left edge (e.g., within 30px)
|
|
85
|
+
// and swiping right by at least 50px
|
|
86
|
+
if (touchStartX < 30 && touchEndX - touchStartX > 50) {
|
|
87
|
+
mobileMenuOpen = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
65
90
|
</script>
|
|
66
91
|
|
|
67
|
-
<svelte:window
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
92
|
+
<svelte:window
|
|
93
|
+
onkeydown={(e) => {
|
|
94
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; }
|
|
95
|
+
if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) { e.preventDefault(); shortcutsOpen = true; }
|
|
96
|
+
}}
|
|
97
|
+
ontouchstart={handleTouchStart}
|
|
98
|
+
ontouchmove={handleTouchMove}
|
|
99
|
+
ontouchend={handleTouchEnd}
|
|
100
|
+
/>
|
|
71
101
|
{#if loading}
|
|
72
102
|
<div class="flex h-screen" in:fade={{ duration: 150 }}>
|
|
73
103
|
<div class="hidden md:block w-64 bg-sidebar/80 p-4 space-y-4">
|
|
@@ -91,13 +121,13 @@
|
|
|
91
121
|
<div class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
|
|
92
122
|
<!-- Desktop sidebar -->
|
|
93
123
|
<div class="hidden md:block">
|
|
94
|
-
<Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
|
|
124
|
+
<Sidebar {collapsed} {identity} {title} {menu} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
|
|
95
125
|
</div>
|
|
96
126
|
|
|
97
127
|
<!-- Mobile sidebar via Sheet -->
|
|
98
128
|
<Sheet.Root bind:open={mobileMenuOpen} side="left">
|
|
99
129
|
<div class="md:hidden">
|
|
100
|
-
<Sidebar collapsed={false} {identity} {title} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
|
|
130
|
+
<Sidebar collapsed={false} {identity} {title} {menu} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
|
|
101
131
|
</div>
|
|
102
132
|
</Sheet.Root>
|
|
103
133
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// @ts-expect-error - missing types
|
|
3
|
-
import { Marked } from
|
|
3
|
+
import { Marked } from "marked";
|
|
4
4
|
// @ts-expect-error - missing types
|
|
5
|
-
import { markedHighlight } from
|
|
5
|
+
import { markedHighlight } from "marked-highlight";
|
|
6
6
|
// @ts-expect-error - missing types
|
|
7
|
-
import
|
|
8
|
-
|
|
7
|
+
import * as hljsModule from "highlight.js";
|
|
8
|
+
const hljs = "default" in hljsModule ? hljsModule.default : hljsModule;
|
|
9
|
+
import "highlight.js/styles/github-dark.css"; // or your preferred theme
|
|
9
10
|
// @ts-expect-error - missing types
|
|
10
|
-
import DOMPurify from
|
|
11
|
-
import { Check, Copy } from
|
|
12
|
-
import { Button } from
|
|
11
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
12
|
+
import { Check, Copy } from "lucide-svelte";
|
|
13
|
+
import { Button } from "./ui/button/index.js";
|
|
13
14
|
|
|
14
15
|
interface Props {
|
|
15
16
|
/** The raw markdown string to render */
|
|
@@ -20,39 +21,41 @@
|
|
|
20
21
|
class?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
let { content, streaming = false, class: className =
|
|
24
|
+
let { content, streaming = false, class: className = "" }: Props = $props();
|
|
24
25
|
|
|
25
26
|
// Configure marked with syntax highlighting
|
|
26
27
|
const marked = new Marked(
|
|
27
28
|
markedHighlight({
|
|
28
|
-
langPrefix:
|
|
29
|
+
langPrefix: "hljs language-",
|
|
29
30
|
highlight(code: string, lang: string) {
|
|
30
|
-
const language = hljs.getLanguage(lang) ? lang :
|
|
31
|
+
const language = hljs.getLanguage(lang) ? lang : "plaintext";
|
|
31
32
|
return hljs.highlight(code, { language }).value;
|
|
32
|
-
}
|
|
33
|
-
})
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
34
35
|
);
|
|
35
36
|
|
|
36
37
|
// Render HTML safely
|
|
37
|
-
const html = $derived(
|
|
38
|
+
const html = $derived(
|
|
39
|
+
DOMPurify.sanitize(marked.parse(content || "") as string),
|
|
40
|
+
);
|
|
38
41
|
|
|
39
42
|
// Handle copy code blocks
|
|
40
43
|
let copiedBlock = $state<string | null>(null);
|
|
41
44
|
|
|
42
45
|
function handleCopy(event: MouseEvent) {
|
|
43
46
|
const target = event.target as HTMLElement;
|
|
44
|
-
const button = target.closest(
|
|
47
|
+
const button = target.closest(".copy-btn");
|
|
45
48
|
if (!button) return;
|
|
46
49
|
|
|
47
|
-
const pre = button.closest(
|
|
50
|
+
const pre = button.closest(".code-block-wrapper")?.querySelector("pre");
|
|
48
51
|
if (!pre) return;
|
|
49
52
|
|
|
50
|
-
const code = pre.textContent ||
|
|
53
|
+
const code = pre.textContent || "";
|
|
51
54
|
navigator.clipboard.writeText(code);
|
|
52
55
|
|
|
53
56
|
const id = Math.random().toString(36);
|
|
54
57
|
copiedBlock = id;
|
|
55
|
-
button.setAttribute(
|
|
58
|
+
button.setAttribute("data-copied-id", id);
|
|
56
59
|
|
|
57
60
|
setTimeout(() => {
|
|
58
61
|
if (copiedBlock === id) copiedBlock = null;
|
|
@@ -65,48 +68,51 @@
|
|
|
65
68
|
return {
|
|
66
69
|
update(newHtml: string) {
|
|
67
70
|
// Find all pre > code blocks that don't have wrappers yet
|
|
68
|
-
const pres = node.querySelectorAll(
|
|
69
|
-
pres.forEach(pre => {
|
|
70
|
-
pre.classList.add(
|
|
71
|
-
const wrapper = document.createElement(
|
|
72
|
-
wrapper.className =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
header
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
const pres = node.querySelectorAll("pre:not(.enhanced)");
|
|
72
|
+
pres.forEach((pre) => {
|
|
73
|
+
pre.classList.add("enhanced");
|
|
74
|
+
const wrapper = document.createElement("div");
|
|
75
|
+
wrapper.className =
|
|
76
|
+
"code-block-wrapper group relative my-4 rounded-md bg-foreground/95";
|
|
77
|
+
|
|
78
|
+
const header = document.createElement("div");
|
|
79
|
+
header.className =
|
|
80
|
+
"flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800";
|
|
81
|
+
|
|
82
|
+
const lang =
|
|
83
|
+
Array.from(pre.querySelector("code")?.classList || [])
|
|
84
|
+
.find((c) => c.startsWith("language-"))
|
|
85
|
+
?.replace("language-", "") || "Code";
|
|
78
86
|
header.innerHTML = `
|
|
79
87
|
<span>${lang}</span>
|
|
80
88
|
<button class="copy-btn hover:text-white transition-colors flex items-center gap-1">
|
|
81
89
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
82
90
|
</button>
|
|
83
91
|
`;
|
|
84
|
-
|
|
92
|
+
|
|
85
93
|
pre.replaceWith(wrapper);
|
|
86
94
|
wrapper.appendChild(header);
|
|
87
|
-
|
|
88
|
-
const scrollContainer = document.createElement(
|
|
89
|
-
scrollContainer.className =
|
|
95
|
+
|
|
96
|
+
const scrollContainer = document.createElement("div");
|
|
97
|
+
scrollContainer.className = "overflow-x-auto p-4";
|
|
90
98
|
scrollContainer.appendChild(pre);
|
|
91
99
|
wrapper.appendChild(scrollContainer);
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
// Add event listener for copy buttons
|
|
95
|
-
node.addEventListener(
|
|
103
|
+
node.addEventListener("click", handleCopy);
|
|
96
104
|
},
|
|
97
105
|
destroy() {
|
|
98
|
-
node.removeEventListener(
|
|
99
|
-
}
|
|
106
|
+
node.removeEventListener("click", handleCopy);
|
|
107
|
+
},
|
|
100
108
|
};
|
|
101
109
|
}
|
|
102
110
|
</script>
|
|
103
111
|
|
|
104
112
|
<div
|
|
105
|
-
class="prose prose-sm dark:prose-invert max-w-none break-
|
|
113
|
+
class="prose prose-sm dark:prose-invert max-w-none wrap-break-word {className}"
|
|
106
114
|
class:streaming={streaming}
|
|
107
115
|
use:enhanceCodeBlocks={html}
|
|
108
116
|
>
|
|
109
117
|
{@html html}
|
|
110
118
|
</div>
|
|
111
|
-
|
|
112
|
-
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
<Card.Root>
|
|
40
40
|
<Card.Content class="divide-y divide-border p-0">
|
|
41
41
|
{#each showFields.slice(0, 6) as _}
|
|
42
|
-
<div class="flex px-6 py-4">
|
|
43
|
-
<Skeleton class="h-4 w-1/4" />
|
|
44
|
-
<Skeleton class="h-4 w-2/5 ml-auto" />
|
|
42
|
+
<div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
|
|
43
|
+
<Skeleton class="h-4 w-1/2 sm:w-1/4" />
|
|
44
|
+
<Skeleton class="h-4 w-3/4 sm:w-2/5 mt-1 sm:mt-0 sm:ml-auto" />
|
|
45
45
|
</div>
|
|
46
46
|
{/each}
|
|
47
47
|
</Card.Content>
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
{#each showFields as field}
|
|
53
53
|
{@const value = (query.query.data!.data as Record<string, unknown>)[field.key]}
|
|
54
54
|
{@const DisplayComponent = getDisplayComponent(field.type)}
|
|
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">
|
|
55
|
+
<div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
|
|
56
|
+
<div class="sm:w-1/3 text-xs sm:text-sm font-medium text-muted-foreground mb-1 sm:mb-0">{field.label}</div>
|
|
57
|
+
<div class="sm:w-2/3 text-sm text-foreground">
|
|
58
58
|
{#if DisplayComponent && value != null}
|
|
59
59
|
<DisplayComponent
|
|
60
60
|
{value}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getResources, canAccessAsync } from '@svadmin/core';
|
|
3
|
-
import type { Identity } from '@svadmin/core';
|
|
3
|
+
import type { Identity, MenuItem } from '@svadmin/core';
|
|
4
4
|
import { currentPath, navigate } from '@svadmin/core/router';
|
|
5
5
|
import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
|
|
6
6
|
import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
|
|
7
7
|
import { Button } from './ui/button/index.js';
|
|
8
8
|
import TooltipButton from './TooltipButton.svelte';
|
|
9
9
|
import * as Tooltip from './ui/tooltip/index.js';
|
|
10
|
+
import SidebarItem from './SidebarItem.svelte';
|
|
10
11
|
|
|
11
12
|
import { ScrollArea } from './ui/scroll-area/index.js';
|
|
12
13
|
import * as Collapsible from './ui/collapsible/index.js';
|
|
@@ -16,13 +17,14 @@
|
|
|
16
17
|
ChevronLeft, ChevronDown, LogOut, Sun, Moon, Palette
|
|
17
18
|
} from 'lucide-svelte';
|
|
18
19
|
|
|
19
|
-
let { collapsed, identity, title, onToggle, onLogout }
|
|
20
|
+
let { collapsed, identity, title, onToggle, onLogout, menu }: {
|
|
20
21
|
collapsed: boolean;
|
|
21
22
|
identity: Identity | null;
|
|
22
23
|
title: string;
|
|
23
24
|
onToggle: () => void;
|
|
24
25
|
onLogout: () => void;
|
|
25
|
-
|
|
26
|
+
menu?: MenuItem[];
|
|
27
|
+
} = $props();
|
|
26
28
|
|
|
27
29
|
const resources = getResources();
|
|
28
30
|
|
|
@@ -176,7 +178,14 @@
|
|
|
176
178
|
</div>
|
|
177
179
|
|
|
178
180
|
<ScrollArea class="flex-1">
|
|
179
|
-
<nav aria-label="Main menu" class="py-4 px-3 space-y-
|
|
181
|
+
<nav aria-label="Main menu" class="py-4 px-3 space-y-1">
|
|
182
|
+
{#if menu && menu.length > 0}
|
|
183
|
+
<!-- Custom multi-level menu -->
|
|
184
|
+
{#each menu as item}
|
|
185
|
+
<SidebarItem {item} currentPath={path} {collapsed} depth={0} />
|
|
186
|
+
{/each}
|
|
187
|
+
{:else}
|
|
188
|
+
<!-- Auto-generated menu from resources (fallback) -->
|
|
180
189
|
{#each navGroups as group}
|
|
181
190
|
{#if group.name && !collapsed}
|
|
182
191
|
<!-- Grouped section with Collapsible -->
|
|
@@ -248,6 +257,7 @@
|
|
|
248
257
|
{/each}
|
|
249
258
|
{/if}
|
|
250
259
|
{/each}
|
|
260
|
+
{/if}
|
|
251
261
|
</nav>
|
|
252
262
|
</ScrollArea>
|
|
253
263
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { MenuItem } from '@svadmin/core';
|
|
3
|
+
import { t } from '@svadmin/core/i18n';
|
|
4
|
+
import * as Collapsible from './ui/collapsible/index.js';
|
|
5
|
+
import * as Tooltip from './ui/tooltip/index.js';
|
|
6
|
+
import SidebarItem from './SidebarItem.svelte';
|
|
7
|
+
import {
|
|
8
|
+
LayoutDashboard, FileText, Users, Settings, Home,
|
|
9
|
+
ChevronDown, Folder, ExternalLink, type Icon as LucideIcon,
|
|
10
|
+
} from 'lucide-svelte';
|
|
11
|
+
|
|
12
|
+
let { item, currentPath, collapsed = false, depth = 0 }: {
|
|
13
|
+
item: MenuItem;
|
|
14
|
+
currentPath: string;
|
|
15
|
+
collapsed?: boolean;
|
|
16
|
+
depth?: number;
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
const iconMap: Record<string, typeof LayoutDashboard> = {
|
|
20
|
+
dashboard: LayoutDashboard,
|
|
21
|
+
posts: FileText,
|
|
22
|
+
users: Users,
|
|
23
|
+
settings: Settings,
|
|
24
|
+
home: Home,
|
|
25
|
+
folder: Folder,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function getIcon(name?: string): typeof LayoutDashboard {
|
|
29
|
+
if (!name) return depth === 0 ? Settings : Folder;
|
|
30
|
+
return iconMap[name] ?? Settings;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getLabel(mi: MenuItem): string {
|
|
34
|
+
return mi.label ?? mi.name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isActive(itemHref: string | undefined): boolean {
|
|
38
|
+
if (!itemHref) return false;
|
|
39
|
+
if (itemHref === '/') return currentPath === '/';
|
|
40
|
+
return currentPath.startsWith(itemHref);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check if any child (recursively) is active — used to auto-open parent groups */
|
|
44
|
+
function hasActiveChild(menuItem: MenuItem): boolean {
|
|
45
|
+
if (isActive(menuItem.href)) return true;
|
|
46
|
+
return menuItem.children?.some((c: MenuItem) => hasActiveChild(c)) ?? false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const hasChildren = $derived(item.children && item.children.length > 0);
|
|
50
|
+
const active = $derived(isActive(item.href));
|
|
51
|
+
const childActive = $derived(hasActiveChild(item));
|
|
52
|
+
const isExternal = $derived(item.target === '_blank');
|
|
53
|
+
|
|
54
|
+
// Track open state — auto-open if a child is active
|
|
55
|
+
let isOpen = $state(false);
|
|
56
|
+
$effect(() => { if (childActive) isOpen = true; });
|
|
57
|
+
|
|
58
|
+
const Icon = $derived(getIcon(item.icon));
|
|
59
|
+
const label = $derived(getLabel(item));
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if item.meta?.hidden}
|
|
63
|
+
<!-- hidden item, render nothing -->
|
|
64
|
+
{:else if hasChildren && !collapsed}
|
|
65
|
+
<!-- Parent node with children: render as collapsible group -->
|
|
66
|
+
<Collapsible.Root bind:open={isOpen}>
|
|
67
|
+
<Collapsible.Trigger
|
|
68
|
+
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
|
|
69
|
+
{childActive
|
|
70
|
+
? 'text-sidebar-foreground bg-sidebar-accent/30'
|
|
71
|
+
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
|
|
72
|
+
style="padding-left: {12 + depth * 12}px"
|
|
73
|
+
>
|
|
74
|
+
<span class="flex items-center gap-3">
|
|
75
|
+
<Icon class="h-4 w-4 flex-shrink-0" />
|
|
76
|
+
<span>{label}</span>
|
|
77
|
+
</span>
|
|
78
|
+
<ChevronDown class="h-3.5 w-3.5 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}" />
|
|
79
|
+
</Collapsible.Trigger>
|
|
80
|
+
<Collapsible.Content>
|
|
81
|
+
<div class="mt-0.5 space-y-0.5" style="padding-left: {depth > 0 ? 0 : 0}px">
|
|
82
|
+
{#each item.children as child}
|
|
83
|
+
<SidebarItem item={child} {currentPath} {collapsed} depth={depth + 1} />
|
|
84
|
+
{/each}
|
|
85
|
+
</div>
|
|
86
|
+
</Collapsible.Content>
|
|
87
|
+
</Collapsible.Root>
|
|
88
|
+
{:else if collapsed}
|
|
89
|
+
<!-- Collapsed sidebar: icon-only with tooltip -->
|
|
90
|
+
<Tooltip.Root>
|
|
91
|
+
<Tooltip.Trigger>
|
|
92
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
93
|
+
<a
|
|
94
|
+
{...props}
|
|
95
|
+
href={item.href ? `#${item.href}` : undefined}
|
|
96
|
+
target={isExternal ? '_blank' : undefined}
|
|
97
|
+
rel={isExternal ? 'noopener noreferrer' : undefined}
|
|
98
|
+
class="flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
|
|
99
|
+
{active
|
|
100
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
101
|
+
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
|
|
102
|
+
>
|
|
103
|
+
<Icon class="h-4 w-4 flex-shrink-0" />
|
|
104
|
+
</a>
|
|
105
|
+
{/snippet}
|
|
106
|
+
</Tooltip.Trigger>
|
|
107
|
+
<Tooltip.Content side="right">
|
|
108
|
+
{label}
|
|
109
|
+
</Tooltip.Content>
|
|
110
|
+
</Tooltip.Root>
|
|
111
|
+
{:else}
|
|
112
|
+
<!-- Leaf node: render as a link -->
|
|
113
|
+
<a
|
|
114
|
+
href={item.href ? `#${item.href}` : undefined}
|
|
115
|
+
target={isExternal ? '_blank' : undefined}
|
|
116
|
+
rel={isExternal ? 'noopener noreferrer' : undefined}
|
|
117
|
+
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
|
|
118
|
+
{active
|
|
119
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
|
|
120
|
+
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
|
|
121
|
+
style="padding-left: {12 + depth * 12}px"
|
|
122
|
+
>
|
|
123
|
+
<Icon class="h-4 w-4 flex-shrink-0" />
|
|
124
|
+
<span class="flex-1">{label}</span>
|
|
125
|
+
{#if isExternal}
|
|
126
|
+
<ExternalLink class="h-3 w-3 opacity-50" />
|
|
127
|
+
{/if}
|
|
128
|
+
</a>
|
|
129
|
+
{/if}
|
|
@@ -4,16 +4,13 @@
|
|
|
4
4
|
import type { Snippet } from 'svelte';
|
|
5
5
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
6
6
|
|
|
7
|
-
interface Props {
|
|
7
|
+
interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children'> {
|
|
8
8
|
tooltip: string;
|
|
9
9
|
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
|
10
10
|
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
|
|
11
11
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
12
12
|
class?: string;
|
|
13
13
|
onclick?: (e: MouseEvent) => void;
|
|
14
|
-
disabled?: boolean;
|
|
15
|
-
type?: 'button' | 'submit' | 'reset';
|
|
16
|
-
tabindex?: number;
|
|
17
14
|
children: Snippet;
|
|
18
15
|
}
|
|
19
16
|
|
|
@@ -28,6 +25,7 @@
|
|
|
28
25
|
type = 'button',
|
|
29
26
|
tabindex,
|
|
30
27
|
children,
|
|
28
|
+
...restProps
|
|
31
29
|
}: Props = $props();
|
|
32
30
|
</script>
|
|
33
31
|
|
|
@@ -36,10 +34,12 @@
|
|
|
36
34
|
{#snippet child({ props })}
|
|
37
35
|
<Button
|
|
38
36
|
{...props}
|
|
37
|
+
{...restProps}
|
|
39
38
|
{variant}
|
|
40
39
|
{size}
|
|
41
40
|
{type}
|
|
42
41
|
class={className}
|
|
42
|
+
aria-label={restProps['aria-label'] ?? tooltip}
|
|
43
43
|
{onclick}
|
|
44
44
|
{disabled}
|
|
45
45
|
{tabindex}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
<ol
|
|
10
10
|
data-slot="breadcrumb-list"
|
|
11
|
-
class={cn("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-
|
|
11
|
+
class={cn("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5", className)}
|
|
12
12
|
{...restProps}
|
|
13
13
|
>
|
|
14
14
|
{@render children?.()}
|