@svadmin/ui 0.9.0 → 0.10.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.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import { getResources, getChatProvider } from '@svadmin/core';
3
+ import { navigate } from '@svadmin/core/router';
4
+ import { t } from '@svadmin/core/i18n';
5
+ import { toggleTheme } from '@svadmin/core';
6
+ import { Command } from 'cmdk-sv';
7
+ import * as Dialog from './ui/dialog/index.js';
8
+ import { Search, LayoutDashboard, Plus, Sun, FileText, Sparkles, Loader2, Send } from 'lucide-svelte';
9
+ import MarkdownRenderer from './MarkdownRenderer.svelte';
10
+
11
+ let { open = $bindable(false) } = $props<{ open?: boolean }>();
12
+ let searchValue = $state('');
13
+
14
+ const resources = getResources();
15
+ const provider = $derived(getChatProvider());
16
+
17
+ // AI State
18
+ let aiMode = $state(false);
19
+ let aiResponse = $state('');
20
+ let isPredicting = $state(false);
21
+ let abortController: AbortController | null = null;
22
+
23
+ function close() {
24
+ open = false;
25
+ searchValue = '';
26
+ aiMode = false;
27
+ aiResponse = '';
28
+ if (abortController) {
29
+ abortController.abort();
30
+ abortController = null;
31
+ }
32
+ }
33
+
34
+ function act(fn: () => void) {
35
+ fn();
36
+ close();
37
+ }
38
+
39
+ async function askAI() {
40
+ if (!provider || !searchValue.trim()) return;
41
+
42
+ aiMode = true;
43
+ isPredicting = true;
44
+ aiResponse = '';
45
+
46
+ if (abortController) abortController.abort();
47
+ abortController = new AbortController();
48
+
49
+ try {
50
+ const result = provider.sendMessage(
51
+ [{ id: 'cmd', role: 'user', content: searchValue, timestamp: Date.now() }],
52
+ { signal: abortController.signal }
53
+ );
54
+
55
+ if (Symbol.asyncIterator in result) {
56
+ for await (const chunk of result as AsyncIterable<string>) {
57
+ aiResponse += chunk;
58
+ }
59
+ } else {
60
+ aiResponse = await result;
61
+ }
62
+ } catch (err: any) {
63
+ if (err.name !== 'AbortError') {
64
+ aiResponse = 'Failed to get response from AI.';
65
+ }
66
+ } finally {
67
+ isPredicting = false;
68
+ }
69
+ }
70
+
71
+ function handleKeyDown(e: KeyboardEvent) {
72
+ if (e.key === 'Enter' && searchValue.trim() && !aiMode) {
73
+ // If pressing enter on an active search, cmdk usually handles selection.
74
+ // But if there's no selection or we want to force AI, we can intercept.
75
+ // For simplicity, we trigger AI on Ctrl+Enter
76
+ if (e.ctrlKey || e.metaKey) {
77
+ e.preventDefault();
78
+ askAI();
79
+ }
80
+ }
81
+ }
82
+
83
+ const itemClass = 'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50';
84
+ </script>
85
+
86
+ <Dialog.Dialog bind:open onOpenChange={(v: boolean) => !v && close()}>
87
+ <Dialog.DialogContent class="overflow-hidden p-0 sm:max-w-[600px] border-none shadow-2xl">
88
+ <Command.Root
89
+ shouldFilter={!aiMode}
90
+ class="flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-semibold [&_[data-cmdk-group-heading]]:uppercase [&_[data-cmdk-group-heading]]:tracking-wider"
91
+ >
92
+ <div class="flex items-center border-b px-3 h-12 relative" data-cmdk-input-wrapper="">
93
+ {#if aiMode}
94
+ <Sparkles class="mr-2 h-4 w-4 shrink-0 text-amber-500 animate-pulse" />
95
+ {:else}
96
+ <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
97
+ {/if}
98
+ <Command.Input
99
+ bind:value={searchValue}
100
+ onkeydown={handleKeyDown}
101
+ placeholder={aiMode ? "Ask AI anything..." : "Search commands or ask AI (Ctrl+Enter)..."}
102
+ class="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
103
+ />
104
+ {#if searchValue.trim() && !aiMode}
105
+ <button
106
+ class="absolute right-3 flex items-center gap-1 text-[10px] font-medium text-amber-500 bg-amber-500/10 hover:bg-amber-500/20 px-2 py-1 rounded transition-colors"
107
+ onclick={askAI}
108
+ >
109
+ <Sparkles class="h-3 w-3" />
110
+ <kbd class="font-sans">Ctrl+Enter</kbd> to Ask AI
111
+ </button>
112
+ {/if}
113
+ </div>
114
+
115
+ {#if !aiMode}
116
+ <Command.List class="max-h-[360px] overflow-y-auto overflow-x-hidden p-1">
117
+ <Command.Empty class="py-6 text-center text-sm text-muted-foreground">
118
+ No standard commands found. Press <kbd class="px-1 py-0.5 rounded bg-muted border font-sans text-[10px]">Ctrl+Enter</kbd> to ask AI.
119
+ </Command.Empty>
120
+
121
+ <!-- Navigation -->
122
+ <Command.Group heading={t('common.home')}>
123
+ <Command.Item value="home" onSelect={() => act(() => navigate('/'))} class={itemClass}>
124
+ <LayoutDashboard class="h-4 w-4 text-muted-foreground" />
125
+ {t('common.home')}
126
+ </Command.Item>
127
+ {#each resources as r}
128
+ <Command.Item value={r.name} onSelect={() => act(() => navigate(`/${r.name}`))} class={itemClass}>
129
+ <FileText class="h-4 w-4 text-muted-foreground" />
130
+ {r.label}
131
+ </Command.Item>
132
+ {/each}
133
+ </Command.Group>
134
+
135
+ <Command.Separator class="mx-1 my-1 h-px bg-border" />
136
+
137
+ <!-- Actions -->
138
+ <Command.Group heading={t('common.actions')}>
139
+ {#each resources as r}
140
+ <Command.Item value={`create-${r.name}`} onSelect={() => act(() => navigate(`/${r.name}/create`))} class={itemClass}>
141
+ <Plus class="h-4 w-4 text-muted-foreground" />
142
+ {t('common.create')} {r.label}
143
+ </Command.Item>
144
+ {/each}
145
+ <Command.Item value="toggle-theme" onSelect={() => act(() => toggleTheme())} class={itemClass}>
146
+ <Sun class="h-4 w-4 text-muted-foreground" />
147
+ {t('common.toggleTheme')}
148
+ </Command.Item>
149
+ </Command.Group>
150
+ </Command.List>
151
+ {:else}
152
+ <!-- AI Response Area -->
153
+ <div class="h-[360px] max-h-[360px] overflow-y-auto p-4 bg-muted/20">
154
+ <div class="flex items-start gap-3">
155
+ <div class="mt-0.5 w-8 h-8 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center shrink-0">
156
+ <Sparkles class="h-4 w-4" />
157
+ </div>
158
+ <div class="flex-1 w-full text-sm">
159
+ {#if !aiResponse && isPredicting}
160
+ <div class="flex items-center gap-2 text-muted-foreground mt-1.5">
161
+ <Loader2 class="h-4 w-4 animate-spin" /> Thinking...
162
+ </div>
163
+ {:else}
164
+ <MarkdownRenderer content={aiResponse} streaming={isPredicting} />
165
+ {/if}
166
+ </div>
167
+ </div>
168
+ </div>
169
+ <div class="p-2 border-t flex justify-end gap-2 bg-muted/10">
170
+ <button
171
+ class="text-xs px-3 py-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground"
172
+ onclick={() => { aiMode = false; searchValue = ''; }}
173
+ >
174
+ Clear & Return
175
+ </button>
176
+ </div>
177
+ {/if}
178
+ </Command.Root>
179
+ </Dialog.DialogContent>
180
+ </Dialog.Dialog>
@@ -0,0 +1,65 @@
1
+ <script lang="ts">
2
+ import { Badge } from './ui/badge/index.js';
3
+ import { TrendingUp, TrendingDown, AlertTriangle } from 'lucide-svelte';
4
+ import TooltipButton from './TooltipButton.svelte';
5
+
6
+ interface Props {
7
+ /** The actual current value */
8
+ value: number;
9
+ /** The baseline/expected value to compare against */
10
+ baseline: number;
11
+ /** Threshold percentage to trigger an anomaly (e.g. 0.2 means 20% diff) */
12
+ threshold?: number;
13
+ /** Optional label formatting */
14
+ formatter?: (val: number) => string;
15
+ /** If true, lower is better (e.g. latency). Default false (higher is better e.g. revenue). */
16
+ lowerIsBetter?: boolean;
17
+ class?: string;
18
+ }
19
+
20
+ let {
21
+ value,
22
+ baseline,
23
+ threshold = 0.2,
24
+ formatter = (v) => v.toString(),
25
+ lowerIsBetter = false,
26
+ class: className = ''
27
+ }: Props = $props();
28
+
29
+ // Calculate percentage difference
30
+ const diff = $derived(baseline === 0 ? 0 : (value - baseline) / Math.abs(baseline));
31
+ const isAnomaly = $derived(Math.abs(diff) >= threshold);
32
+
33
+ // Determine sentiment (good, bad, neutral)
34
+ const isGood = $derived(
35
+ !isAnomaly ? null :
36
+ (lowerIsBetter ? diff < 0 : diff > 0)
37
+ );
38
+
39
+ const percentLabel = $derived(`${Math.abs(diff * 100).toFixed(1)}%`);
40
+ </script>
41
+
42
+ {#if !isAnomaly}
43
+ <span class="text-muted-foreground text-sm font-medium {className}">
44
+ {formatter(value)}
45
+ </span>
46
+ {:else}
47
+ <TooltipButton
48
+ tooltip={`Baseline: ${formatter(baseline)} (${diff > 0 ? '+' : '-'}${percentLabel})`}
49
+ variant="ghost"
50
+ size="sm"
51
+ class="p-0 h-auto hover:bg-transparent"
52
+ >
53
+ <Badge
54
+ variant={isGood ? 'default' : 'destructive'}
55
+ class="flex items-center gap-1 font-mono text-xs px-1.5 py-0 {isGood ? 'bg-green-500 hover:bg-green-600 text-white' : ''} {className}"
56
+ >
57
+ {#if diff > 0}
58
+ <TrendingUp class="h-3 w-3" />
59
+ {:else}
60
+ <TrendingDown class="h-3 w-3" />
61
+ {/if}
62
+ {formatter(value)}
63
+ </Badge>
64
+ </TooltipButton>
65
+ {/if}
@@ -0,0 +1,163 @@
1
+ <script lang="ts">
2
+ import { getChatProvider, getChatContext } from '@svadmin/core';
3
+ import { Sparkles, X, ChevronRight, Check } from 'lucide-svelte';
4
+ import { fade, slide } from 'svelte/transition';
5
+ import { Button } from './ui/button/index.js';
6
+ import MarkdownRenderer from './MarkdownRenderer.svelte';
7
+ import TooltipButton from './TooltipButton.svelte';
8
+
9
+ let { open = $bindable(false) } = $props<{ open?: boolean }>();
10
+
11
+ const provider = $derived(getChatProvider());
12
+ const ctx = $derived(getChatContext());
13
+
14
+ let isPredicting = $state(false);
15
+ let insightText = $state('');
16
+ let abortController: AbortController | null = null;
17
+ let hasFetchedForContext = $state('');
18
+
19
+ // Automatically request insights when the context changes
20
+ $effect(() => {
21
+ if (!open || !provider) return;
22
+
23
+ // Create a context hash to avoid refetching for the same page
24
+ const currentHash = `${ctx.currentResource}-${ctx.currentView}-${ctx.selectedRecordId}`;
25
+ if (hasFetchedForContext === currentHash) return;
26
+
27
+ hasFetchedForContext = currentHash;
28
+ getInsights();
29
+ });
30
+
31
+ async function getInsights() {
32
+ if (!provider) return;
33
+
34
+ isPredicting = true;
35
+ insightText = '';
36
+
37
+ if (abortController) abortController.abort();
38
+ abortController = new AbortController();
39
+
40
+ const prompt = `Analyze the current context and provide 3 quick insights or context-aware suggestions for the admin user.
41
+ Resource: ${ctx.currentResource}
42
+ View: ${ctx.currentView}
43
+ Record ID: ${ctx.selectedRecordId || 'None'}
44
+ Keep it concise, professional, and use bullet points.`;
45
+
46
+ try {
47
+ const result = provider.sendMessage(
48
+ [{ id: 'insights', role: 'system', content: prompt, timestamp: Date.now() }],
49
+ { signal: abortController.signal }
50
+ );
51
+
52
+ if (Symbol.asyncIterator in result) {
53
+ for await (const chunk of result as AsyncIterable<string>) {
54
+ insightText += chunk;
55
+ }
56
+ } else {
57
+ insightText = await result;
58
+ }
59
+ } catch (err: any) {
60
+ if (err.name !== 'AbortError') {
61
+ insightText = 'Failed to generate insights for this context.';
62
+ }
63
+ } finally {
64
+ isPredicting = false;
65
+ }
66
+ }
67
+ </script>
68
+
69
+ {#if open}
70
+ <div
71
+ class="fixed right-0 top-14 bottom-0 w-80 bg-card border-l shadow-2xl z-40 flex flex-col"
72
+ transition:slide={{ axis: 'x', duration: 300 }}
73
+ >
74
+ <!-- Header -->
75
+ <div class="h-14 flex items-center justify-between px-4 border-b bg-muted/20">
76
+ <div class="flex items-center gap-2 font-medium">
77
+ <Sparkles class="h-4 w-4 text-primary" />
78
+ Copilot Sidebar
79
+ </div>
80
+ <TooltipButton tooltip="Close panel" variant="ghost" size="icon-sm" onclick={() => open = false}>
81
+ <X class="h-4 w-4" />
82
+ </TooltipButton>
83
+ </div>
84
+
85
+ <!-- Context Meta -->
86
+ <div class="px-4 py-3 border-b bg-muted/10 text-xs text-muted-foreground flex flex-col gap-1">
87
+ <div class="flex items-center gap-2">
88
+ <span class="w-16">Resource:</span>
89
+ <span class="font-mono text-foreground">{ctx.currentResource || 'Dashboard'}</span>
90
+ </div>
91
+ <div class="flex items-center gap-2">
92
+ <span class="w-16">View:</span>
93
+ <span class="font-mono text-foreground capitalize">{ctx.currentView || 'List'}</span>
94
+ </div>
95
+ {#if ctx.selectedRecordId}
96
+ <div class="flex items-center gap-2">
97
+ <span class="w-16">Record ID:</span>
98
+ <span class="font-mono text-foreground">{ctx.selectedRecordId}</span>
99
+ </div>
100
+ {/if}
101
+ </div>
102
+
103
+ <!-- Content Area -->
104
+ <div class="flex-1 overflow-y-auto p-4 space-y-6">
105
+
106
+ <!-- Smart Insights Section -->
107
+ <div>
108
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3 flex items-center justify-between">
109
+ Smart Insights
110
+ {#if isPredicting}
111
+ <span class="flex items-center gap-1 text-[10px] text-primary normal-case font-medium">
112
+ <div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-75"></div>
113
+ <div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-150"></div>
114
+ <div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-300"></div>
115
+ </span>
116
+ {:else}
117
+ <Button variant="ghost" size="icon-sm" class="h-5 w-5 hover:bg-muted" onclick={getInsights}>
118
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 21v-5h5"/></svg>
119
+ </Button>
120
+ {/if}
121
+ </h4>
122
+
123
+ <div class="text-sm">
124
+ {#if !insightText && !isPredicting}
125
+ <div class="text-muted-foreground italic text-center py-4 bg-muted/30 rounded-lg border border-dashed">
126
+ No insights loaded.
127
+ </div>
128
+ {:else}
129
+ <div class="bg-primary/5 p-3 rounded-xl border border-primary/10 prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0.5">
130
+ <MarkdownRenderer content={insightText} streaming={isPredicting} />
131
+ </div>
132
+ {/if}
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Quick Actions (Example static definitions for the copilot) -->
137
+ <div>
138
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
139
+ Suggested Actions
140
+ </h4>
141
+ <div class="space-y-2">
142
+ {#if ctx.currentView === 'list'}
143
+ <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
144
+ <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
145
+ <span>Generate monthly report</span>
146
+ </button>
147
+ <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
148
+ <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
149
+ <span>Find anomalies in {ctx.currentResource}</span>
150
+ </button>
151
+ {:else if ctx.currentView === 'edit' || ctx.currentView === 'create'}
152
+ <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
153
+ <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
154
+ <span>Auto-fill form from context</span>
155
+ </button>
156
+ {:else}
157
+ <div class="text-muted-foreground text-sm py-2 text-center">No precise actions for this view</div>
158
+ {/if}
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ {/if}
@@ -34,14 +34,14 @@
34
34
  const locale = $derived(getLocale());
35
35
 
36
36
  // Auto-trace state changes in dev mode via Svelte 5 $inspect
37
- if (import.meta.env?.DEV) {
37
+ if ((import.meta as any).env?.DEV) {
38
38
  $inspect({ path, theme, colorTheme, locale, resourceCount: resources.length });
39
39
  }
40
40
  </script>
41
41
 
42
42
  <svelte:window onkeydown={handleKeydown} />
43
43
 
44
- {#if import.meta.env.DEV}
44
+ {#if (import.meta as any).env?.DEV}
45
45
  {#if visible}
46
46
  <div
47
47
  class="fixed bottom-0 right-4 z-[9999] w-[420px] max-w-[95vw] rounded-t-xl border border-b-0 bg-card shadow-2xl text-[0.8125rem] overflow-hidden"
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ import { getChatProvider } from '@svadmin/core';
3
+ import { Sparkles, RefreshCcw, Loader2 } from 'lucide-svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import MarkdownRenderer from './MarkdownRenderer.svelte';
6
+
7
+ interface Props {
8
+ title?: string;
9
+ context: string;
10
+ autoFetch?: boolean;
11
+ class?: string;
12
+ }
13
+
14
+ let { title = 'AI Insights', context, autoFetch = true, class: className = '' }: Props = $props();
15
+
16
+ const provider = $derived(getChatProvider());
17
+
18
+ let isPredicting = $state(false);
19
+ let insightText = $state('');
20
+ let abortController: AbortController | null = null;
21
+ let hasFetchedForContext = $state('');
22
+
23
+ $effect(() => {
24
+ if (autoFetch && context && provider && hasFetchedForContext !== context) {
25
+ hasFetchedForContext = context;
26
+ getInsights();
27
+ }
28
+ });
29
+
30
+ export async function getInsights() {
31
+ if (!provider || !context) return;
32
+
33
+ isPredicting = true;
34
+ insightText = '';
35
+
36
+ if (abortController) abortController.abort();
37
+ abortController = new AbortController();
38
+
39
+ const prompt = `Based on the following data/context, generate 3 concise, highly professional insights.
40
+ Use bullet points. Do not include introductory text like "Here are the insights".
41
+
42
+ Data Context:
43
+ ${context}`;
44
+
45
+ try {
46
+ const result = provider.sendMessage(
47
+ [{ id: 'insights', role: 'user', content: prompt, timestamp: Date.now() }],
48
+ { signal: abortController.signal }
49
+ );
50
+
51
+ if (Symbol.asyncIterator in result) {
52
+ for await (const chunk of result as AsyncIterable<string>) {
53
+ insightText += chunk;
54
+ }
55
+ } else {
56
+ insightText = await result;
57
+ }
58
+ } catch (err: any) {
59
+ if (err.name !== 'AbortError') {
60
+ insightText = 'Failed to generate insights.';
61
+ }
62
+ } finally {
63
+ isPredicting = false;
64
+ }
65
+ }
66
+ </script>
67
+
68
+ <div class="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col {className}">
69
+ <div class="flex items-center justify-between p-4 border-b">
70
+ <div class="flex items-center gap-2 font-medium">
71
+ <Sparkles class="h-4 w-4 text-amber-500" />
72
+ {title}
73
+ </div>
74
+ <Button variant="ghost" size="icon-sm" class="h-6 w-6" onclick={getInsights} disabled={isPredicting || !provider}>
75
+ <RefreshCcw class="h-3 w-3 {isPredicting ? 'animate-spin' : ''}" />
76
+ </Button>
77
+ </div>
78
+
79
+ <div class="p-4 flex-1">
80
+ {#if !provider}
81
+ <div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
82
+ ChatProvider not configured.
83
+ </div>
84
+ {:else if isPredicting && !insightText}
85
+ <div class="flex items-center justify-center gap-2 text-sm text-muted-foreground h-full py-4">
86
+ <Loader2 class="h-4 w-4 animate-spin" /> Analyzing data...
87
+ </div>
88
+ {:else if !insightText}
89
+ <div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
90
+ No insights available. Click refresh to analyze.
91
+ </div>
92
+ {:else}
93
+ <div class="text-sm prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0.5">
94
+ <MarkdownRenderer content={insightText} streaming={isPredicting} />
95
+ </div>
96
+ {/if}
97
+ </div>
98
+ </div>
@@ -62,7 +62,7 @@
62
62
  let collapsed = $state(false);
63
63
  </script>
64
64
 
65
- <div role="application" onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; } }}>
65
+ <svelte:window onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; } }} />
66
66
  {#if loading}
67
67
  <div class="flex h-screen" in:fade={{ duration: 150 }}>
68
68
  <div class="hidden md:block w-64 bg-sidebar/80 p-4 space-y-4">
@@ -128,4 +128,3 @@
128
128
  <ChatDialog />
129
129
  <Toast />
130
130
  {/if}
131
- </div>
@@ -1,8 +1,12 @@
1
1
  <script lang="ts">
2
+ // @ts-expect-error - missing types
2
3
  import { Marked } from 'marked';
4
+ // @ts-expect-error - missing types
3
5
  import { markedHighlight } from 'marked-highlight';
6
+ // @ts-expect-error - missing types
4
7
  import hljs from 'highlight.js';
5
8
  import 'highlight.js/styles/github-dark.css'; // or your preferred theme
9
+ // @ts-expect-error - missing types
6
10
  import DOMPurify from 'isomorphic-dompurify';
7
11
  import { Check, Copy } from 'lucide-svelte';
8
12
  import { Button } from './ui/button/index.js';
@@ -22,7 +26,7 @@
22
26
  const marked = new Marked(
23
27
  markedHighlight({
24
28
  langPrefix: 'hljs language-',
25
- highlight(code, lang) {
29
+ highlight(code: string, lang: string) {
26
30
  const language = hljs.getLanguage(lang) ? lang : 'plaintext';
27
31
  return hljs.highlight(code, { language }).value;
28
32
  }
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ import { Input } from './ui/input/index.js';
3
+ import type { ChatProvider } from '@svadmin/core';
4
+
5
+ interface Props {
6
+ /** The bound value of the input */
7
+ value: string;
8
+ /** The chat provider to generate suggestions */
9
+ provider?: ChatProvider;
10
+ /** Context given to the AI to help it predict the text */
11
+ context?: string;
12
+ /** Standard input props */
13
+ placeholder?: string;
14
+ class?: string;
15
+ id?: string;
16
+ disabled?: boolean;
17
+ name?: string;
18
+ type?: string;
19
+ }
20
+
21
+ let {
22
+ value = $bindable(),
23
+ provider,
24
+ context = '',
25
+ placeholder = '',
26
+ class: className = '',
27
+ id,
28
+ disabled = false,
29
+ name,
30
+ type = 'text'
31
+ }: Props = $props();
32
+
33
+ let suggestion = $state('');
34
+ let isPredicting = $state(false);
35
+ let abortController: AbortController | null = null;
36
+ let predictTimer: ReturnType<typeof setTimeout> | null = null;
37
+
38
+ // The ghost text shows the suggestion, but only the part *after* what the user has typed
39
+ // It only displays if the suggestion actually starts with the current value
40
+ const ghostText = $derived.by(() => {
41
+ if (!suggestion || !value) return '';
42
+ const lowerVal = value.toLowerCase();
43
+ const lowerSugg = suggestion.toLowerCase();
44
+
45
+ if (lowerSugg.startsWith(lowerVal) && suggestion.length > value.length) {
46
+ // Preserve original casing from the suggestion but append only the suffix
47
+ return value + suggestion.slice(value.length);
48
+ }
49
+ return '';
50
+ });
51
+
52
+ const suffixText = $derived(ghostText ? ghostText.slice(value.length) : '');
53
+
54
+ async function predict() {
55
+ if (!provider || !value.trim()) {
56
+ suggestion = '';
57
+ return;
58
+ }
59
+
60
+ isPredicting = true;
61
+ if (abortController) {
62
+ abortController.abort();
63
+ }
64
+ abortController = new AbortController();
65
+
66
+ try {
67
+ const prompt = `You are an autocomplete engine. The user is typing: "${value}".
68
+ Context: ${context}.
69
+ Provide ONLY the most likely completion of the exact phrase the user is typing.
70
+ Include the user's input in your returned string.
71
+ Do not include any other text, Markdown formatting, quotes, or explanations.`;
72
+
73
+ const result = provider.sendMessage(
74
+ [{ id: 'prompt', role: 'user', content: prompt, timestamp: Date.now() }],
75
+ { signal: abortController.signal }
76
+ );
77
+
78
+ suggestion = '';
79
+
80
+ // Handle streaming or Promise responses seamlessly
81
+ if (Symbol.asyncIterator in result) {
82
+ for await (const chunk of result as AsyncIterable<string>) {
83
+ suggestion += chunk;
84
+ }
85
+ } else {
86
+ suggestion = await result;
87
+ }
88
+
89
+ // Clean up the suggestion
90
+ suggestion = suggestion.trim().replace(/^["']|["']$/g, '');
91
+ } catch (err: any) {
92
+ if (err.name !== 'AbortError') {
93
+ suggestion = '';
94
+ }
95
+ } finally {
96
+ isPredicting = false;
97
+ }
98
+ }
99
+
100
+ function handleInput(e: Event) {
101
+ const target = e.target as HTMLInputElement;
102
+ value = target.value;
103
+
104
+ // Reset suggestion immediately if it no longer matches
105
+ if (suggestion && !suggestion.toLowerCase().startsWith(value.toLowerCase())) {
106
+ suggestion = '';
107
+ }
108
+
109
+ if (predictTimer) clearTimeout(predictTimer);
110
+
111
+ // Debounce prediction
112
+ predictTimer = setTimeout(() => {
113
+ predict();
114
+ }, 500);
115
+ }
116
+
117
+ function handleKeyDown(e: KeyboardEvent) {
118
+ if (e.key === 'Tab' && suffixText) {
119
+ e.preventDefault(); // Prevent standard tab navigation
120
+ value = ghostText;
121
+ suggestion = ''; // Clear suggestion once accepted
122
+ }
123
+ }
124
+
125
+ </script>
126
+
127
+ <div class="relative w-full {className}">
128
+ <!-- Ghost Text Layer (positioned exactly beneath the actual text) -->
129
+ {#if suffixText}
130
+ <div
131
+ class="absolute inset-0 pointer-events-none flex items-center px-3 text-sm font-medium"
132
+ aria-hidden="true"
133
+ >
134
+ <span class="opacity-0">{value}</span>
135
+ <span class="text-muted-foreground/40">{suffixText}</span>
136
+ </div>
137
+ {/if}
138
+
139
+ <!-- Actual Input Layer -->
140
+ <Input
141
+ {id}
142
+ {name}
143
+ {type}
144
+ {disabled}
145
+ {placeholder}
146
+ value={value}
147
+ oninput={handleInput}
148
+ onkeydown={handleKeyDown}
149
+ class="bg-transparent relative z-10 w-full"
150
+ />
151
+
152
+ <!-- Loading indicator -->
153
+ {#if isPredicting}
154
+ <div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
155
+ <div class="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse"></div>
156
+ </div>
157
+ {/if}
158
+ </div>
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ import { Mic, MicOff, Loader2 } from 'lucide-svelte';
3
+ import TooltipButton from './TooltipButton.svelte';
4
+
5
+ interface Props {
6
+ /** Called when a speech recognition result is finalized */
7
+ onresult?: (text: string) => void;
8
+ /** Called when the engine starts listening */
9
+ onstart?: () => void;
10
+ /** Called when the engine stops listening */
11
+ onend?: () => void;
12
+ /** Standard button size */
13
+ size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
14
+ /** Custom class for the button */
15
+ class?: string;
16
+ }
17
+
18
+ let {
19
+ onresult,
20
+ onstart,
21
+ onend,
22
+ size = 'icon',
23
+ class: className = ''
24
+ }: Props = $props();
25
+
26
+ let isListening = $state(false);
27
+ let notSupported = $state(false);
28
+ let recognition: any = null;
29
+
30
+ // Initialize SpeechRecognition on mount
31
+ $effect(() => {
32
+ // @ts-ignore - Browser specific APIs
33
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
34
+
35
+ if (!SpeechRecognition) {
36
+ notSupported = true;
37
+ return;
38
+ }
39
+
40
+ recognition = new SpeechRecognition();
41
+ recognition.continuous = false;
42
+ // Tries to return interim results, but we mainly care about final
43
+ recognition.interimResults = false;
44
+ // Uses the browser locale by default
45
+ recognition.lang = navigator.language || 'en-US';
46
+
47
+ recognition.onstart = () => {
48
+ isListening = true;
49
+ if (onstart) onstart();
50
+ };
51
+
52
+ recognition.onresult = (event: any) => {
53
+ const transcript = Array.from(event.results)
54
+ .map((result: any) => result[0])
55
+ .map((result: any) => result.transcript)
56
+ .join('');
57
+
58
+ if (onresult && transcript) {
59
+ onresult(transcript);
60
+ }
61
+ };
62
+
63
+ recognition.onerror = (event: any) => {
64
+ console.warn('SpeechRecognition error:', event.error);
65
+ isListening = false;
66
+ if (onend) onend();
67
+ };
68
+
69
+ recognition.onend = () => {
70
+ isListening = false;
71
+ if (onend) onend();
72
+ };
73
+ });
74
+
75
+ function toggleListening() {
76
+ if (!recognition) return;
77
+
78
+ if (isListening) {
79
+ recognition.stop();
80
+ } else {
81
+ recognition.start();
82
+ }
83
+ }
84
+ </script>
85
+
86
+ <TooltipButton
87
+ tooltip={notSupported ? "Voice input not supported in this browser" : (isListening ? "Stop listening" : "Start voice input")}
88
+ variant={isListening ? "destructive" : "ghost"}
89
+ {size}
90
+ class="{isListening ? 'animate-pulse' : ''} {className}"
91
+ onclick={toggleListening}
92
+ disabled={notSupported}
93
+ >
94
+ {#if isListening}
95
+ <Loader2 class="h-4 w-4 animate-spin -ml-1 mr-1.5 inline-block sm:hidden" />
96
+ <MicOff class="h-4 w-4" />
97
+ {:else}
98
+ <Mic class="h-4 w-4" />
99
+ {/if}
100
+ </TooltipButton>
@@ -53,11 +53,19 @@
53
53
  children,
54
54
  ...restProps
55
55
  }: ButtonProps = $props();
56
+
57
+ let anchorRef: HTMLAnchorElement | null = $state(null);
58
+ let buttonRef: HTMLButtonElement | null = $state(null);
59
+
60
+ $effect(() => {
61
+ if (href && anchorRef) ref = anchorRef as any;
62
+ else if (!href && buttonRef) ref = buttonRef as any;
63
+ });
56
64
  </script>
57
65
 
58
66
  {#if href}
59
67
  <a
60
- bind:this={ref}
68
+ bind:this={anchorRef}
61
69
  data-slot="button"
62
70
  class={cn(buttonVariants({ variant, size }), className)}
63
71
  href={disabled ? undefined : href}
@@ -70,7 +78,7 @@
70
78
  </a>
71
79
  {:else}
72
80
  <button
73
- bind:this={ref}
81
+ bind:this={buttonRef}
74
82
  data-slot="button"
75
83
  class={cn(buttonVariants({ variant, size }), className)}
76
84
  {type}
@@ -6,7 +6,7 @@
6
6
  let { class: className, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
7
7
  </script>
8
8
 
9
- <span aria-hidden data-slot="pagination-ellipsis" class={cn("flex size-9 items-center justify-center", className)} {...restProps}>
9
+ <span aria-hidden="true" data-slot="pagination-ellipsis" class={cn("flex size-9 items-center justify-center", className)} {...restProps}>
10
10
  <Ellipsis class="size-4" />
11
11
  <span class="sr-only">More pages</span>
12
12
  </span>
@@ -43,6 +43,7 @@
43
43
  <!-- Overlay -->
44
44
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
45
45
  <div
46
+ role="presentation"
46
47
  class="fixed inset-0 z-50 bg-black/80 animate-in fade-in-0"
47
48
  onclick={close}
48
49
  ></div>
package/src/index.ts CHANGED
@@ -46,7 +46,14 @@ export { default as StepsForm } from './components/StepsForm.svelte';
46
46
  export { default as InfiniteList } from './components/InfiniteList.svelte';
47
47
  export { default as ComboboxField } from './components/ComboboxField.svelte';
48
48
  export { default as PasswordInput } from './components/PasswordInput.svelte';
49
- export { default as ChatDialog } from './components/ChatDialog.svelte';
49
+ export { default as ChatDialog } from "./components/ChatDialog.svelte";
50
+ export { default as MarkdownRenderer } from "./components/MarkdownRenderer.svelte";
51
+ export { default as SmartSuggest } from "./components/SmartSuggest.svelte";
52
+ export { default as AICommandBar } from "./components/AICommandBar.svelte";
53
+ export { default as CopilotPanel } from "./components/CopilotPanel.svelte";
54
+ export { default as InsightCard } from "./components/InsightCard.svelte";
55
+ export { default as AnomalyBadge } from "./components/AnomalyBadge.svelte";
56
+ export { default as VoiceInput } from "./components/VoiceInput.svelte";
50
57
  // Field display components
51
58
  export { default as NumberField } from './components/fields/NumberField.svelte';
52
59
  export { default as DateField } from './components/fields/DateField.svelte';