@svadmin/ui 0.14.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/AutoForm.svelte +2 -2
- package/src/components/AutoTable.svelte +5 -5
- package/src/components/ChatDialog.svelte +6 -4
- package/src/components/Layout.svelte +34 -4
- package/src/components/MarkdownRenderer.svelte +43 -37
- package/src/components/ShowPage.svelte +6 -6
- 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);
|
|
@@ -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 }}
|
|
@@ -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">
|
|
@@ -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}
|
|
@@ -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?.()}
|