@svadmin/ui 0.19.0 → 0.20.1

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.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -30,7 +30,8 @@
30
30
  "marked": "^15.0.0",
31
31
  "marked-highlight": "^2.2.0",
32
32
  "highlight.js": "^11.0.0",
33
- "isomorphic-dompurify": "^2.0.0"
33
+ "isomorphic-dompurify": "^2.0.0",
34
+ "@svadmin/editor": "^0.1.0"
34
35
  },
35
36
  "peerDependenciesMeta": {
36
37
  "marked": {
@@ -44,6 +45,9 @@
44
45
  },
45
46
  "isomorphic-dompurify": {
46
47
  "optional": true
48
+ },
49
+ "@svadmin/editor": {
50
+ "optional": true
47
51
  }
48
52
  },
49
53
  "dependencies": {
@@ -103,7 +103,7 @@
103
103
  </div>
104
104
 
105
105
  <!-- Table -->
106
- <div class="flex-1 border rounded-lg bg-card overflow-hidden">
106
+ <div class="flex-1 shadow-sm ring-1 ring-border/10 rounded-lg bg-card overflow-hidden">
107
107
  {#if loading}
108
108
  <div class="flex items-center justify-center h-48 text-muted-foreground">
109
109
  <Loader2 class="h-5 w-5 animate-spin mr-2" />
@@ -124,7 +124,7 @@
124
124
  </Table.Header>
125
125
  <Table.Body>
126
126
  {#each filteredLogs as log}
127
- <Table.Row class="hover:bg-muted/50 transition-colors">
127
+ <Table.Row class="border-0 even:bg-muted/20 hover:bg-muted/40 transition-colors">
128
128
  <Table.Cell class="text-xs text-muted-foreground font-mono">
129
129
  {formatDate(log.createdAt)}
130
130
  </Table.Cell>
@@ -188,7 +188,7 @@
188
188
  <!-- svelte-ignore a11y_click_events_have_key_events -->
189
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
190
  <!-- Drawer Header -->
191
- <div class="p-6 border-b border-border flex items-center justify-between bg-muted/30">
191
+ <div class="p-6 shadow-[0_1px_0_rgba(0,0,0,0.05)] dark:shadow-[0_1px_0_rgba(255,255,255,0.05)] flex items-center justify-between bg-muted/30">
192
192
  <div>
193
193
  <h2 class="text-lg font-bold text-foreground flex items-center gap-2">
194
194
  <FileSearch class="h-5 w-5 text-primary" />
@@ -202,7 +202,7 @@
202
202
  </div>
203
203
 
204
204
  <!-- Meta Tags -->
205
- <div class="px-6 py-3 border-b border-border flex items-center gap-2 flex-wrap">
205
+ <div class="px-6 py-3 shadow-[0_1px_0_rgba(0,0,0,0.05)] dark:shadow-[0_1px_0_rgba(255,255,255,0.05)] flex items-center gap-2 flex-wrap">
206
206
  <Badge variant="outline">{drawerLog.action}</Badge>
207
207
  {#if drawerLog.resource}
208
208
  <Badge variant="secondary">{drawerLog.resource}</Badge>
@@ -218,7 +218,7 @@
218
218
  </div>
219
219
 
220
220
  <!-- Footer -->
221
- <div class="p-4 border-t border-border bg-muted/20 text-right text-[11px] text-muted-foreground font-medium">
221
+ <div class="p-4 shadow-[0_-1px_0_rgba(0,0,0,0.05)] dark:shadow-[0_-1px_0_rgba(255,255,255,0.05)] bg-muted/20 text-right text-[11px] text-muted-foreground font-medium">
222
222
  System audit snapshot · Read-only
223
223
  </div>
224
224
  </div>
@@ -139,7 +139,7 @@
139
139
 
140
140
  {#if form.loading}
141
141
  <div class="max-w-3xl space-y-6">
142
- <div class="rounded-lg border p-6 space-y-5">
142
+ <div class="rounded-lg shadow-sm ring-1 ring-border/10 p-6 space-y-5">
143
143
  {#each Array(4) as _}
144
144
  <div class="space-y-2">
145
145
  <Skeleton class="h-4 w-24" />
@@ -159,7 +159,7 @@
159
159
 
160
160
  {#if hasGroups}
161
161
  {#each groups as group}
162
- <Card.Root>
162
+ <Card.Root class="border-border/40 shadow-sm">
163
163
  {#if group.name}
164
164
  <Card.Header>
165
165
  <Card.Title class="text-lg">{group.name}</Card.Title>
@@ -186,7 +186,7 @@
186
186
  </Card.Root>
187
187
  {/each}
188
188
  {:else}
189
- <Card.Root>
189
+ <Card.Root class="border-border/40 shadow-sm">
190
190
  <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
191
191
  {#each formFields as field (field.key)}
192
192
  <div class:border-destructive={!!form.errors[field.key]}>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
+ import { cn } from '../utils.js';
3
4
  import {
4
5
  createTable,
5
6
  getCoreRowModel,
@@ -36,6 +37,7 @@
36
37
  import ConfirmDialog from './ConfirmDialog.svelte';
37
38
  import TooltipButton from './TooltipButton.svelte';
38
39
  import InlineEdit from './InlineEdit.svelte';
40
+ import DraggableHeader from './DraggableHeader.svelte';
39
41
  import type { Snippet } from 'svelte';
40
42
 
41
43
  // ─── Props with Snippet composability ─────────────────────────
@@ -445,7 +447,7 @@
445
447
  {/if}
446
448
 
447
449
  <!-- Table (TanStack-powered) -->
448
- <div class="rounded-lg bg-card shadow-sm overflow-hidden" role="region" aria-label="{resource.label} {t('common.list')}">
450
+ <div class="rounded-lg bg-card shadow-sm ring-1 ring-border/10 overflow-hidden" role="region" aria-label="{resource.label} {t('common.list')}">
449
451
  {#if query.isLoading}
450
452
  <div class="p-4 space-y-3">
451
453
  <div class="flex gap-4 mb-2">
@@ -523,7 +525,7 @@
523
525
  <ContextMenu.Root>
524
526
  <ContextMenu.Trigger>
525
527
  {#snippet child({ props })}
526
- <Table.Row {...props} class="transition-colors {row.getIsSelected() ? 'bg-accent' : ''}">
528
+ <Table.Row {...props} class="transition-colors border-0 even:bg-muted/20 {row.getIsSelected() ? 'bg-accent/50' : 'hover:bg-muted/40'}">
527
529
  {#each row.getVisibleCells() as cell}
528
530
  <Table.Cell>
529
531
  {#if cell.column.id === '_select'}
@@ -652,7 +654,7 @@
652
654
  {@const record = row.original}
653
655
  {@const id = record[primaryKey] as string | number}
654
656
  <div
655
- class="rounded-xl border bg-card p-4 shadow-sm transition-colors {row.getIsSelected() ? 'ring-2 ring-primary bg-accent/30' : ''}"
657
+ class="rounded-xl shadow-sm ring-1 ring-border/10 bg-card p-4 transition-colors {row.getIsSelected() ? 'ring-2 ring-primary bg-accent/30' : ''}"
656
658
  >
657
659
  <!-- Card header: ID + select + actions -->
658
660
  <div class="flex items-center justify-between mb-3">
@@ -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
+
@@ -1,10 +1,12 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
-
1
+ <script context="module">
4
2
  export interface GridModule {
5
3
  id: string;
6
4
  [key: string]: unknown;
7
5
  }
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ import type { Snippet } from 'svelte';
8
10
 
9
11
  let {
10
12
  modules = $bindable<GridModule[]>([]),
@@ -163,13 +163,43 @@
163
163
  required={field.required}
164
164
  />
165
165
 
166
- {:else if field.type === 'textarea' || field.type === 'richtext'}
166
+ {:else if field.type === 'richtext'}
167
+ {#await import('@svadmin/editor')}
168
+ <Textarea
169
+ id={field.key}
170
+ value={strVal}
171
+ oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
172
+ required={field.required}
173
+ rows={10}
174
+ placeholder={t('field.enterValue', { label: field.label })}
175
+ class="resize-y"
176
+ />
177
+ {:then editorMod}
178
+ <editorMod.Editor
179
+ value={strVal}
180
+ placeholder={t('field.enterValue', { label: field.label })}
181
+ preset="full"
182
+ onchange={(html) => onchange(html)}
183
+ />
184
+ {:catch}
185
+ <Textarea
186
+ id={field.key}
187
+ value={strVal}
188
+ oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
189
+ required={field.required}
190
+ rows={10}
191
+ placeholder={t('field.enterValue', { label: field.label })}
192
+ class="resize-y"
193
+ />
194
+ {/await}
195
+
196
+ {:else if field.type === 'textarea'}
167
197
  <Textarea
168
198
  id={field.key}
169
199
  value={strVal}
170
200
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
171
201
  required={field.required}
172
- rows={field.type === 'richtext' ? 10 : 4}
202
+ rows={4}
173
203
  placeholder={t('field.enterValue', { label: field.label })}
174
204
  class="resize-y"
175
205
  />
@@ -1,4 +1,4 @@
1
- <script module context="module">
1
+ <script context="module">
2
2
  export type RoleInfo = { code: string; name: string; [key: string]: any };
3
3
  export type ResourceInfo = { code: string; name: string; section?: string; [key: string]: any };
4
4
  export type ActionInfo = { code: string; name: string; [key: string]: any };
@@ -128,12 +128,12 @@
128
128
 
129
129
  <!-- Table Area -->
130
130
  <div class="flex-1 overflow-auto p-4">
131
- <table class="min-w-full divide-y divide-border border border-border rounded-lg overflow-hidden">
131
+ <table class="min-w-full rounded-lg overflow-hidden shadow-sm ring-1 ring-border/10">
132
132
  <thead class="bg-muted/50">
133
133
  <tr>
134
- <th scope="col" class="py-3 pr-3 pl-4 text-left text-sm font-semibold text-foreground border-r border-border w-48">模块域与资源点</th>
134
+ <th scope="col" class="py-3 pr-3 pl-4 text-left text-sm font-semibold text-foreground w-48">模块域与资源点</th>
135
135
  {#each actions as action}
136
- <th scope="col" class="px-2 py-3 text-center text-sm font-semibold text-foreground border-r border-border">
136
+ <th scope="col" class="px-2 py-3 text-center text-sm font-semibold text-foreground">
137
137
  <div class="flex flex-col items-center">
138
138
  <span>{action.name}</span>
139
139
  <span class="text-[10px] text-muted-foreground font-mono">{action.code}</span>
@@ -142,7 +142,7 @@
142
142
  {/each}
143
143
  </tr>
144
144
  </thead>
145
- <tbody class="divide-y divide-border bg-card">
145
+ <tbody class="bg-card">
146
146
  {#if resources.length === 0}
147
147
  <tr>
148
148
  <td colspan={actions.length + 1} class="py-10 text-center text-muted-foreground">暂无可用资源</td>
@@ -156,8 +156,8 @@
156
156
  </tr>
157
157
  {/if}
158
158
 
159
- <tr class="hover:bg-muted/50 transition-colors">
160
- <td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm border-r border-border">
159
+ <tr class="even:bg-muted/30 hover:bg-muted/50 transition-colors">
160
+ <td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm">
161
161
  <div class="font-medium text-foreground">{resource.name}</div>
162
162
  <div class="text-xs text-muted-foreground font-mono">{resource.code}</div>
163
163
  </td>
@@ -165,7 +165,7 @@
165
165
  <!-- Action Checkboxes -->
166
166
  {#each actions as action}
167
167
  {@const granted = selectedRole ? isGranted(selectedRole, resource.code, action.code) : false}
168
- <td class="whitespace-nowrap px-3 py-3 text-center border-r border-border">
168
+ <td class="whitespace-nowrap px-3 py-3 text-center">
169
169
  <div class="flex items-center justify-center">
170
170
  <button
171
171
  disabled={loading || !selectedRole}
@@ -47,7 +47,7 @@
47
47
 
48
48
  <div class="flex flex-col lg:flex-row gap-0 min-h-full">
49
49
  <!-- Left sidebar navigation -->
50
- <nav class="w-full lg:w-56 shrink-0 border-b lg:border-b-0 lg:border-r border-border bg-muted/30">
50
+ <nav class="w-full lg:w-56 shrink-0 bg-muted/10 shadow-[inset_0_-1px_0_rgba(0,0,0,0.05)] lg:shadow-[inset_-1px_0_0_rgba(0,0,0,0.05)] dark:shadow-[inset_-1px_0_0_rgba(255,255,255,0.05)]">
51
51
  <!-- Mobile: horizontal scroll tabs -->
52
52
  <div class="flex lg:hidden overflow-x-auto px-4 py-2 gap-1">
53
53
  {#each sections as section}
@@ -36,10 +36,10 @@
36
36
  </PageHeader>
37
37
 
38
38
  {#if query.query.isLoading}
39
- <Card.Root>
40
- <Card.Content class="divide-y divide-border p-0">
41
- {#each showFields.slice(0, 6) as _}
42
- <div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
39
+ <Card.Root class="overflow-hidden border-border/40 shadow-sm">
40
+ <Card.Content class="p-0">
41
+ {#each showFields.slice(0, 6) as _, i}
42
+ <div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4 {i % 2 === 1 ? 'bg-muted/20' : ''}">
43
43
  <Skeleton class="h-4 w-1/2 sm:w-1/4" />
44
44
  <Skeleton class="h-4 w-3/4 sm:w-2/5 mt-1 sm:mt-0 sm:ml-auto" />
45
45
  </div>
@@ -47,12 +47,12 @@
47
47
  </Card.Content>
48
48
  </Card.Root>
49
49
  {:else if query.query.data?.data}
50
- <Card.Root>
51
- <Card.Content class="divide-y divide-border p-0">
52
- {#each showFields as field}
50
+ <Card.Root class="overflow-hidden border-border/40 shadow-sm">
51
+ <Card.Content class="p-0">
52
+ {#each showFields as field, i}
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 flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
55
+ <div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4 {i % 2 === 1 ? 'bg-muted/20' : ''}">
56
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
57
  <div class="sm:w-2/3 text-sm text-foreground">
58
58
  {#if DisplayComponent && value != null}
@@ -1,9 +1,4 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
- import { Loader2, CheckCircle2, XCircle, Clock, Download, X, ListTodo } from 'lucide-svelte';
4
- import { Button } from './ui/button/index.js';
5
- import { Badge } from './ui/badge/index.js';
6
-
1
+ <script context="module">
7
2
  export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed';
8
3
 
9
4
  export interface BackgroundTask {
@@ -16,6 +11,13 @@
16
11
  downloadUrl?: string;
17
12
  [key: string]: unknown;
18
13
  }
14
+ </script>
15
+
16
+ <script lang="ts">
17
+ import type { Snippet } from 'svelte';
18
+ import { Loader2, CheckCircle2, XCircle, Clock, Download, X, ListTodo } from 'lucide-svelte';
19
+ import { Button } from './ui/button/index.js';
20
+ import { Badge } from './ui/badge/index.js';
19
21
 
20
22
  let {
21
23
  open = $bindable(false),
@@ -1,15 +1,17 @@
1
- <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
- import { ChevronsUpDown, Building2, Check } from 'lucide-svelte';
4
- import { Button } from './ui/button/index.js';
5
- import * as DropdownMenu from './ui/dropdown-menu/index.js';
6
-
1
+ <script context="module">
7
2
  export interface Tenant {
8
3
  id: string;
9
4
  name: string;
10
5
  logo?: string;
11
6
  [key: string]: unknown;
12
7
  }
8
+ </script>
9
+
10
+ <script lang="ts">
11
+ import type { Snippet } from 'svelte';
12
+ import { ChevronsUpDown, Building2, Check } from 'lucide-svelte';
13
+ import { Button } from './ui/button/index.js';
14
+ import * as DropdownMenu from './ui/dropdown-menu/index.js';
13
15
 
14
16
  let {
15
17
  tenants = [],
@@ -59,14 +61,14 @@
59
61
  </button>
60
62
  {/snippet}
61
63
  </DropdownMenu.Trigger>
62
- <DropdownMenu.Content class="w-56" align="start" side="right" sideOffset={8}>
64
+ <DropdownMenu.Content class="w-56">
63
65
  {#if headerSnippet}
64
66
  <div class="px-2 py-1.5">
65
67
  {@render headerSnippet()}
66
68
  </div>
67
69
  <DropdownMenu.Separator />
68
70
  {/if}
69
- <DropdownMenu.Label class="text-xs text-muted-foreground">Workspaces</DropdownMenu.Label>
71
+ <div class="px-2 py-1.5 text-xs text-muted-foreground">Workspaces</div>
70
72
  {#each tenants as tenant}
71
73
  <DropdownMenu.Item
72
74
  class="flex items-center gap-3 cursor-pointer"
package/src/index.ts CHANGED
@@ -93,6 +93,9 @@ export { default as RelationField } from './components/fields/RelationField.svel
93
93
  export { default as JsonField } from './components/fields/JsonField.svelte';
94
94
  export { default as RichTextField } from './components/fields/RichTextField.svelte';
95
95
 
96
+ // Rich text editor (requires @svadmin/editor)
97
+ export { Editor as RichTextEditor } from '@svadmin/editor';
98
+
96
99
  // CRUD Buttons
97
100
  export {
98
101
  CreateButton, EditButton, DeleteButton, ShowButton, ListButton,