Skip to content

Commit 71d54f8

Browse files
committed
feat: Import/Export UX improvements
1 parent 2f89bcd commit 71d54f8

File tree

7 files changed

+546
-44
lines changed

7 files changed

+546
-44
lines changed

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
Sun,
1010
Moon,
1111
ChevronLeft,
12-
ChevronRight
12+
ChevronRight,
13+
Database
1314
} from '@lucide/svelte';
1415
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
16+
import ImportExportTab from './ImportExportTab.svelte';
1517
import * as Dialog from '$lib/components/ui/dialog';
1618
import { ScrollArea } from '$lib/components/ui/scroll-area';
1719
import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
@@ -221,6 +223,11 @@
221223
type: 'textarea'
222224
}
223225
]
226+
},
227+
{
228+
title: 'Import/Export',
229+
icon: Database,
230+
fields: []
224231
}
225232
// TODO: Experimental features section will be implemented after initial release
226233
// This includes Python interpreter (Pyodide integration) and other experimental features
@@ -456,21 +463,27 @@
456463

457464
<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
458465
<div class="space-y-6 p-4 md:p-6">
459-
<div>
466+
<div class="grid">
460467
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
461468
<currentSection.icon class="h-5 w-5" />
462469

463470
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
464471
</div>
465472

466-
<div class="space-y-6">
467-
<ChatSettingsFields
468-
fields={currentSection.fields}
469-
{localConfig}
470-
onConfigChange={handleConfigChange}
471-
onThemeChange={handleThemeChange}
472-
/>
473-
</div>
473+
{#if currentSection.title === 'Import/Export'}
474+
<!-- Import/Export Section -->
475+
<ImportExportTab />
476+
{:else}
477+
<!-- Regular Settings Fields -->
478+
<div class="space-y-6">
479+
<ChatSettingsFields
480+
fields={currentSection.fields}
481+
{localConfig}
482+
onConfigChange={handleConfigChange}
483+
onThemeChange={handleThemeChange}
484+
/>
485+
</div>
486+
{/if}
474487
</div>
475488

476489
<div class="mt-8 border-t pt-6">
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<script lang="ts">
2+
import { Search, X } from '@lucide/svelte';
3+
import * as Dialog from '$lib/components/ui/dialog';
4+
import { Button } from '$lib/components/ui/button';
5+
import { Input } from '$lib/components/ui/input';
6+
import { Checkbox } from '$lib/components/ui/checkbox';
7+
import { ScrollArea } from '$lib/components/ui/scroll-area';
8+
import { SvelteSet } from 'svelte/reactivity';
9+
10+
interface Props {
11+
conversations: DatabaseConversation[];
12+
messageCountMap?: Map<string, number>;
13+
mode: 'export' | 'import';
14+
onCancel: () => void;
15+
onConfirm: (selectedConversations: DatabaseConversation[]) => void;
16+
open?: boolean;
17+
}
18+
19+
let {
20+
conversations,
21+
messageCountMap = new Map(),
22+
mode,
23+
onCancel,
24+
onConfirm,
25+
open = $bindable(false)
26+
}: Props = $props();
27+
28+
let searchQuery = $state('');
29+
let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
30+
31+
// Filter conversations based on search query
32+
let filteredConversations = $derived(
33+
conversations.filter((conv) => {
34+
const name = conv.name || 'Untitled conversation';
35+
return name.toLowerCase().includes(searchQuery.toLowerCase());
36+
})
37+
);
38+
39+
// Check if all filtered conversations are selected
40+
let allSelected = $derived(
41+
filteredConversations.length > 0 &&
42+
filteredConversations.every((conv) => selectedIds.has(conv.id))
43+
);
44+
45+
// Check if some (but not all) filtered conversations are selected
46+
let someSelected = $derived(
47+
filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
48+
);
49+
50+
function toggleConversation(id: string) {
51+
const newSet = new SvelteSet(selectedIds);
52+
if (newSet.has(id)) {
53+
newSet.delete(id);
54+
} else {
55+
newSet.add(id);
56+
}
57+
selectedIds = newSet;
58+
}
59+
60+
function toggleAll() {
61+
if (allSelected) {
62+
// Deselect all filtered conversations
63+
const newSet = new SvelteSet(selectedIds);
64+
filteredConversations.forEach((conv) => newSet.delete(conv.id));
65+
selectedIds = newSet;
66+
} else {
67+
// Select all filtered conversations
68+
const newSet = new SvelteSet(selectedIds);
69+
filteredConversations.forEach((conv) => newSet.add(conv.id));
70+
selectedIds = newSet;
71+
}
72+
}
73+
74+
function handleConfirm() {
75+
const selected = conversations.filter((conv) => selectedIds.has(conv.id));
76+
onConfirm(selected);
77+
}
78+
79+
function handleCancel() {
80+
// Reset selection to all conversations
81+
selectedIds = new SvelteSet(conversations.map((c) => c.id));
82+
searchQuery = '';
83+
onCancel();
84+
}
85+
86+
// Track previous open state to detect close
87+
let previousOpen = $state(false);
88+
89+
// Reset selection when dialog opens, call onCancel when it closes
90+
$effect(() => {
91+
if (open && !previousOpen) {
92+
// Dialog just opened
93+
selectedIds = new SvelteSet(conversations.map((c) => c.id));
94+
searchQuery = '';
95+
} else if (!open && previousOpen) {
96+
// Dialog just closed
97+
onCancel();
98+
}
99+
previousOpen = open;
100+
});
101+
</script>
102+
103+
<Dialog.Root bind:open>
104+
<Dialog.Portal>
105+
<Dialog.Overlay class="z-[1000000]" />
106+
<Dialog.Content class="z-[1000001] max-w-2xl">
107+
<Dialog.Header>
108+
<Dialog.Title>
109+
Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
110+
</Dialog.Title>
111+
<Dialog.Description>
112+
{#if mode === 'export'}
113+
Choose which conversations you want to export. Selected conversations will be downloaded
114+
as a JSON file.
115+
{:else}
116+
Choose which conversations you want to import. Selected conversations will be merged
117+
with your existing conversations.
118+
{/if}
119+
</Dialog.Description>
120+
</Dialog.Header>
121+
122+
<div class="space-y-4">
123+
<!-- Search Input -->
124+
<div class="relative">
125+
<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
126+
<Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
127+
{#if searchQuery}
128+
<button
129+
class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
130+
onclick={() => (searchQuery = '')}
131+
type="button"
132+
>
133+
<X class="h-4 w-4" />
134+
</button>
135+
{/if}
136+
</div>
137+
138+
<!-- Selection Summary -->
139+
<div class="flex items-center justify-between text-sm text-muted-foreground">
140+
<span>
141+
{selectedIds.size} of {conversations.length} selected
142+
{#if searchQuery}
143+
({filteredConversations.length} shown)
144+
{/if}
145+
</span>
146+
</div>
147+
148+
<!-- Conversations Table -->
149+
<div class="overflow-hidden rounded-md border">
150+
<ScrollArea class="h-[400px]">
151+
<table class="w-full">
152+
<thead class="sticky top-0 z-10 bg-muted">
153+
<tr class="border-b">
154+
<th class="w-12 p-3 text-left">
155+
<Checkbox
156+
checked={allSelected}
157+
indeterminate={someSelected}
158+
onCheckedChange={toggleAll}
159+
/>
160+
</th>
161+
<th class="p-3 text-left text-sm font-medium">Conversation Name</th>
162+
<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
163+
</tr>
164+
</thead>
165+
<tbody>
166+
{#if filteredConversations.length === 0}
167+
<tr>
168+
<td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
169+
{#if searchQuery}
170+
No conversations found matching "{searchQuery}"
171+
{:else}
172+
No conversations available
173+
{/if}
174+
</td>
175+
</tr>
176+
{:else}
177+
{#each filteredConversations as conv (conv.id)}
178+
<tr
179+
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
180+
onclick={() => toggleConversation(conv.id)}
181+
>
182+
<td class="p-3" onclick={(e) => e.stopPropagation()}>
183+
<Checkbox
184+
checked={selectedIds.has(conv.id)}
185+
onCheckedChange={() => toggleConversation(conv.id)}
186+
/>
187+
</td>
188+
<td class="p-3 text-sm">
189+
<div
190+
class="max-w-[17rem] truncate"
191+
title={conv.name || 'Untitled conversation'}
192+
>
193+
{conv.name || 'Untitled conversation'}
194+
</div>
195+
</td>
196+
<td class="p-3 text-sm text-muted-foreground">
197+
{messageCountMap.get(conv.id) ?? 0}
198+
</td>
199+
</tr>
200+
{/each}
201+
{/if}
202+
</tbody>
203+
</table>
204+
</ScrollArea>
205+
</div>
206+
</div>
207+
208+
<Dialog.Footer>
209+
<Button variant="outline" onclick={handleCancel}>Cancel</Button>
210+
<Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
211+
{mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
212+
</Button>
213+
</Dialog.Footer>
214+
</Dialog.Content>
215+
</Dialog.Portal>
216+
</Dialog.Root>

0 commit comments

Comments
 (0)