1<script lang="ts">
2 import { Search, X } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import { Input } from '$lib/components/ui/input';
5 import { Checkbox } from '$lib/components/ui/checkbox';
6 import { ScrollArea } from '$lib/components/ui/scroll-area';
7 import { SvelteSet } from 'svelte/reactivity';
8
9 interface Props {
10 conversations: DatabaseConversation[];
11 messageCountMap?: Map<string, number>;
12 mode: 'export' | 'import';
13 onCancel: () => void;
14 onConfirm: (selectedConversations: DatabaseConversation[]) => void;
15 }
16
17 let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
18
19 let searchQuery = $state('');
20 let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
21 let lastClickedId = $state<string | null>(null);
22
23 let filteredConversations = $derived(
24 conversations.filter((conv) => {
25 const name = conv.name || 'Untitled conversation';
26 return name.toLowerCase().includes(searchQuery.toLowerCase());
27 })
28 );
29
30 let allSelected = $derived(
31 filteredConversations.length > 0 &&
32 filteredConversations.every((conv) => selectedIds.has(conv.id))
33 );
34
35 let someSelected = $derived(
36 filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
37 );
38
39 function toggleConversation(id: string, shiftKey: boolean = false) {
40 const newSet = new SvelteSet(selectedIds);
41
42 if (shiftKey && lastClickedId !== null) {
43 const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
44 const currentIndex = filteredConversations.findIndex((c) => c.id === id);
45
46 if (lastIndex !== -1 && currentIndex !== -1) {
47 const start = Math.min(lastIndex, currentIndex);
48 const end = Math.max(lastIndex, currentIndex);
49
50 const shouldSelect = !newSet.has(id);
51
52 for (let i = start; i <= end; i++) {
53 if (shouldSelect) {
54 newSet.add(filteredConversations[i].id);
55 } else {
56 newSet.delete(filteredConversations[i].id);
57 }
58 }
59
60 selectedIds = newSet;
61 return;
62 }
63 }
64
65 if (newSet.has(id)) {
66 newSet.delete(id);
67 } else {
68 newSet.add(id);
69 }
70
71 selectedIds = newSet;
72 lastClickedId = id;
73 }
74
75 function toggleAll() {
76 if (allSelected) {
77 const newSet = new SvelteSet(selectedIds);
78
79 filteredConversations.forEach((conv) => newSet.delete(conv.id));
80 selectedIds = newSet;
81 } else {
82 const newSet = new SvelteSet(selectedIds);
83
84 filteredConversations.forEach((conv) => newSet.add(conv.id));
85 selectedIds = newSet;
86 }
87 }
88
89 function handleConfirm() {
90 const selected = conversations.filter((conv) => selectedIds.has(conv.id));
91 onConfirm(selected);
92 }
93
94 function handleCancel() {
95 selectedIds = new SvelteSet(conversations.map((c) => c.id));
96 searchQuery = '';
97 lastClickedId = null;
98
99 onCancel();
100 }
101
102 export function reset() {
103 selectedIds = new SvelteSet(conversations.map((c) => c.id));
104 searchQuery = '';
105 lastClickedId = null;
106 }
107</script>
108
109<div class="space-y-4">
110 <div class="relative">
111 <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
112
113 <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
114
115 {#if searchQuery}
116 <button
117 class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
118 onclick={() => (searchQuery = '')}
119 type="button"
120 >
121 <X class="h-4 w-4" />
122 </button>
123 {/if}
124 </div>
125
126 <div class="flex items-center justify-between text-sm text-muted-foreground">
127 <span>
128 {selectedIds.size} of {conversations.length} selected
129 {#if searchQuery}
130 ({filteredConversations.length} shown)
131 {/if}
132 </span>
133 </div>
134
135 <div class="overflow-hidden rounded-md border">
136 <ScrollArea class="h-[400px]">
137 <table class="w-full">
138 <thead class="sticky top-0 z-10 bg-muted">
139 <tr class="border-b">
140 <th class="w-12 p-3 text-left">
141 <Checkbox
142 checked={allSelected}
143 indeterminate={someSelected}
144 onCheckedChange={toggleAll}
145 />
146 </th>
147
148 <th class="p-3 text-left text-sm font-medium">Conversation Name</th>
149
150 <th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
151 </tr>
152 </thead>
153 <tbody>
154 {#if filteredConversations.length === 0}
155 <tr>
156 <td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
157 {#if searchQuery}
158 No conversations found matching "{searchQuery}"
159 {:else}
160 No conversations available
161 {/if}
162 </td>
163 </tr>
164 {:else}
165 {#each filteredConversations as conv (conv.id)}
166 <tr
167 class="cursor-pointer border-b transition-colors hover:bg-muted/50"
168 onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
169 >
170 <td class="p-3">
171 <Checkbox
172 checked={selectedIds.has(conv.id)}
173 onclick={(e) => {
174 e.preventDefault();
175 e.stopPropagation();
176 toggleConversation(conv.id, e.shiftKey);
177 }}
178 />
179 </td>
180
181 <td class="p-3 text-sm">
182 <div class="max-w-[17rem] truncate" title={conv.name || 'Untitled conversation'}>
183 {conv.name || 'Untitled conversation'}
184 </div>
185 </td>
186
187 <td class="p-3 text-sm text-muted-foreground">
188 {messageCountMap.get(conv.id) ?? 0}
189 </td>
190 </tr>
191 {/each}
192 {/if}
193 </tbody>
194 </table>
195 </ScrollArea>
196 </div>
197
198 <div class="flex justify-end gap-2">
199 <Button variant="outline" onclick={handleCancel}>Cancel</Button>
200
201 <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
202 {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
203 </Button>
204 </div>
205</div>