@svadmin/ui 0.8.0 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -1,12 +1,31 @@
1
1
  <script lang="ts">
2
- import { getChatProvider } from '@svadmin/core';
3
- import type { ChatMessage } from '@svadmin/core';
2
+ import { getChatProvider, getChatContext, setChatContext } from '@svadmin/core';
3
+ import type { ChatMessage, ChatAction } from '@svadmin/core';
4
4
  import { t } from '@svadmin/core/i18n';
5
+ import { useParsed } from '@svadmin/core';
5
6
  import { fly, fade, scale } from 'svelte/transition';
6
7
  import { Button } from './ui/button/index.js';
7
8
  import TooltipButton from './TooltipButton.svelte';
8
9
  import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2 } from 'lucide-svelte';
9
10
 
11
+ interface Props {
12
+ /** localStorage key for chat history persistence. Set to '' to disable. */
13
+ persistKey?: string;
14
+ /** Custom persistence callback — called when messages change */
15
+ onPersist?: (messages: ChatMessage[]) => void;
16
+ /** Custom restore callback — called on mount to load history */
17
+ onRestore?: () => ChatMessage[];
18
+ /** Callback when user clicks an action button in an assistant message */
19
+ onAction?: (action: ChatAction, message: ChatMessage) => void;
20
+ }
21
+
22
+ const {
23
+ persistKey = 'svadmin-chat',
24
+ onPersist,
25
+ onRestore,
26
+ onAction,
27
+ }: Props = $props();
28
+
10
29
  let open = $state(false);
11
30
  let minimized = $state(false);
12
31
  let inputValue = $state('');
@@ -15,8 +34,63 @@
15
34
  let messagesContainer: HTMLDivElement | undefined = $state();
16
35
  let abortController: AbortController | null = null;
17
36
  let messageIdCounter = $state(0);
37
+ let initialized = $state(false);
18
38
 
19
39
  const provider = $derived(getChatProvider());
40
+ const parsed = useParsed();
41
+
42
+ // ─── Auto-update ChatContext from current route ─────────────
43
+ $effect(() => {
44
+ setChatContext({
45
+ currentResource: parsed.resource,
46
+ selectedRecordId: parsed.id,
47
+ currentView: parsed.action as 'list' | 'edit' | 'create' | 'show' | undefined,
48
+ pathname: `/${parsed.resource ?? ''}${parsed.action ? '/' + parsed.action : ''}${parsed.id ? '/' + parsed.id : ''}`,
49
+ });
50
+ });
51
+
52
+ // ─── Restore persisted history on mount ─────────────────────
53
+ $effect(() => {
54
+ if (initialized) return;
55
+ initialized = true;
56
+ if (onRestore) {
57
+ messages = onRestore();
58
+ } else if (persistKey) {
59
+ try {
60
+ const stored = localStorage.getItem(persistKey);
61
+ if (stored) {
62
+ const parsed = JSON.parse(stored) as ChatMessage[];
63
+ if (Array.isArray(parsed) && parsed.length > 0) {
64
+ messages = parsed;
65
+ }
66
+ }
67
+ } catch {
68
+ // ignore corrupt data
69
+ }
70
+ }
71
+ });
72
+
73
+ // ─── Persist on change (debounced) ──────────────────────────
74
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
75
+
76
+ $effect(() => {
77
+ // Access `messages` to subscribe to changes
78
+ const snapshot = messages;
79
+ if (!initialized) return;
80
+
81
+ if (persistTimer) clearTimeout(persistTimer);
82
+ persistTimer = setTimeout(() => {
83
+ if (onPersist) {
84
+ onPersist(snapshot);
85
+ } else if (persistKey) {
86
+ try {
87
+ localStorage.setItem(persistKey, JSON.stringify(snapshot));
88
+ } catch {
89
+ // storage full or unavailable
90
+ }
91
+ }
92
+ }, 300);
93
+ });
20
94
 
21
95
  function genId(): string {
22
96
  messageIdCounter++;
@@ -31,6 +105,25 @@
31
105
  });
32
106
  }
33
107
 
108
+ /** Build a system message with the current admin context */
109
+ function buildContextSystemMessage(): ChatMessage | null {
110
+ const ctx = getChatContext();
111
+ if (!ctx.currentResource) return null;
112
+
113
+ const parts: string[] = [];
114
+ parts.push(`Resource: ${ctx.currentResource}`);
115
+ if (ctx.currentView) parts.push(`View: ${ctx.currentView}`);
116
+ if (ctx.selectedRecordId) parts.push(`Selected Record ID: ${ctx.selectedRecordId}`);
117
+ if (ctx.pathname) parts.push(`Path: ${ctx.pathname}`);
118
+
119
+ return {
120
+ id: 'ctx-system',
121
+ role: 'system',
122
+ content: `[Admin Context] ${parts.join(' | ')}`,
123
+ timestamp: Date.now(),
124
+ };
125
+ }
126
+
34
127
  async function sendMessage() {
35
128
  if (!inputValue.trim() || isStreaming || !provider) return;
36
129
 
@@ -59,8 +152,13 @@
59
152
  abortController = new AbortController();
60
153
 
61
154
  try {
155
+ // Build message list with context system message prepended
156
+ const contextMsg = buildContextSystemMessage();
157
+ const allMessages = messages.filter((m) => m.content);
158
+ const messagesToSend = contextMsg ? [contextMsg, ...allMessages] : allMessages;
159
+
62
160
  const result = provider.sendMessage(
63
- messages.filter((m) => m.content), // exclude empty placeholder
161
+ messagesToSend,
64
162
  { signal: abortController.signal },
65
163
  );
66
164
 
@@ -104,6 +202,10 @@
104
202
  abortController.abort();
105
203
  abortController = null;
106
204
  }
205
+ // Also clear persisted data
206
+ if (persistKey) {
207
+ try { localStorage.removeItem(persistKey); } catch { /* noop */ }
208
+ }
107
209
  }
108
210
 
109
211
  function handleKeydown(e: KeyboardEvent) {
@@ -125,6 +227,12 @@
125
227
  }
126
228
  }
127
229
 
230
+ function handleAction(action: ChatAction, msg: ChatMessage) {
231
+ if (onAction) {
232
+ onAction(action, msg);
233
+ }
234
+ }
235
+
128
236
  /** Simple markdown→html: bold, italic, code blocks, inline code, line breaks */
129
237
  function renderMarkdown(text: string): string {
130
238
  return text
@@ -266,6 +374,21 @@
266
374
  {#if msg.role === 'assistant'}
267
375
  {#if msg.content}
268
376
  <div class="chat-markdown">{@html renderMarkdown(msg.content)}</div>
377
+ <!-- Action Buttons -->
378
+ {#if msg.actions && msg.actions.length > 0}
379
+ <div class="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-border/50">
380
+ {#each msg.actions as action}
381
+ <Button
382
+ variant={action.variant ?? 'outline'}
383
+ size="sm"
384
+ class="h-7 text-xs"
385
+ onclick={() => handleAction(action, msg)}
386
+ >
387
+ {action.label}
388
+ </Button>
389
+ {/each}
390
+ </div>
391
+ {/if}
269
392
  {:else}
270
393
  <div class="flex items-center gap-1.5 text-muted-foreground">
271
394
  <Loader2 class="h-3.5 w-3.5 animate-spin" />
@@ -0,0 +1,140 @@
1
+ <script lang="ts">
2
+ import { Marked } from 'marked';
3
+ import { markedHighlight } from 'marked-highlight';
4
+ import hljs from 'highlight.js';
5
+ import 'highlight.js/styles/github-dark.css'; // or your preferred theme
6
+ import DOMPurify from 'isomorphic-dompurify';
7
+ import { Check, Copy } from 'lucide-svelte';
8
+ import { Button } from './ui/button/index.js';
9
+
10
+ interface Props {
11
+ /** The raw markdown string to render */
12
+ content: string;
13
+ /** Whether to enable streaming/typing effect styling */
14
+ streaming?: boolean;
15
+ /** Custom class for the container */
16
+ class?: string;
17
+ }
18
+
19
+ let { content, streaming = false, class: className = '' }: Props = $props();
20
+
21
+ // Configure marked with syntax highlighting
22
+ const marked = new Marked(
23
+ markedHighlight({
24
+ langPrefix: 'hljs language-',
25
+ highlight(code, lang) {
26
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
27
+ return hljs.highlight(code, { language }).value;
28
+ }
29
+ })
30
+ );
31
+
32
+ // Render HTML safely
33
+ const html = $derived(DOMPurify.sanitize(marked.parse(content || '') as string));
34
+
35
+ // Handle copy code blocks
36
+ let copiedBlock = $state<string | null>(null);
37
+
38
+ function handleCopy(event: MouseEvent) {
39
+ const target = event.target as HTMLElement;
40
+ const button = target.closest('.copy-btn');
41
+ if (!button) return;
42
+
43
+ const pre = button.closest('.code-block-wrapper')?.querySelector('pre');
44
+ if (!pre) return;
45
+
46
+ const code = pre.textContent || '';
47
+ navigator.clipboard.writeText(code);
48
+
49
+ const id = Math.random().toString(36);
50
+ copiedBlock = id;
51
+ button.setAttribute('data-copied-id', id);
52
+
53
+ setTimeout(() => {
54
+ if (copiedBlock === id) copiedBlock = null;
55
+ }, 2000);
56
+ }
57
+
58
+ // Inject copy buttons into the HTML after rendering
59
+ // We do this via action instead of raw string manipulation for safety
60
+ function enhanceCodeBlocks(node: HTMLElement, _: string) {
61
+ return {
62
+ update(newHtml: string) {
63
+ // Find all pre > code blocks that don't have wrappers yet
64
+ const pres = node.querySelectorAll('pre:not(.enhanced)');
65
+ pres.forEach(pre => {
66
+ pre.classList.add('enhanced');
67
+ const wrapper = document.createElement('div');
68
+ wrapper.className = 'code-block-wrapper group relative my-4 rounded-md bg-zinc-950';
69
+
70
+ const header = document.createElement('div');
71
+ header.className = 'flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800';
72
+
73
+ const lang = Array.from(pre.querySelector('code')?.classList || []).find(c => c.startsWith('language-'))?.replace('language-', '') || 'Code';
74
+ header.innerHTML = `
75
+ <span>${lang}</span>
76
+ <button class="copy-btn hover:text-white transition-colors flex items-center gap-1">
77
+ <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>
78
+ </button>
79
+ `;
80
+
81
+ pre.replaceWith(wrapper);
82
+ wrapper.appendChild(header);
83
+
84
+ const scrollContainer = document.createElement('div');
85
+ scrollContainer.className = 'overflow-x-auto p-4';
86
+ scrollContainer.appendChild(pre);
87
+ wrapper.appendChild(scrollContainer);
88
+ });
89
+
90
+ // Add event listener for copy buttons
91
+ node.addEventListener('click', handleCopy);
92
+ },
93
+ destroy() {
94
+ node.removeEventListener('click', handleCopy);
95
+ }
96
+ };
97
+ }
98
+ </script>
99
+
100
+ <div
101
+ class="prose prose-sm dark:prose-invert max-w-none break-words {className}"
102
+ class:streaming={streaming}
103
+ use:enhanceCodeBlocks={html}
104
+ >
105
+ {@html html}
106
+ </div>
107
+
108
+ <style>
109
+ /* Base Markdown Styles */
110
+ :global(.prose) {
111
+ line-height: 1.6;
112
+ }
113
+ :global(.prose p) {
114
+ margin-top: 0.5em;
115
+ margin-bottom: 0.5em;
116
+ }
117
+
118
+ /* Streaming cursor effect */
119
+ .streaming > :global(*:last-child)::after {
120
+ content: '▋';
121
+ display: inline-block;
122
+ animation: blink 1s step-start infinite;
123
+ margin-left: 2px;
124
+ vertical-align: baseline;
125
+ color: currentColor;
126
+ }
127
+
128
+ @keyframes blink {
129
+ 50% { opacity: 0; }
130
+ }
131
+
132
+ /* Handle copied state for dynamically injected buttons */
133
+ :global(.copy-btn[data-copied-id]) {
134
+ color: #10b981; /* Tailwind emerald-500 */
135
+ }
136
+ :global(.copy-btn[data-copied-id] svg) {
137
+ /* Swap to Check icon */
138
+ content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="%2310b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>');
139
+ }
140
+ </style>