@svadmin/ui 0.18.0 → 0.20.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.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -26,7 +26,29 @@
26
26
  "bits-ui": "^2.0.0",
27
27
  "tailwind-variants": "^3.0.0",
28
28
  "clsx": "^2.0.0",
29
- "tailwind-merge": "^3.0.0"
29
+ "tailwind-merge": "^3.0.0",
30
+ "marked": "^15.0.0",
31
+ "marked-highlight": "^2.2.0",
32
+ "highlight.js": "^11.0.0",
33
+ "isomorphic-dompurify": "^2.0.0",
34
+ "@svadmin/editor": "^0.1.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "marked": {
38
+ "optional": true
39
+ },
40
+ "marked-highlight": {
41
+ "optional": true
42
+ },
43
+ "highlight.js": {
44
+ "optional": true
45
+ },
46
+ "isomorphic-dompurify": {
47
+ "optional": true
48
+ },
49
+ "@svadmin/editor": {
50
+ "optional": true
51
+ }
30
52
  },
31
53
  "dependencies": {
32
54
  "@lucide/svelte": "^0.577.0",
@@ -0,0 +1,226 @@
1
+ <script lang="ts">
2
+ import { t } from '@svadmin/core/i18n';
3
+ import { getAuthProvider } from '@svadmin/core';
4
+ import type { AuditLog } from '@svadmin/core';
5
+ import { toast } from '@svadmin/core/toast';
6
+ import * as Table from './ui/table/index.js';
7
+ import { Badge } from './ui/badge/index.js';
8
+ import { Button } from './ui/button/index.js';
9
+ import { Input } from './ui/input/index.js';
10
+ import { Loader2, ChevronLeft, ChevronRight, Search, FileSearch, Eye, X } from 'lucide-svelte';
11
+
12
+ const authProvider = getAuthProvider();
13
+
14
+ let logs = $state<AuditLog[]>([]);
15
+ let total = $state(0);
16
+ let page = $state(1);
17
+ let pageSize = 20;
18
+ let loading = $state(true);
19
+ let error = $state<string | null>(null);
20
+ let searchQuery = $state('');
21
+
22
+ // Snapshot drawer
23
+ let drawerOpen = $state(false);
24
+ let drawerLog = $state<AuditLog | null>(null);
25
+
26
+ async function loadLogs() {
27
+ if (!authProvider?.getAuditLogs) {
28
+ error = t('settings.auditNotSupported') ?? 'Audit logs are not supported by the current AuthProvider';
29
+ loading = false;
30
+ return;
31
+ }
32
+ loading = true;
33
+ try {
34
+ const result = await authProvider.getAuditLogs({ page, pageSize });
35
+ logs = result.data;
36
+ total = result.total;
37
+ } catch (e) {
38
+ toast.error((e as Error).message);
39
+ } finally {
40
+ loading = false;
41
+ }
42
+ }
43
+
44
+ $effect(() => {
45
+ loadLogs();
46
+ });
47
+
48
+ let totalPages = $derived(Math.ceil(total / pageSize) || 1);
49
+
50
+ let filteredLogs = $derived(
51
+ searchQuery.trim()
52
+ ? logs.filter(l =>
53
+ (l.userName ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
54
+ l.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
55
+ (l.resource ?? '').toLowerCase().includes(searchQuery.toLowerCase())
56
+ )
57
+ : logs
58
+ );
59
+
60
+ function openSnapshot(log: AuditLog) {
61
+ drawerLog = log;
62
+ drawerOpen = true;
63
+ }
64
+
65
+ function formatDate(d: string | Date) {
66
+ return new Date(d).toLocaleString();
67
+ }
68
+
69
+ function getActionColor(action: string): 'default' | 'secondary' | 'destructive' | 'outline' {
70
+ const a = action.toLowerCase();
71
+ if (a.includes('delete') || a.includes('remove')) return 'destructive';
72
+ if (a.includes('create') || a.includes('add')) return 'default';
73
+ if (a.includes('update') || a.includes('edit')) return 'secondary';
74
+ return 'outline';
75
+ }
76
+ </script>
77
+
78
+ <div class="h-full flex flex-col">
79
+ <!-- Header -->
80
+ <div class="mb-6">
81
+ <h2 class="text-xl font-semibold text-foreground flex items-center gap-2">
82
+ <FileSearch class="h-5 w-5 text-muted-foreground" />
83
+ {t('settings.auditLogs') ?? 'Audit Logs'}
84
+ </h2>
85
+ <p class="text-sm text-muted-foreground mt-1">
86
+ {t('settings.auditDescription') ?? 'Track and review all system operations for compliance and debugging.'}
87
+ </p>
88
+ </div>
89
+
90
+ {#if error}
91
+ <div class="p-6 border border-destructive/20 bg-destructive/5 text-destructive rounded-lg text-center">
92
+ <p>{error}</p>
93
+ </div>
94
+ {:else}
95
+ <!-- Search Bar -->
96
+ <div class="mb-4 relative">
97
+ <Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
98
+ <Input
99
+ bind:value={searchQuery}
100
+ placeholder={t('settings.searchLogs') ?? 'Search by user, action, or resource...'}
101
+ class="pl-9"
102
+ />
103
+ </div>
104
+
105
+ <!-- Table -->
106
+ <div class="flex-1 border rounded-lg bg-card overflow-hidden">
107
+ {#if loading}
108
+ <div class="flex items-center justify-center h-48 text-muted-foreground">
109
+ <Loader2 class="h-5 w-5 animate-spin mr-2" />
110
+ {t('common.loading')}
111
+ </div>
112
+ {:else}
113
+ <div class="overflow-auto">
114
+ <Table.Root>
115
+ <Table.Header>
116
+ <Table.Row>
117
+ <Table.Head class="w-[160px]">{t('settings.auditTime') ?? 'Time'}</Table.Head>
118
+ <Table.Head>{t('settings.auditUser') ?? 'User'}</Table.Head>
119
+ <Table.Head>{t('settings.auditAction') ?? 'Action'}</Table.Head>
120
+ <Table.Head>{t('settings.auditResource') ?? 'Resource'}</Table.Head>
121
+ <Table.Head class="w-[120px]">{t('settings.auditIp') ?? 'IP Address'}</Table.Head>
122
+ <Table.Head class="w-[60px] text-center">{t('common.detail') ?? 'Detail'}</Table.Head>
123
+ </Table.Row>
124
+ </Table.Header>
125
+ <Table.Body>
126
+ {#each filteredLogs as log}
127
+ <Table.Row class="hover:bg-muted/50 transition-colors">
128
+ <Table.Cell class="text-xs text-muted-foreground font-mono">
129
+ {formatDate(log.createdAt)}
130
+ </Table.Cell>
131
+ <Table.Cell class="text-sm font-medium">{log.userName ?? log.userId ?? '—'}</Table.Cell>
132
+ <Table.Cell>
133
+ <Badge variant={getActionColor(log.action)}>{log.action}</Badge>
134
+ </Table.Cell>
135
+ <Table.Cell class="text-sm text-muted-foreground">{log.resource ?? '—'}</Table.Cell>
136
+ <Table.Cell class="text-xs text-muted-foreground font-mono">{log.ipAddress ?? '—'}</Table.Cell>
137
+ <Table.Cell class="text-center">
138
+ {#if log.details}
139
+ <Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => openSnapshot(log)}>
140
+ <Eye class="h-3.5 w-3.5" />
141
+ </Button>
142
+ {/if}
143
+ </Table.Cell>
144
+ </Table.Row>
145
+ {:else}
146
+ <Table.Row>
147
+ <Table.Cell colspan={6} class="h-24 text-center text-muted-foreground">
148
+ {t('common.noData')}
149
+ </Table.Cell>
150
+ </Table.Row>
151
+ {/each}
152
+ </Table.Body>
153
+ </Table.Root>
154
+ </div>
155
+ {/if}
156
+ </div>
157
+
158
+ <!-- Pagination -->
159
+ <div class="mt-4 flex items-center justify-between text-sm text-muted-foreground">
160
+ <span>{total} {t('settings.auditTotal') ?? 'total records'}</span>
161
+ <div class="flex items-center gap-2">
162
+ <Button
163
+ variant="outline" size="sm"
164
+ disabled={page <= 1}
165
+ onclick={() => { page--; loadLogs(); }}
166
+ >
167
+ <ChevronLeft class="h-4 w-4" />
168
+ </Button>
169
+ <span>{page} / {totalPages}</span>
170
+ <Button
171
+ variant="outline" size="sm"
172
+ disabled={page >= totalPages}
173
+ onclick={() => { page++; loadLogs(); }}
174
+ >
175
+ <ChevronRight class="h-4 w-4" />
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ {/if}
180
+ </div>
181
+
182
+ <!-- Snapshot Drawer -->
183
+ {#if drawerOpen && drawerLog}
184
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
185
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
186
+ <div class="fixed inset-0 z-[100] flex justify-end bg-black/40 backdrop-blur-sm" onclick={() => drawerOpen = false}>
187
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
188
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
189
+ <div class="bg-card shadow-2xl w-full max-w-2xl h-full flex flex-col animate-in slide-in-from-right duration-300" onclick={(e) => e.stopPropagation()}>
190
+ <!-- Drawer Header -->
191
+ <div class="p-6 border-b border-border flex items-center justify-between bg-muted/30">
192
+ <div>
193
+ <h2 class="text-lg font-bold text-foreground flex items-center gap-2">
194
+ <FileSearch class="h-5 w-5 text-primary" />
195
+ {t('settings.auditSnapshot') ?? 'Change Snapshot'}
196
+ </h2>
197
+ <p class="text-xs text-muted-foreground mt-1">{formatDate(drawerLog.createdAt)} · {drawerLog.userName ?? drawerLog.userId}</p>
198
+ </div>
199
+ <button onclick={() => drawerOpen = false} class="text-muted-foreground hover:text-foreground transition p-2 rounded-lg hover:bg-muted">
200
+ <X class="w-5 h-5" />
201
+ </button>
202
+ </div>
203
+
204
+ <!-- Meta Tags -->
205
+ <div class="px-6 py-3 border-b border-border flex items-center gap-2 flex-wrap">
206
+ <Badge variant="outline">{drawerLog.action}</Badge>
207
+ {#if drawerLog.resource}
208
+ <Badge variant="secondary">{drawerLog.resource}</Badge>
209
+ {/if}
210
+ {#if drawerLog.ipAddress}
211
+ <Badge variant="outline" class="font-mono text-xs">{drawerLog.ipAddress}</Badge>
212
+ {/if}
213
+ </div>
214
+
215
+ <!-- JSON Diff Viewer -->
216
+ <div class="flex-1 p-6 overflow-y-auto bg-foreground/[0.03] dark:bg-background/50">
217
+ <pre class="whitespace-pre-wrap text-xs font-mono leading-relaxed text-foreground/80 bg-muted/30 rounded-lg p-4 border border-border">{typeof drawerLog.details === 'object' ? JSON.stringify(drawerLog.details, null, 2) : drawerLog.details}</pre>
218
+ </div>
219
+
220
+ <!-- Footer -->
221
+ <div class="p-4 border-t border-border bg-muted/20 text-right text-[11px] text-muted-foreground font-medium">
222
+ System audit snapshot · Read-only
223
+ </div>
224
+ </div>
225
+ </div>
226
+ {/if}
@@ -13,7 +13,7 @@
13
13
  import { useList, useDelete, getResource } from '@svadmin/core';
14
14
  import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
15
15
  import { navigate } from '@svadmin/core/router';
16
- import { canAccess } from '@svadmin/core/permissions';
16
+ import { useCan, getAccessControlProvider } from '@svadmin/core';
17
17
  import { readURLState, writeURLState } from '@svadmin/core';
18
18
  import { t } from '@svadmin/core/i18n';
19
19
  import { fade } from 'svelte/transition';
@@ -36,6 +36,7 @@
36
36
  import ConfirmDialog from './ConfirmDialog.svelte';
37
37
  import TooltipButton from './TooltipButton.svelte';
38
38
  import InlineEdit from './InlineEdit.svelte';
39
+ import DraggableHeader from './DraggableHeader.svelte';
39
40
  import type { Snippet } from 'svelte';
40
41
 
41
42
  // ─── Props with Snippet composability ─────────────────────────
@@ -136,10 +137,15 @@
136
137
  const deleteMutation = deleteResult.mutation;
137
138
 
138
139
  // ─── Permissions ──────────────────────────────────────────────
139
- const canCreate = $derived(canAccess(resourceName, 'create').can && resource.canCreate !== false);
140
- const canEdit = $derived(canAccess(resourceName, 'edit').can && resource.canEdit !== false);
141
- const canDelete = $derived(canAccess(resourceName, 'delete').can && resource.canDelete !== false);
142
- const canExport = $derived(canAccess(resourceName, 'export').can);
140
+ const acEnabled = $derived(!!getAccessControlProvider());
141
+ const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
142
+ const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
143
+ const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
144
+ const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
145
+ const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
146
+ const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
147
+ const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
148
+ const canExport = $derived(canExportPerm.allowed);
143
149
 
144
150
  // ─── TanStack Table state ────────────────────────────────────
145
151
  let sorting = $state<SortingState>(untrack(() =>
@@ -11,7 +11,7 @@
11
11
  fallback?: Snippet;
12
12
  }>();
13
13
 
14
- const can = $derived(useCan(resource, action, params));
14
+ const can = useCan(() => ({ resource, action, params }));
15
15
  </script>
16
16
 
17
17
  {#if can.isLoading}
@@ -1,12 +1,12 @@
1
1
  <script lang="ts">
2
- import { getChatProvider, getChatContext, setChatContext } from '@svadmin/core';
3
- import type { ChatMessage, ChatAction } from '@svadmin/core';
2
+ import { getChatProvider, getChatContext, setChatContext, getAgentProvider, registerApproval, resolveApproval } from '@svadmin/core';
3
+ import type { ChatMessage, ChatAction, AgentEvent } from '@svadmin/core';
4
4
  import { t } from '@svadmin/core/i18n';
5
5
  import { useParsed } from '@svadmin/core';
6
6
  import { fly, fade, scale } from 'svelte/transition';
7
7
  import { Button } from './ui/button/index.js';
8
8
  import TooltipButton from './TooltipButton.svelte';
9
- import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2 } from 'lucide-svelte';
9
+ import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2, ShieldCheck, ShieldX, Wrench, LayoutDashboard } from 'lucide-svelte';
10
10
 
11
11
  interface Props {
12
12
  /** localStorage key for chat history persistence. Set to '' to disable. */
@@ -37,8 +37,12 @@
37
37
  let initialized = $state(false);
38
38
 
39
39
  const provider = $derived(getChatProvider());
40
+ const agent = $derived(getAgentProvider());
40
41
  const parsed = useParsed();
41
42
 
43
+ /** Track pending approval requests for UI rendering */
44
+ let pendingApprovalIds = $state<string[]>([]);
45
+
42
46
  // ─── Auto-update ChatContext from current route ─────────────
43
47
  $effect(() => {
44
48
  setChatContext({
@@ -125,7 +129,7 @@
125
129
  }
126
130
 
127
131
  async function sendMessage() {
128
- if (!inputValue.trim() || isStreaming || !provider) return;
132
+ if (!inputValue.trim() || isStreaming || (!provider && !agent)) return;
129
133
 
130
134
  const userMsg: ChatMessage = {
131
135
  id: genId(),
@@ -157,24 +161,93 @@
157
161
  const allMessages = messages.filter((m) => m.content);
158
162
  const messagesToSend = contextMsg ? [contextMsg, ...allMessages] : allMessages;
159
163
 
160
- const result = provider.sendMessage(
161
- messagesToSend,
162
- { signal: abortController.signal },
163
- );
164
-
165
- if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
166
- // Streaming response
167
- for await (const chunk of result as AsyncGenerator<string>) {
168
- assistantMsg.content += chunk;
164
+ if (agent) {
165
+ // ─── AgentProvider path: typed event stream ─────────
166
+ const stream = agent.chat(messagesToSend, {
167
+ signal: abortController.signal,
168
+ context: getChatContext(),
169
+ });
170
+
171
+ const collectedActions: ChatAction[] = [];
172
+
173
+ for await (const event of stream) {
174
+ switch (event.type) {
175
+ case 'text':
176
+ assistantMsg.content += event.content;
177
+ messages = [...messages.slice(0, -1), { ...assistantMsg }];
178
+ scrollToBottom();
179
+ break;
180
+
181
+ case 'tool_call':
182
+ assistantMsg.content += `\n🔧 *${event.tool}*(${JSON.stringify(event.args)})`;
183
+ messages = [...messages.slice(0, -1), { ...assistantMsg }];
184
+ scrollToBottom();
185
+ break;
186
+
187
+ case 'tool_result':
188
+ if (event.result.success) {
189
+ assistantMsg.content += `\n✅ ${event.tool} completed`;
190
+ } else {
191
+ assistantMsg.content += `\n❌ ${event.tool} failed: ${event.result.error ?? 'Unknown error'}`;
192
+ }
193
+ messages = [...messages.slice(0, -1), { ...assistantMsg }];
194
+ scrollToBottom();
195
+ break;
196
+
197
+ case 'approval_request': {
198
+ // Register in the core approval system
199
+ const reqId = event.id;
200
+ pendingApprovalIds = [...pendingApprovalIds, reqId];
201
+
202
+ collectedActions.push(
203
+ {
204
+ label: `✅ ${t('common.confirm') || 'Approve'}: ${event.description}`,
205
+ variant: 'default',
206
+ payload: { approvalId: reqId, approved: true },
207
+ },
208
+ {
209
+ label: `❌ ${t('common.cancel') || 'Reject'}`,
210
+ variant: 'destructive',
211
+ payload: { approvalId: reqId, approved: false },
212
+ },
213
+ );
214
+
215
+ assistantMsg.content += `\n⚠️ **${t('chat.approvalRequired') || 'Approval required'}**: ${event.description}`;
216
+ assistantMsg.actions = [...collectedActions];
217
+ messages = [...messages.slice(0, -1), { ...assistantMsg }];
218
+ scrollToBottom();
219
+ break;
220
+ }
221
+
222
+ case 'component':
223
+ assistantMsg.content += `\n📊 [${event.name}]`;
224
+ messages = [...messages.slice(0, -1), { ...assistantMsg }];
225
+ scrollToBottom();
226
+ break;
227
+
228
+ case 'done':
229
+ break;
230
+ }
231
+ }
232
+ } else if (provider) {
233
+ // ─── ChatProvider path: raw text stream ─────────────
234
+ const result = provider.sendMessage(
235
+ messagesToSend,
236
+ { signal: abortController.signal },
237
+ );
238
+
239
+ if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
240
+ for await (const chunk of result as AsyncGenerator<string>) {
241
+ assistantMsg.content += chunk;
242
+ messages = [...messages.slice(0, -1), { ...assistantMsg }];
243
+ scrollToBottom();
244
+ }
245
+ } else {
246
+ const text = await (result as Promise<string>);
247
+ assistantMsg.content = text;
169
248
  messages = [...messages.slice(0, -1), { ...assistantMsg }];
170
249
  scrollToBottom();
171
250
  }
172
- } else {
173
- // Non-streaming response
174
- const text = await (result as Promise<string>);
175
- assistantMsg.content = text;
176
- messages = [...messages.slice(0, -1), { ...assistantMsg }];
177
- scrollToBottom();
178
251
  }
179
252
  } catch (err: unknown) {
180
253
  if (err instanceof Error && err.name === 'AbortError') return;
@@ -228,6 +301,17 @@
228
301
  }
229
302
 
230
303
  function handleAction(action: ChatAction, msg: ChatMessage) {
304
+ // Handle approval actions from AgentProvider
305
+ if (action.payload?.approvalId) {
306
+ const id = action.payload.approvalId as string;
307
+ const approved = action.payload.approved as boolean;
308
+ resolveApproval(id, approved);
309
+ pendingApprovalIds = pendingApprovalIds.filter(pid => pid !== id);
310
+ // Remove action buttons after resolution
311
+ msg.actions = undefined;
312
+ messages = [...messages];
313
+ return;
314
+ }
231
315
  if (onAction) {
232
316
  onAction(action, msg);
233
317
  }
@@ -252,7 +336,7 @@
252
336
 
253
337
  <svelte:window onkeydown={handleGlobalKeydown} />
254
338
 
255
- {#if provider}
339
+ {#if provider || agent}
256
340
  <!-- FAB Button -->
257
341
  {#if !open}
258
342
  <div transition:scale={{ duration: 200 }}>
@@ -1,16 +1,23 @@
1
1
  <script lang="ts">
2
- import { getResources } from '@svadmin/core';
2
+ import { getResources, getAgentProvider, getChatProvider } from '@svadmin/core';
3
3
  import { navigate } from '@svadmin/core/router';
4
4
  import { t } from '@svadmin/core/i18n';
5
5
  import { toggleTheme } from '@svadmin/core';
6
6
  import { Command } from 'cmdk-sv';
7
7
  import * as Dialog from './ui/dialog/index.js';
8
- import { Search, LayoutDashboard, Plus, Sun, FileText } from 'lucide-svelte';
8
+ import { Search, LayoutDashboard, Plus, Sun, FileText, Bot, Sparkles } from 'lucide-svelte';
9
9
 
10
- let { open = $bindable(false) } = $props<{ open?: boolean }>();
10
+ interface Props {
11
+ open?: boolean;
12
+ /** Callback fired when user selects "Ask AI" with a query string */
13
+ onAskAI?: (query: string) => void;
14
+ }
15
+
16
+ let { open = $bindable(false), onAskAI }: Props = $props();
11
17
  let searchValue = $state('');
12
18
 
13
19
  const resources = getResources();
20
+ const hasAI = $derived(!!(getAgentProvider() || getChatProvider()));
14
21
 
15
22
  function close() {
16
23
  open = false;
@@ -22,6 +29,14 @@
22
29
  close();
23
30
  }
24
31
 
32
+ function askAI() {
33
+ const query = searchValue.trim();
34
+ if (onAskAI && query) {
35
+ onAskAI(query);
36
+ }
37
+ close();
38
+ }
39
+
25
40
  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';
26
41
  </script>
27
42
 
@@ -32,16 +47,42 @@
32
47
  <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
33
48
  <Command.Input
34
49
  bind:value={searchValue}
35
- placeholder={t('common.search')}
50
+ placeholder={hasAI ? (t('commandPalette.searchOrAsk') || 'Search or ask AI...') : t('common.search')}
36
51
  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"
37
52
  />
38
53
  <kbd class="ml-2 text-[10px] font-mono font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border">ESC</kbd>
39
54
  </div>
40
55
  <Command.List class="max-h-[320px] overflow-y-auto overflow-x-hidden p-1">
41
56
  <Command.Empty class="py-6 text-center text-sm text-muted-foreground">
42
- {t('common.noData')}
57
+ {#if hasAI && searchValue.trim()}
58
+ <button
59
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors cursor-pointer text-sm font-medium"
60
+ onclick={askAI}
61
+ >
62
+ <Sparkles class="h-3.5 w-3.5" />
63
+ {t('commandPalette.askAI') || 'Ask AI'}: "{searchValue.trim()}"
64
+ </button>
65
+ {:else}
66
+ {t('common.noData')}
67
+ {/if}
43
68
  </Command.Empty>
44
69
 
70
+ <!-- AI -->
71
+ {#if hasAI && searchValue.trim()}
72
+ <Command.Group heading="AI">
73
+ <Command.Item
74
+ value="ask-ai-{searchValue}"
75
+ onSelect={askAI}
76
+ class={itemClass}
77
+ >
78
+ <Sparkles class="h-4 w-4 text-primary" />
79
+ <span class="text-primary font-medium">{t('commandPalette.askAI') || 'Ask AI'}</span>
80
+ <span class="text-muted-foreground truncate ml-1">"{searchValue.trim()}"</span>
81
+ </Command.Item>
82
+ </Command.Group>
83
+ <Command.Separator class="mx-1 my-1 h-px bg-border" />
84
+ {/if}
85
+
45
86
  <!-- Navigation -->
46
87
  <Command.Group heading={t('common.home')}>
47
88
  <Command.Item
@@ -91,3 +132,4 @@
91
132
  </Command.Root>
92
133
  </Dialog.DialogContent>
93
134
  </Dialog.Dialog>
135
+
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export interface GridModule {
5
+ id: string;
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ let {
10
+ modules = $bindable<GridModule[]>([]),
11
+ onOrderSave,
12
+ renderItem,
13
+ class: className = '',
14
+ columns = 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
15
+ gap = 'gap-4',
16
+ }: {
17
+ modules?: GridModule[];
18
+ onOrderSave?: (orderedIds: string[]) => void;
19
+ renderItem: Snippet<[{ module: GridModule; index: number; dragging: boolean }]>;
20
+ class?: string;
21
+ columns?: string;
22
+ gap?: string;
23
+ } = $props();
24
+
25
+ let draggingId = $state<string | null>(null);
26
+ let dragOverId = $state<string | null>(null);
27
+
28
+ function handleDragStart(e: DragEvent, id: string) {
29
+ draggingId = id;
30
+ if (e.dataTransfer) {
31
+ e.dataTransfer.effectAllowed = 'move';
32
+ e.dataTransfer.setData('text/plain', id);
33
+ }
34
+ }
35
+
36
+ function handleDragOver(e: DragEvent, id: string) {
37
+ e.preventDefault();
38
+ if (draggingId && draggingId !== id) {
39
+ dragOverId = id;
40
+ }
41
+ }
42
+
43
+ function handleDrop(e: DragEvent, targetId: string) {
44
+ e.preventDefault();
45
+ if (!draggingId || draggingId === targetId) {
46
+ dragOverId = null;
47
+ return;
48
+ }
49
+
50
+ const sourceIdx = modules.findIndex(m => m.id === draggingId);
51
+ const targetIdx = modules.findIndex(m => m.id === targetId);
52
+
53
+ if (sourceIdx === -1 || targetIdx === -1) {
54
+ dragOverId = null;
55
+ return;
56
+ }
57
+
58
+ const newModules = [...modules];
59
+ const [dragged] = newModules.splice(sourceIdx, 1);
60
+ newModules.splice(targetIdx, 0, dragged);
61
+
62
+ modules = newModules;
63
+ dragOverId = null;
64
+ onOrderSave?.(modules.map(m => m.id));
65
+ }
66
+
67
+ function handleDragEnd() {
68
+ draggingId = null;
69
+ dragOverId = null;
70
+ }
71
+ </script>
72
+
73
+ <div class="grid {columns} {gap} {className}">
74
+ {#each modules as mod, i (mod.id)}
75
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
76
+ <div
77
+ draggable="true"
78
+ ondragstart={(e) => handleDragStart(e, mod.id)}
79
+ ondragover={(e) => handleDragOver(e, mod.id)}
80
+ ondrop={(e) => handleDrop(e, mod.id)}
81
+ ondragend={handleDragEnd}
82
+ ondragleave={() => { if (dragOverId === mod.id) dragOverId = null; }}
83
+ class="transition-all duration-200
84
+ {draggingId === mod.id ? 'opacity-40 scale-[0.97] cursor-grabbing' : 'cursor-grab'}
85
+ {dragOverId === mod.id ? 'ring-2 ring-primary/40 ring-offset-2 ring-offset-background rounded-lg scale-[1.02]' : ''}"
86
+ >
87
+ {@render renderItem({ module: mod, index: i, dragging: draggingId === mod.id })}
88
+ </div>
89
+ {/each}
90
+ </div>