@svadmin/ui 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/AuditLogDrawer.svelte +93 -0
- package/src/components/AutoTable.svelte +83 -0
- package/src/components/ChatDialog.svelte +346 -0
- package/src/components/CreatePage.svelte +2 -6
- package/src/components/EditPage.svelte +9 -29
- package/src/components/ForgotPasswordPage.svelte +12 -12
- package/src/components/Layout.svelte +2 -0
- package/src/components/LoginPage.svelte +12 -12
- package/src/components/ProfilePage.svelte +7 -3
- package/src/components/RegisterPage.svelte +11 -11
- package/src/components/ShowPage.svelte +10 -8
- package/src/components/Sidebar.svelte +5 -5
- package/src/components/buttons/CloneButton.svelte +1 -1
- package/src/components/buttons/CreateButton.svelte +1 -1
- package/src/components/buttons/DeleteButton.svelte +1 -1
- package/src/components/buttons/EditButton.svelte +1 -1
- package/src/components/buttons/ExportButton.svelte +18 -12
- package/src/components/buttons/ImportButton.svelte +18 -12
- package/src/components/buttons/ShowButton.svelte +18 -11
- package/src/components/ui/select/select.svelte +3 -1
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getAuditLogProvider, t } from '@svadmin/core';
|
|
3
|
+
import type { AuditEntry } from '@svadmin/core';
|
|
4
|
+
import * as Sheet from './ui/sheet/index.js';
|
|
5
|
+
import { History, Loader2 } from 'lucide-svelte';
|
|
6
|
+
|
|
7
|
+
let { open = $bindable(false), resource, recordId } = $props<{
|
|
8
|
+
open: boolean;
|
|
9
|
+
resource: string;
|
|
10
|
+
recordId?: string | number;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
let logs = $state<AuditEntry[]>([]);
|
|
14
|
+
let isLoading = $state(false);
|
|
15
|
+
|
|
16
|
+
$effect(() => {
|
|
17
|
+
if (open) {
|
|
18
|
+
const provider = getAuditLogProvider();
|
|
19
|
+
if (provider) {
|
|
20
|
+
isLoading = true;
|
|
21
|
+
provider.get({ resource, meta: recordId ? { recordId } : undefined })
|
|
22
|
+
.then((data) => {
|
|
23
|
+
logs = data;
|
|
24
|
+
isLoading = false;
|
|
25
|
+
})
|
|
26
|
+
.catch((err) => {
|
|
27
|
+
console.error('[svadmin] Failed to fetch audit logs', err);
|
|
28
|
+
isLoading = false;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Action semantic color mapping
|
|
35
|
+
function getActionColor(action: string) {
|
|
36
|
+
switch (action) {
|
|
37
|
+
case 'create': return 'bg-green-500';
|
|
38
|
+
case 'update': return 'bg-blue-500';
|
|
39
|
+
case 'delete': return 'bg-red-500';
|
|
40
|
+
default: return 'bg-slate-400';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<Sheet.Root bind:open>
|
|
46
|
+
<Sheet.Content class="w-full sm:max-w-md overflow-y-auto">
|
|
47
|
+
<Sheet.Header>
|
|
48
|
+
<Sheet.Title class="flex items-center gap-2">
|
|
49
|
+
<History class="h-5 w-5" />
|
|
50
|
+
{t('common.history') || 'History'}
|
|
51
|
+
</Sheet.Title>
|
|
52
|
+
<Sheet.Description>
|
|
53
|
+
Audit log and revision history for this record.
|
|
54
|
+
</Sheet.Description>
|
|
55
|
+
</Sheet.Header>
|
|
56
|
+
|
|
57
|
+
<div class="mt-8 space-y-4">
|
|
58
|
+
{#if isLoading}
|
|
59
|
+
<div class="flex justify-center py-8 text-muted-foreground">
|
|
60
|
+
<Loader2 class="h-6 w-6 animate-spin" />
|
|
61
|
+
</div>
|
|
62
|
+
{:else if logs.length === 0}
|
|
63
|
+
<div class="text-center py-10">
|
|
64
|
+
<History class="h-8 w-8 mx-auto text-muted-foreground/30 mb-3" />
|
|
65
|
+
<p class="text-sm text-muted-foreground">
|
|
66
|
+
{t('common.noData') || 'No audit logs found'}
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
{:else}
|
|
70
|
+
<div class="relative border-l border-border ml-3 pl-6 space-y-8">
|
|
71
|
+
{#each logs as log}
|
|
72
|
+
<div class="relative">
|
|
73
|
+
<span class="absolute -left-7 top-1 flex h-2 w-2 items-center justify-center rounded-full ring-4 ring-background {getActionColor(log.action)}"></span>
|
|
74
|
+
<div class="flex flex-col gap-1.5">
|
|
75
|
+
<div class="flex items-center gap-2 justify-between">
|
|
76
|
+
<span class="text-sm font-semibold capitalize">{log.action}</span>
|
|
77
|
+
<span class="text-xs text-muted-foreground">
|
|
78
|
+
{new Date(log.timestamp).toLocaleString()}
|
|
79
|
+
</span>
|
|
80
|
+
</div>
|
|
81
|
+
{#if log.userId}
|
|
82
|
+
<span class="text-xs text-muted-foreground font-mono bg-muted inline-flex px-1.5 py-0.5 rounded w-fit">
|
|
83
|
+
User: {log.userId}
|
|
84
|
+
</span>
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
{/each}
|
|
89
|
+
</div>
|
|
90
|
+
{/if}
|
|
91
|
+
</div>
|
|
92
|
+
</Sheet.Content>
|
|
93
|
+
</Sheet.Root>
|
|
@@ -436,6 +436,8 @@
|
|
|
436
436
|
</div>
|
|
437
437
|
{:else}
|
|
438
438
|
<div in:fade={{ duration: 150 }}>
|
|
439
|
+
<!-- Desktop Table (hidden on mobile) -->
|
|
440
|
+
<div class="hidden md:block">
|
|
439
441
|
<Table.Root>
|
|
440
442
|
<Table.Header>
|
|
441
443
|
{#each tbl.getHeaderGroups() as headerGroup}
|
|
@@ -582,6 +584,87 @@
|
|
|
582
584
|
{/each}
|
|
583
585
|
</Table.Body>
|
|
584
586
|
</Table.Root>
|
|
587
|
+
</div>
|
|
588
|
+
|
|
589
|
+
<!-- Mobile Card View (visible only on small screens) -->
|
|
590
|
+
<div class="md:hidden space-y-3 p-2">
|
|
591
|
+
{#each tbl.getRowModel().rows as row}
|
|
592
|
+
{@const record = row.original}
|
|
593
|
+
{@const id = record[primaryKey] as string | number}
|
|
594
|
+
<div
|
|
595
|
+
class="rounded-xl border bg-card p-4 shadow-sm transition-colors {row.getIsSelected() ? 'ring-2 ring-primary bg-accent/30' : ''}"
|
|
596
|
+
>
|
|
597
|
+
<!-- Card header: ID + select + actions -->
|
|
598
|
+
<div class="flex items-center justify-between mb-3">
|
|
599
|
+
<div class="flex items-center gap-2">
|
|
600
|
+
{#if selectable && canDelete}
|
|
601
|
+
<Checkbox
|
|
602
|
+
checked={row.getIsSelected()}
|
|
603
|
+
onCheckedChange={() => row.toggleSelected()}
|
|
604
|
+
/>
|
|
605
|
+
{/if}
|
|
606
|
+
<span class="text-xs font-mono text-muted-foreground">#{id}</span>
|
|
607
|
+
</div>
|
|
608
|
+
<div class="flex items-center gap-1">
|
|
609
|
+
{#if rowActions}
|
|
610
|
+
{@render rowActions({ record, id })}
|
|
611
|
+
{:else}
|
|
612
|
+
{#if canEdit}
|
|
613
|
+
<TooltipButton tooltip={t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigate(`/${resourceName}/edit/${id}`)}>
|
|
614
|
+
<Pencil class="h-4 w-4" />
|
|
615
|
+
</TooltipButton>
|
|
616
|
+
{/if}
|
|
617
|
+
<TooltipButton tooltip={t('common.detail')} variant="ghost" size="icon-sm" onclick={() => navigate(`/${resourceName}/show/${id}`)}>
|
|
618
|
+
<Eye class="h-4 w-4" />
|
|
619
|
+
</TooltipButton>
|
|
620
|
+
{#if canDelete}
|
|
621
|
+
<TooltipButton tooltip={t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
|
|
622
|
+
<Trash2 class="h-4 w-4" />
|
|
623
|
+
</TooltipButton>
|
|
624
|
+
{/if}
|
|
625
|
+
{/if}
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
<!-- Card fields -->
|
|
629
|
+
<div class="space-y-2">
|
|
630
|
+
{#each visibleFields.slice(0, 6) as field}
|
|
631
|
+
{@const value = record[field.key]}
|
|
632
|
+
<div class="flex items-start justify-between gap-4">
|
|
633
|
+
<span class="text-xs font-medium text-muted-foreground shrink-0">{field.label}</span>
|
|
634
|
+
<span class="text-sm text-right truncate max-w-[60%]">
|
|
635
|
+
{#if cellRenderer}
|
|
636
|
+
{@render cellRenderer({ field, value, record })}
|
|
637
|
+
{:else if field.type === 'boolean'}
|
|
638
|
+
<span class="inline-block h-2 w-2 rounded-full {value ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
|
|
639
|
+
{:else if field.type === 'date' && value}
|
|
640
|
+
{new Date(value as string).toLocaleDateString()}
|
|
641
|
+
{:else if field.type === 'tags' && Array.isArray(value)}
|
|
642
|
+
<div class="flex flex-wrap gap-1 justify-end">
|
|
643
|
+
{#each (value as string[]).slice(0, 2) as tag}
|
|
644
|
+
<Badge variant="secondary" class="text-[10px]">{tag}</Badge>
|
|
645
|
+
{/each}
|
|
646
|
+
</div>
|
|
647
|
+
{:else if field.type === 'select' && field.options}
|
|
648
|
+
{@const opt = field.options.find(o => o.value === value)}
|
|
649
|
+
<Badge variant="outline" class="text-[10px]">{opt?.label ?? value ?? '—'}</Badge>
|
|
650
|
+
{:else}
|
|
651
|
+
{value ?? '—'}
|
|
652
|
+
{/if}
|
|
653
|
+
</span>
|
|
654
|
+
</div>
|
|
655
|
+
{/each}
|
|
656
|
+
</div>
|
|
657
|
+
</div>
|
|
658
|
+
{:else}
|
|
659
|
+
<div class="text-center py-10 text-muted-foreground">
|
|
660
|
+
{#if emptyState}
|
|
661
|
+
{@render emptyState()}
|
|
662
|
+
{:else}
|
|
663
|
+
{t('common.noData')}
|
|
664
|
+
{/if}
|
|
665
|
+
</div>
|
|
666
|
+
{/each}
|
|
667
|
+
</div>
|
|
585
668
|
</div>
|
|
586
669
|
{/if}
|
|
587
670
|
</div>
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getChatProvider } from '@svadmin/core';
|
|
3
|
+
import type { ChatMessage } from '@svadmin/core';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import { fly, fade, scale } from 'svelte/transition';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import TooltipButton from './TooltipButton.svelte';
|
|
8
|
+
import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2 } from 'lucide-svelte';
|
|
9
|
+
|
|
10
|
+
let open = $state(false);
|
|
11
|
+
let minimized = $state(false);
|
|
12
|
+
let inputValue = $state('');
|
|
13
|
+
let messages = $state<ChatMessage[]>([]);
|
|
14
|
+
let isStreaming = $state(false);
|
|
15
|
+
let messagesContainer: HTMLDivElement | undefined = $state();
|
|
16
|
+
let abortController: AbortController | null = null;
|
|
17
|
+
let messageIdCounter = $state(0);
|
|
18
|
+
|
|
19
|
+
const provider = $derived(getChatProvider());
|
|
20
|
+
|
|
21
|
+
function genId(): string {
|
|
22
|
+
messageIdCounter++;
|
|
23
|
+
return `msg-${Date.now()}-${messageIdCounter}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scrollToBottom() {
|
|
27
|
+
requestAnimationFrame(() => {
|
|
28
|
+
if (messagesContainer) {
|
|
29
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function sendMessage() {
|
|
35
|
+
if (!inputValue.trim() || isStreaming || !provider) return;
|
|
36
|
+
|
|
37
|
+
const userMsg: ChatMessage = {
|
|
38
|
+
id: genId(),
|
|
39
|
+
role: 'user',
|
|
40
|
+
content: inputValue.trim(),
|
|
41
|
+
timestamp: Date.now(),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
messages = [...messages, userMsg];
|
|
45
|
+
inputValue = '';
|
|
46
|
+
scrollToBottom();
|
|
47
|
+
|
|
48
|
+
// Create placeholder assistant message
|
|
49
|
+
const assistantMsg: ChatMessage = {
|
|
50
|
+
id: genId(),
|
|
51
|
+
role: 'assistant',
|
|
52
|
+
content: '',
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
};
|
|
55
|
+
messages = [...messages, assistantMsg];
|
|
56
|
+
isStreaming = true;
|
|
57
|
+
scrollToBottom();
|
|
58
|
+
|
|
59
|
+
abortController = new AbortController();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = provider.sendMessage(
|
|
63
|
+
messages.filter((m) => m.content), // exclude empty placeholder
|
|
64
|
+
{ signal: abortController.signal },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
|
|
68
|
+
// Streaming response
|
|
69
|
+
for await (const chunk of result as AsyncGenerator<string>) {
|
|
70
|
+
assistantMsg.content += chunk;
|
|
71
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
72
|
+
scrollToBottom();
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
// Non-streaming response
|
|
76
|
+
const text = await (result as Promise<string>);
|
|
77
|
+
assistantMsg.content = text;
|
|
78
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
79
|
+
scrollToBottom();
|
|
80
|
+
}
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
83
|
+
assistantMsg.content = t('chat.error') || 'Sorry, something went wrong. Please try again.';
|
|
84
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
85
|
+
} finally {
|
|
86
|
+
isStreaming = false;
|
|
87
|
+
abortController = null;
|
|
88
|
+
scrollToBottom();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stopStreaming() {
|
|
93
|
+
if (abortController) {
|
|
94
|
+
abortController.abort();
|
|
95
|
+
isStreaming = false;
|
|
96
|
+
abortController = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function clearChat() {
|
|
101
|
+
messages = [];
|
|
102
|
+
isStreaming = false;
|
|
103
|
+
if (abortController) {
|
|
104
|
+
abortController.abort();
|
|
105
|
+
abortController = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
110
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
sendMessage();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleGlobalKeydown(e: KeyboardEvent) {
|
|
117
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
if (open && !minimized) {
|
|
120
|
+
open = false;
|
|
121
|
+
} else {
|
|
122
|
+
open = true;
|
|
123
|
+
minimized = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Simple markdown→html: bold, italic, code blocks, inline code, line breaks */
|
|
129
|
+
function renderMarkdown(text: string): string {
|
|
130
|
+
return text
|
|
131
|
+
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="chat-code-block"><code>$2</code></pre>')
|
|
132
|
+
.replace(/`([^`]+)`/g, '<code class="chat-inline-code">$1</code>')
|
|
133
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
134
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
135
|
+
.replace(/\n/g, '<br>');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const suggestions = $derived([
|
|
139
|
+
t('chat.suggestion1') || 'How do I create a new resource?',
|
|
140
|
+
t('chat.suggestion2') || 'Explain the data model',
|
|
141
|
+
t('chat.suggestion3') || 'Help me write a filter query',
|
|
142
|
+
]);
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<svelte:window onkeydown={handleGlobalKeydown} />
|
|
146
|
+
|
|
147
|
+
{#if provider}
|
|
148
|
+
<!-- FAB Button -->
|
|
149
|
+
{#if !open}
|
|
150
|
+
<div transition:scale={{ duration: 200 }}>
|
|
151
|
+
<TooltipButton
|
|
152
|
+
tooltip={t('chat.title') || 'AI Assistant'}
|
|
153
|
+
variant="default"
|
|
154
|
+
size="icon"
|
|
155
|
+
class="fixed bottom-6 right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
|
|
156
|
+
onclick={() => { open = true; minimized = false; }}
|
|
157
|
+
>
|
|
158
|
+
<MessageCircle class="h-5 w-5" />
|
|
159
|
+
{#if messages.length > 0}
|
|
160
|
+
<span class="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
|
161
|
+
{messages.filter(m => m.role === 'assistant').length}
|
|
162
|
+
</span>
|
|
163
|
+
{/if}
|
|
164
|
+
</TooltipButton>
|
|
165
|
+
</div>
|
|
166
|
+
{/if}
|
|
167
|
+
|
|
168
|
+
<!-- Chat Panel -->
|
|
169
|
+
{#if open}
|
|
170
|
+
<div
|
|
171
|
+
class="fixed bottom-6 right-6 z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden"
|
|
172
|
+
class:w-[400px]={!minimized}
|
|
173
|
+
class:h-[560px]={!minimized}
|
|
174
|
+
class:w-[280px]={minimized}
|
|
175
|
+
class:h-auto={minimized}
|
|
176
|
+
transition:fly={{ y: 300, duration: 250 }}
|
|
177
|
+
>
|
|
178
|
+
<!-- Header -->
|
|
179
|
+
<div class="flex items-center justify-between px-4 py-3 bg-primary text-primary-foreground shrink-0">
|
|
180
|
+
<div class="flex items-center gap-2.5">
|
|
181
|
+
<div class="flex items-center justify-center h-7 w-7 rounded-full bg-primary-foreground/20">
|
|
182
|
+
<Bot class="h-4 w-4" />
|
|
183
|
+
</div>
|
|
184
|
+
<div>
|
|
185
|
+
<h3 class="text-sm font-semibold leading-none">
|
|
186
|
+
{t('chat.title') || 'AI Assistant'}
|
|
187
|
+
</h3>
|
|
188
|
+
{#if isStreaming}
|
|
189
|
+
<p class="text-[10px] opacity-80 mt-0.5">{t('chat.typing') || 'Typing...'}</p>
|
|
190
|
+
{/if}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="flex items-center gap-0.5">
|
|
194
|
+
{#if messages.length > 0}
|
|
195
|
+
<TooltipButton
|
|
196
|
+
tooltip={t('chat.clear') || 'Clear'}
|
|
197
|
+
variant="ghost"
|
|
198
|
+
size="icon"
|
|
199
|
+
class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
|
|
200
|
+
onclick={clearChat}
|
|
201
|
+
>
|
|
202
|
+
<Trash2 class="h-3.5 w-3.5" />
|
|
203
|
+
</TooltipButton>
|
|
204
|
+
{/if}
|
|
205
|
+
<TooltipButton
|
|
206
|
+
tooltip={minimized ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Minimize')}
|
|
207
|
+
variant="ghost"
|
|
208
|
+
size="icon"
|
|
209
|
+
class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
|
|
210
|
+
onclick={() => minimized = !minimized}
|
|
211
|
+
>
|
|
212
|
+
<Minus class="h-3.5 w-3.5" />
|
|
213
|
+
</TooltipButton>
|
|
214
|
+
<TooltipButton
|
|
215
|
+
tooltip={t('common.close') || 'Close'}
|
|
216
|
+
variant="ghost"
|
|
217
|
+
size="icon"
|
|
218
|
+
class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
|
|
219
|
+
onclick={() => open = false}
|
|
220
|
+
>
|
|
221
|
+
<X class="h-3.5 w-3.5" />
|
|
222
|
+
</TooltipButton>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{#if !minimized}
|
|
227
|
+
<!-- Messages -->
|
|
228
|
+
<div
|
|
229
|
+
bind:this={messagesContainer}
|
|
230
|
+
class="flex-1 overflow-y-auto p-4 space-y-3 scroll-smooth"
|
|
231
|
+
>
|
|
232
|
+
{#if messages.length === 0}
|
|
233
|
+
<!-- Welcome state -->
|
|
234
|
+
<div class="flex flex-col items-center justify-center h-full text-center px-4" in:fade={{ duration: 200 }}>
|
|
235
|
+
<div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
|
236
|
+
<Bot class="h-6 w-6 text-primary" />
|
|
237
|
+
</div>
|
|
238
|
+
<h4 class="text-sm font-semibold text-foreground mb-1">
|
|
239
|
+
{t('chat.welcome') || 'How can I help?'}
|
|
240
|
+
</h4>
|
|
241
|
+
<p class="text-xs text-muted-foreground mb-4">
|
|
242
|
+
{t('chat.welcomeDesc') || 'Ask me anything about your admin panel.'}
|
|
243
|
+
</p>
|
|
244
|
+
<div class="flex flex-col gap-2 w-full max-w-[240px]">
|
|
245
|
+
{#each suggestions as suggestion}
|
|
246
|
+
<button
|
|
247
|
+
class="text-left text-xs px-3 py-2 rounded-lg border border-border hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
|
|
248
|
+
onclick={() => { inputValue = suggestion; }}
|
|
249
|
+
>
|
|
250
|
+
{suggestion}
|
|
251
|
+
</button>
|
|
252
|
+
{/each}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
{:else}
|
|
256
|
+
{#each messages as msg (msg.id)}
|
|
257
|
+
<div
|
|
258
|
+
class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}"
|
|
259
|
+
in:fly={{ y: 10, duration: 150 }}
|
|
260
|
+
>
|
|
261
|
+
<div
|
|
262
|
+
class="max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed {msg.role === 'user'
|
|
263
|
+
? 'bg-primary text-primary-foreground rounded-br-md'
|
|
264
|
+
: 'bg-muted text-foreground rounded-bl-md'}"
|
|
265
|
+
>
|
|
266
|
+
{#if msg.role === 'assistant'}
|
|
267
|
+
{#if msg.content}
|
|
268
|
+
<div class="chat-markdown">{@html renderMarkdown(msg.content)}</div>
|
|
269
|
+
{:else}
|
|
270
|
+
<div class="flex items-center gap-1.5 text-muted-foreground">
|
|
271
|
+
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
|
272
|
+
<span class="text-xs">{t('chat.thinking') || 'Thinking...'}</span>
|
|
273
|
+
</div>
|
|
274
|
+
{/if}
|
|
275
|
+
{:else}
|
|
276
|
+
{msg.content}
|
|
277
|
+
{/if}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
{/each}
|
|
281
|
+
{/if}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<!-- Input area -->
|
|
285
|
+
<div class="shrink-0 border-t bg-card p-3">
|
|
286
|
+
<div class="flex items-end gap-2">
|
|
287
|
+
<textarea
|
|
288
|
+
bind:value={inputValue}
|
|
289
|
+
onkeydown={handleKeydown}
|
|
290
|
+
placeholder={t('chat.placeholder') || 'Type a message...'}
|
|
291
|
+
rows={1}
|
|
292
|
+
disabled={isStreaming}
|
|
293
|
+
class="flex-1 resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:opacity-50 max-h-[100px] min-h-[40px]"
|
|
294
|
+
></textarea>
|
|
295
|
+
{#if isStreaming}
|
|
296
|
+
<Button
|
|
297
|
+
variant="destructive"
|
|
298
|
+
size="icon"
|
|
299
|
+
class="h-10 w-10 rounded-xl shrink-0"
|
|
300
|
+
onclick={stopStreaming}
|
|
301
|
+
>
|
|
302
|
+
<X class="h-4 w-4" />
|
|
303
|
+
</Button>
|
|
304
|
+
{:else}
|
|
305
|
+
<Button
|
|
306
|
+
variant="default"
|
|
307
|
+
size="icon"
|
|
308
|
+
class="h-10 w-10 rounded-xl shrink-0"
|
|
309
|
+
onclick={sendMessage}
|
|
310
|
+
disabled={!inputValue.trim()}
|
|
311
|
+
>
|
|
312
|
+
<Send class="h-4 w-4" />
|
|
313
|
+
</Button>
|
|
314
|
+
{/if}
|
|
315
|
+
</div>
|
|
316
|
+
<p class="text-[10px] text-muted-foreground mt-1.5 text-center">
|
|
317
|
+
<kbd class="px-1 py-0.5 rounded border border-border bg-muted text-[9px] font-mono">Ctrl+Shift+L</kbd>
|
|
318
|
+
{t('chat.shortcutHint') || 'to toggle'}
|
|
319
|
+
</p>
|
|
320
|
+
</div>
|
|
321
|
+
{/if}
|
|
322
|
+
</div>
|
|
323
|
+
{/if}
|
|
324
|
+
{/if}
|
|
325
|
+
|
|
326
|
+
<style>
|
|
327
|
+
:global(.chat-markdown pre.chat-code-block) {
|
|
328
|
+
background: hsl(var(--muted));
|
|
329
|
+
border-radius: 0.5rem;
|
|
330
|
+
padding: 0.75rem;
|
|
331
|
+
margin: 0.5rem 0;
|
|
332
|
+
overflow-x: auto;
|
|
333
|
+
font-size: 0.75rem;
|
|
334
|
+
line-height: 1.5;
|
|
335
|
+
}
|
|
336
|
+
:global(.chat-markdown code.chat-inline-code) {
|
|
337
|
+
background: hsl(var(--muted));
|
|
338
|
+
padding: 0.125rem 0.375rem;
|
|
339
|
+
border-radius: 0.25rem;
|
|
340
|
+
font-size: 0.8125em;
|
|
341
|
+
font-family: ui-monospace, monospace;
|
|
342
|
+
}
|
|
343
|
+
:global(.chat-markdown strong) {
|
|
344
|
+
font-weight: 600;
|
|
345
|
+
}
|
|
346
|
+
</style>
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
import type { Snippet } from 'svelte';
|
|
6
6
|
import PageHeader from './PageHeader.svelte';
|
|
7
7
|
import AutoForm from './AutoForm.svelte';
|
|
8
|
-
import
|
|
9
|
-
import { Button } from './ui/button/index.js';
|
|
10
|
-
import TooltipButton from './TooltipButton.svelte';
|
|
8
|
+
import ListButton from './buttons/ListButton.svelte';
|
|
11
9
|
|
|
12
10
|
interface Props {
|
|
13
11
|
resourceName: string;
|
|
@@ -30,9 +28,7 @@
|
|
|
30
28
|
<div class="space-y-6 {className}">
|
|
31
29
|
<PageHeader title={pageTitle}>
|
|
32
30
|
{#snippet actions()}
|
|
33
|
-
<
|
|
34
|
-
<ArrowLeft class="h-5 w-5" />
|
|
35
|
-
</TooltipButton>
|
|
31
|
+
<ListButton resource={resourceName} hideText />
|
|
36
32
|
{#if headerActions}
|
|
37
33
|
{@render headerActions()}
|
|
38
34
|
{/if}
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import type { Snippet } from 'svelte';
|
|
6
6
|
import PageHeader from './PageHeader.svelte';
|
|
7
7
|
import AutoForm from './AutoForm.svelte';
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
8
|
+
import ListButton from './buttons/ListButton.svelte';
|
|
9
|
+
import ShowButton from './buttons/ShowButton.svelte';
|
|
10
|
+
import RefreshButton from './buttons/RefreshButton.svelte';
|
|
11
|
+
import DeleteButton from './buttons/DeleteButton.svelte';
|
|
12
12
|
|
|
13
13
|
interface Props {
|
|
14
14
|
resourceName: string;
|
|
@@ -31,29 +31,16 @@
|
|
|
31
31
|
const resource = $derived(getResource(resourceName));
|
|
32
32
|
const pageTitle = $derived(title ?? `${t('common.edit')} ${resource.label} #${id}`);
|
|
33
33
|
const showDelete = $derived(canDelete ?? resource.canDelete !== false);
|
|
34
|
-
|
|
35
|
-
const deleteResult = useDelete({ get resource() { return resourceName; } });
|
|
36
|
-
const deleteMutation = deleteResult.mutation;
|
|
37
|
-
|
|
38
|
-
let confirmOpen = $state(false);
|
|
39
|
-
|
|
40
|
-
async function handleDelete() {
|
|
41
|
-
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
42
|
-
confirmOpen = false;
|
|
43
|
-
navigate(`/${resourceName}`);
|
|
44
|
-
}
|
|
45
34
|
</script>
|
|
46
35
|
|
|
47
36
|
<div class="space-y-6 {className}">
|
|
48
37
|
<PageHeader title={pageTitle}>
|
|
49
38
|
{#snippet actions()}
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{#if showDelete}
|
|
54
|
-
<
|
|
55
|
-
<Trash2 class="h-4 w-4" />
|
|
56
|
-
</TooltipButton>
|
|
39
|
+
<ListButton resource={resourceName} hideText />
|
|
40
|
+
<ShowButton resource={resourceName} recordItemId={id} hideText />
|
|
41
|
+
<RefreshButton resource={resourceName} hideText />
|
|
42
|
+
{#if showDelete !== false}
|
|
43
|
+
<DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => navigate(`/${resourceName}`)} />
|
|
57
44
|
{/if}
|
|
58
45
|
{#if headerActions}
|
|
59
46
|
{@render headerActions()}
|
|
@@ -63,10 +50,3 @@
|
|
|
63
50
|
|
|
64
51
|
<AutoForm {resourceName} mode="edit" {id} />
|
|
65
52
|
</div>
|
|
66
|
-
|
|
67
|
-
<ConfirmDialog
|
|
68
|
-
bind:open={confirmOpen}
|
|
69
|
-
title={t('common.deleteConfirm')}
|
|
70
|
-
onconfirm={handleDelete}
|
|
71
|
-
oncancel={() => confirmOpen = false}
|
|
72
|
-
/>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { Label } from './ui/label/index.js';
|
|
8
8
|
import * as Card from './ui/card/index.js';
|
|
9
9
|
import * as Alert from './ui/alert/index.js';
|
|
10
|
-
import { KeyRound,
|
|
10
|
+
import { KeyRound, User, ArrowLeft, CheckCircle, Loader2, AlertCircle } from 'lucide-svelte';
|
|
11
11
|
|
|
12
12
|
let { title = 'Admin' } = $props<{
|
|
13
13
|
title?: string;
|
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
|
|
16
16
|
const forgot = useForgotPassword();
|
|
17
17
|
|
|
18
|
-
let
|
|
18
|
+
let identifier = $state('');
|
|
19
19
|
let error = $state('');
|
|
20
20
|
let sent = $state(false);
|
|
21
21
|
|
|
22
|
-
async function handleSubmit(e:
|
|
22
|
+
async function handleSubmit(e: Event) {
|
|
23
23
|
e.preventDefault();
|
|
24
24
|
error = '';
|
|
25
25
|
|
|
26
|
-
if (!
|
|
26
|
+
if (!identifier) { error = t('auth.usernameOrEmailRequired'); return; }
|
|
27
27
|
|
|
28
|
-
const result = await forgot.mutate({ email });
|
|
28
|
+
const result = await forgot.mutate({ email: identifier, username: identifier });
|
|
29
29
|
if (result.success) {
|
|
30
30
|
sent = true;
|
|
31
31
|
} else {
|
|
@@ -73,16 +73,16 @@
|
|
|
73
73
|
{/if}
|
|
74
74
|
|
|
75
75
|
<div class="space-y-2">
|
|
76
|
-
<Label for="forgot-
|
|
76
|
+
<Label for="forgot-identifier">{t('auth.usernameOrEmail')}</Label>
|
|
77
77
|
<div class="relative">
|
|
78
|
-
<
|
|
78
|
+
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
|
|
79
79
|
<Input
|
|
80
|
-
id="forgot-
|
|
81
|
-
type="
|
|
82
|
-
placeholder=
|
|
83
|
-
bind:value={
|
|
80
|
+
id="forgot-identifier"
|
|
81
|
+
type="text"
|
|
82
|
+
placeholder={t('auth.identifierPlaceholder')}
|
|
83
|
+
bind:value={identifier}
|
|
84
84
|
class="pl-9"
|
|
85
|
-
autocomplete="
|
|
85
|
+
autocomplete="username"
|
|
86
86
|
/>
|
|
87
87
|
</div>
|
|
88
88
|
</div>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import Toast from './Toast.svelte';
|
|
6
6
|
import Header from './Header.svelte';
|
|
7
7
|
import CommandPalette from './CommandPalette.svelte';
|
|
8
|
+
import ChatDialog from './ChatDialog.svelte';
|
|
8
9
|
import TooltipButton from './TooltipButton.svelte';
|
|
9
10
|
import { t } from '@svadmin/core/i18n';
|
|
10
11
|
import { getAuthProvider } from '@svadmin/core';
|
|
@@ -124,6 +125,7 @@
|
|
|
124
125
|
</div>
|
|
125
126
|
</div>
|
|
126
127
|
<CommandPalette bind:open={commandOpen} />
|
|
128
|
+
<ChatDialog />
|
|
127
129
|
<Toast />
|
|
128
130
|
{/if}
|
|
129
131
|
</div>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as Alert from './ui/alert/index.js';
|
|
10
10
|
import PasswordInput from './PasswordInput.svelte';
|
|
11
11
|
import { Separator } from './ui/separator/index.js';
|
|
12
|
-
import { LogIn,
|
|
12
|
+
import { LogIn, User, Loader2, AlertCircle } from 'lucide-svelte';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Social provider config for the login page.
|
|
@@ -41,18 +41,18 @@
|
|
|
41
41
|
const login = useLogin();
|
|
42
42
|
const authProvider = getAuthProvider();
|
|
43
43
|
|
|
44
|
-
let
|
|
44
|
+
let identifier = $state('');
|
|
45
45
|
let password = $state('');
|
|
46
46
|
let error = $state('');
|
|
47
47
|
|
|
48
|
-
async function handleSubmit(e:
|
|
48
|
+
async function handleSubmit(e: Event) {
|
|
49
49
|
e.preventDefault();
|
|
50
50
|
error = '';
|
|
51
51
|
|
|
52
|
-
if (!
|
|
52
|
+
if (!identifier) { error = t('auth.usernameOrEmailRequired'); return; }
|
|
53
53
|
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
54
54
|
|
|
55
|
-
const result = await login.mutate({ email, password });
|
|
55
|
+
const result = await login.mutate({ email: identifier, username: identifier, password });
|
|
56
56
|
if (result.success) {
|
|
57
57
|
onSuccess?.();
|
|
58
58
|
} else {
|
|
@@ -81,16 +81,16 @@
|
|
|
81
81
|
{/if}
|
|
82
82
|
|
|
83
83
|
<div class="space-y-2">
|
|
84
|
-
<Label for="login-
|
|
84
|
+
<Label for="login-identifier">{t('auth.usernameOrEmail')}</Label>
|
|
85
85
|
<div class="relative">
|
|
86
|
-
<
|
|
86
|
+
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
|
|
87
87
|
<Input
|
|
88
|
-
id="login-
|
|
89
|
-
type="
|
|
90
|
-
placeholder=
|
|
91
|
-
bind:value={
|
|
88
|
+
id="login-identifier"
|
|
89
|
+
type="text"
|
|
90
|
+
placeholder={t('auth.identifierPlaceholder')}
|
|
91
|
+
bind:value={identifier}
|
|
92
92
|
class="pl-9"
|
|
93
|
-
autocomplete="
|
|
93
|
+
autocomplete="username"
|
|
94
94
|
/>
|
|
95
95
|
</div>
|
|
96
96
|
</div>
|
|
@@ -88,10 +88,14 @@
|
|
|
88
88
|
<p class="text-lg font-semibold truncate">{identity.data.name ?? '—'}</p>
|
|
89
89
|
</div>
|
|
90
90
|
|
|
91
|
-
{#if identity.data.email}
|
|
91
|
+
{#if identity.data.email || identity.data.username}
|
|
92
92
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
{#if identity.data.email}
|
|
94
|
+
<Mail class="h-4 w-4 shrink-0" />
|
|
95
|
+
{:else}
|
|
96
|
+
<User class="h-4 w-4 shrink-0" />
|
|
97
|
+
{/if}
|
|
98
|
+
<span class="truncate">{identity.data.email || identity.data.username}</span>
|
|
95
99
|
</div>
|
|
96
100
|
{/if}
|
|
97
101
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import * as Card from './ui/card/index.js';
|
|
9
9
|
import * as Alert from './ui/alert/index.js';
|
|
10
10
|
import PasswordInput from './PasswordInput.svelte';
|
|
11
|
-
import { UserPlus,
|
|
11
|
+
import { UserPlus, User, Loader2, AlertCircle } from 'lucide-svelte';
|
|
12
12
|
|
|
13
13
|
let { title = 'Admin', onSuccess } = $props<{
|
|
14
14
|
title?: string;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const register = useRegister();
|
|
19
19
|
|
|
20
|
-
let
|
|
20
|
+
let identifier = $state('');
|
|
21
21
|
let password = $state('');
|
|
22
22
|
let confirmPassword = $state('');
|
|
23
23
|
let error = $state('');
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
e.preventDefault();
|
|
27
27
|
error = '';
|
|
28
28
|
|
|
29
|
-
if (!
|
|
29
|
+
if (!identifier) { error = t('auth.usernameOrEmailRequired'); return; }
|
|
30
30
|
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
31
31
|
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
|
|
32
32
|
|
|
33
|
-
const result = await register.mutate({ email, password });
|
|
33
|
+
const result = await register.mutate({ email: identifier, username: identifier, password });
|
|
34
34
|
if (result.success) {
|
|
35
35
|
onSuccess?.();
|
|
36
36
|
} else {
|
|
@@ -59,16 +59,16 @@
|
|
|
59
59
|
{/if}
|
|
60
60
|
|
|
61
61
|
<div class="space-y-2">
|
|
62
|
-
<Label for="register-
|
|
62
|
+
<Label for="register-identifier">{t('auth.usernameOrEmail')}</Label>
|
|
63
63
|
<div class="relative">
|
|
64
|
-
<
|
|
64
|
+
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
|
|
65
65
|
<Input
|
|
66
|
-
id="register-
|
|
67
|
-
type="
|
|
68
|
-
placeholder=
|
|
69
|
-
bind:value={
|
|
66
|
+
id="register-identifier"
|
|
67
|
+
type="text"
|
|
68
|
+
placeholder={t('auth.identifierPlaceholder')}
|
|
69
|
+
bind:value={identifier}
|
|
70
70
|
class="pl-9"
|
|
71
|
-
autocomplete="
|
|
71
|
+
autocomplete="username"
|
|
72
72
|
/>
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
import * as Card from './ui/card/index.js';
|
|
8
8
|
import { Skeleton } from './ui/skeleton/index.js';
|
|
9
9
|
import PageHeader from './PageHeader.svelte';
|
|
10
|
-
import TooltipButton from './TooltipButton.svelte';
|
|
11
10
|
import { getDisplayComponent } from './fieldComponentMap';
|
|
12
|
-
import
|
|
11
|
+
import ListButton from './buttons/ListButton.svelte';
|
|
12
|
+
import EditButton from './buttons/EditButton.svelte';
|
|
13
|
+
import DeleteButton from './buttons/DeleteButton.svelte';
|
|
14
|
+
import RefreshButton from './buttons/RefreshButton.svelte';
|
|
13
15
|
|
|
14
16
|
let { resourceName, id } = $props<{ resourceName: string; id: string | number }>();
|
|
15
17
|
|
|
@@ -22,14 +24,14 @@
|
|
|
22
24
|
<div class="space-y-6">
|
|
23
25
|
<PageHeader title="{resource.label} {t('common.detail')}">
|
|
24
26
|
{#snippet actions()}
|
|
25
|
-
<
|
|
26
|
-
<ArrowLeft class="h-5 w-5" />
|
|
27
|
-
</TooltipButton>
|
|
27
|
+
<ListButton resource={resourceName} hideText />
|
|
28
28
|
{#if resource.canEdit !== false}
|
|
29
|
-
<
|
|
30
|
-
<Pencil class="h-4 w-4" data-icon="inline-start" /> {t('common.edit')}
|
|
31
|
-
</Button>
|
|
29
|
+
<EditButton resource={resourceName} recordItemId={id} hideText />
|
|
32
30
|
{/if}
|
|
31
|
+
{#if resource.canDelete !== false}
|
|
32
|
+
<DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => navigate(`/${resourceName}`)} />
|
|
33
|
+
{/if}
|
|
34
|
+
<RefreshButton resource={resourceName} hideText />
|
|
33
35
|
{/snippet}
|
|
34
36
|
</PageHeader>
|
|
35
37
|
|
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
{#if collapsed}
|
|
209
209
|
<Tooltip.Root>
|
|
210
210
|
<Tooltip.Trigger>
|
|
211
|
-
{#snippet child({ props })}
|
|
211
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
212
212
|
<a
|
|
213
213
|
{...props}
|
|
214
214
|
href={`#${item.path}`}
|
|
@@ -294,7 +294,7 @@
|
|
|
294
294
|
/>
|
|
295
295
|
<div class="flex-1 min-w-0">
|
|
296
296
|
<p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
|
|
297
|
-
<p class="truncate text-[10px] text-sidebar-foreground/50">
|
|
297
|
+
<p class="truncate text-[10px] text-sidebar-foreground/50">{((identity as Record<string, unknown>).role || (identity as Record<string, unknown>).roleName) ?? 'User'}</p>
|
|
298
298
|
</div>
|
|
299
299
|
</div>
|
|
300
300
|
<div class="flex items-center justify-between px-1 mt-1">
|
|
@@ -317,7 +317,7 @@
|
|
|
317
317
|
<div class="px-1 py-3 space-y-1">
|
|
318
318
|
<Tooltip.Root>
|
|
319
319
|
<Tooltip.Trigger>
|
|
320
|
-
{#snippet child({ props })}
|
|
320
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
321
321
|
<Button {...props} variant="ghost" size="icon" onclick={toggleLocale} class="w-full text-sidebar-foreground/60 hover:text-sidebar-foreground">
|
|
322
322
|
<span class="text-xs font-bold">{localeLabel}</span>
|
|
323
323
|
</Button>
|
|
@@ -327,7 +327,7 @@
|
|
|
327
327
|
</Tooltip.Root>
|
|
328
328
|
<Tooltip.Root>
|
|
329
329
|
<Tooltip.Trigger>
|
|
330
|
-
{#snippet child({ props })}
|
|
330
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
331
331
|
<Button {...props} variant="ghost" size="icon" onclick={toggleTheme} class="w-full text-sidebar-foreground/60 hover:text-sidebar-foreground">
|
|
332
332
|
{#if getResolvedTheme() === 'dark'}
|
|
333
333
|
<Sun class="h-4 w-4" />
|
|
@@ -341,7 +341,7 @@
|
|
|
341
341
|
</Tooltip.Root>
|
|
342
342
|
<Tooltip.Root>
|
|
343
343
|
<Tooltip.Trigger>
|
|
344
|
-
{#snippet child({ props })}
|
|
344
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
345
345
|
<Button {...props} variant="ghost" size="icon" onclick={onLogout} class="w-full text-sidebar-foreground/60 hover:text-destructive">
|
|
346
346
|
<LogOut class="h-4 w-4" />
|
|
347
347
|
</Button>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Copy } from 'lucide-svelte';
|
|
5
5
|
|
|
6
|
-
let { resource, recordItemId, hideText = false, accessControl, class: className = '' } = $props<{
|
|
6
|
+
let { resource, recordItemId, hideText = false, accessControl = { enabled: true, hideIfUnauthorized: true }, class: className = '' } = $props<{
|
|
7
7
|
resource: string;
|
|
8
8
|
recordItemId: string | number;
|
|
9
9
|
hideText?: boolean;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Plus } from 'lucide-svelte';
|
|
5
5
|
|
|
6
|
-
let { resource, hideText = false, accessControl, class: className = '' } = $props<{
|
|
6
|
+
let { resource, hideText = false, accessControl = { enabled: true, hideIfUnauthorized: true }, class: className = '' } = $props<{
|
|
7
7
|
resource: string;
|
|
8
8
|
hideText?: boolean;
|
|
9
9
|
accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
let {
|
|
7
7
|
resource, recordItemId, hideText = false,
|
|
8
|
-
accessControl, onSuccess, undoable = false, class: className = '',
|
|
8
|
+
accessControl = { enabled: true, hideIfUnauthorized: true }, onSuccess, undoable = false, class: className = '',
|
|
9
9
|
} = $props<{
|
|
10
10
|
resource: string;
|
|
11
11
|
recordItemId: string | number;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Pencil } from 'lucide-svelte';
|
|
5
5
|
|
|
6
|
-
let { resource, recordItemId, hideText = false, accessControl, class: className = '' } = $props<{
|
|
6
|
+
let { resource, recordItemId, hideText = false, accessControl = { enabled: true, hideIfUnauthorized: true }, class: className = '' } = $props<{
|
|
7
7
|
resource: string;
|
|
8
8
|
recordItemId: string | number;
|
|
9
9
|
hideText?: boolean;
|
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { useExport, t } from '@svadmin/core';
|
|
2
|
+
import { useExport, useCan, t } from '@svadmin/core';
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Download } from 'lucide-svelte';
|
|
5
5
|
|
|
6
|
-
let { resource, hideText = false, class: className = '' } = $props<{
|
|
6
|
+
let { resource, hideText = false, accessControl = { enabled: true, hideIfUnauthorized: true }, class: className = '' } = $props<{
|
|
7
7
|
resource: string;
|
|
8
8
|
hideText?: boolean;
|
|
9
|
+
accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
|
|
9
10
|
class?: string;
|
|
10
11
|
}>();
|
|
11
12
|
|
|
12
13
|
const { triggerExport, isLoading } = useExport({ get resource() { return resource; } });
|
|
14
|
+
const can = $derived(accessControl?.enabled ? useCan(resource, 'export') : null);
|
|
15
|
+
const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
|
|
13
16
|
</script>
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
{#if !hidden}
|
|
19
|
+
|
|
20
|
+
<Button
|
|
21
|
+
variant="outline"
|
|
22
|
+
size={hideText ? 'icon' : 'sm'}
|
|
23
|
+
class={className}
|
|
24
|
+
disabled={isLoading || (can ? !can.allowed : false)}
|
|
25
|
+
onclick={triggerExport}
|
|
26
|
+
>
|
|
27
|
+
<Download class="h-4 w-4" />
|
|
28
|
+
{#if !hideText}<span class="ml-1">{t('common.export')}</span>{/if}
|
|
29
|
+
</Button>
|
|
30
|
+
{/if}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { useImport, t, type UseImportOptions } from '@svadmin/core';
|
|
2
|
+
import { useImport, useCan, t, type UseImportOptions } from '@svadmin/core';
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Upload } from 'lucide-svelte';
|
|
5
5
|
|
|
6
|
-
let { resource, hideText = false, onFinish, class: className = '' } = $props<{
|
|
6
|
+
let { resource, hideText = false, onFinish, accessControl = { enabled: true, hideIfUnauthorized: true }, class: className = '' } = $props<{
|
|
7
7
|
resource: string;
|
|
8
8
|
hideText?: boolean;
|
|
9
9
|
onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
|
|
10
|
+
accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
|
|
10
11
|
class?: string;
|
|
11
12
|
}>();
|
|
12
13
|
|
|
@@ -29,8 +30,12 @@
|
|
|
29
30
|
input.value = ''; // reset for re-import
|
|
30
31
|
}
|
|
31
32
|
}
|
|
33
|
+
|
|
34
|
+
const can = $derived(accessControl?.enabled ? useCan(resource, 'import') : null);
|
|
35
|
+
const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
|
|
32
36
|
</script>
|
|
33
37
|
|
|
38
|
+
{#if !hidden}
|
|
34
39
|
<input
|
|
35
40
|
bind:this={fileInput}
|
|
36
41
|
type="file"
|
|
@@ -38,13 +43,14 @@
|
|
|
38
43
|
style="display:none"
|
|
39
44
|
onchange={handleFileChange}
|
|
40
45
|
/>
|
|
41
|
-
<Button
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
</Button>
|
|
46
|
+
<Button
|
|
47
|
+
variant="outline"
|
|
48
|
+
size={hideText ? 'icon' : 'sm'}
|
|
49
|
+
class={className}
|
|
50
|
+
disabled={importHook.isLoading || (can ? !can.allowed : false)}
|
|
51
|
+
onclick={triggerImport}
|
|
52
|
+
>
|
|
53
|
+
<Upload class="h-4 w-4" />
|
|
54
|
+
{#if !hideText}<span class="ml-1">{t('common.import')}</span>{/if}
|
|
55
|
+
</Button>
|
|
56
|
+
{/if}
|
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { useNavigation, t } from '@svadmin/core';
|
|
2
|
+
import { useNavigation, useCan, t } from '@svadmin/core';
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Eye } from 'lucide-svelte';
|
|
5
5
|
|
|
6
|
-
let { resource, recordItemId, hideText = false, class: className = '' } = $props<{
|
|
6
|
+
let { resource, recordItemId, hideText = false, accessControl = { enabled: true, hideIfUnauthorized: true }, class: className = '' } = $props<{
|
|
7
7
|
resource: string;
|
|
8
8
|
recordItemId: string | number;
|
|
9
9
|
hideText?: boolean;
|
|
10
|
+
accessControl?: { enabled?: boolean; hideIfUnauthorized?: boolean };
|
|
10
11
|
class?: string;
|
|
11
12
|
}>();
|
|
12
13
|
|
|
13
14
|
const nav = useNavigation();
|
|
15
|
+
const can = $derived(accessControl?.enabled ? useCan(resource, 'show') : null);
|
|
16
|
+
const hidden = $derived(accessControl?.hideIfUnauthorized && can && !can.allowed);
|
|
14
17
|
</script>
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
{#if !hidden}
|
|
20
|
+
|
|
21
|
+
<Button
|
|
22
|
+
variant="ghost"
|
|
23
|
+
size={hideText ? 'icon' : 'sm'}
|
|
24
|
+
class={className}
|
|
25
|
+
disabled={can ? !can.allowed : false}
|
|
26
|
+
onclick={() => nav.show(resource, recordItemId)}
|
|
27
|
+
>
|
|
28
|
+
<Eye class="h-4 w-4" />
|
|
29
|
+
{#if !hideText}<span class="ml-1">{t('common.detail')}</span>{/if}
|
|
30
|
+
</Button>
|
|
31
|
+
{/if}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { cn, type WithElementRef } from "../../../utils.js";
|
|
3
3
|
import type { HTMLSelectAttributes } from "svelte/elements";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Use HTMLElement instead of HTMLSelectElement to avoid TS error:
|
|
6
|
+
// HTMLSelectElement.remove() returns void, incompatible with HTMLElement's Element return
|
|
7
|
+
type Props = WithElementRef<HTMLSelectAttributes, HTMLElement> & {
|
|
6
8
|
placeholder?: string;
|
|
7
9
|
};
|
|
8
10
|
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ 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
|
-
|
|
49
|
+
export { default as ChatDialog } from './components/ChatDialog.svelte';
|
|
50
50
|
// Field display components
|
|
51
51
|
export { default as NumberField } from './components/fields/NumberField.svelte';
|
|
52
52
|
export { default as DateField } from './components/fields/DateField.svelte';
|