| 
 | 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 | +	let lastClickedId = $state<string | null>(null);  | 
 | 31 | +
  | 
 | 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 | +	let allSelected = $derived(  | 
 | 40 | +		filteredConversations.length > 0 &&  | 
 | 41 | +			filteredConversations.every((conv) => selectedIds.has(conv.id))  | 
 | 42 | +	);  | 
 | 43 | +
  | 
 | 44 | +	let someSelected = $derived(  | 
 | 45 | +		filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected  | 
 | 46 | +	);  | 
 | 47 | +
  | 
 | 48 | +	function toggleConversation(id: string, shiftKey: boolean = false) {  | 
 | 49 | +		const newSet = new SvelteSet(selectedIds);  | 
 | 50 | +
  | 
 | 51 | +		if (shiftKey && lastClickedId !== null) {  | 
 | 52 | +			const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);  | 
 | 53 | +			const currentIndex = filteredConversations.findIndex((c) => c.id === id);  | 
 | 54 | +
  | 
 | 55 | +			if (lastIndex !== -1 && currentIndex !== -1) {  | 
 | 56 | +				const start = Math.min(lastIndex, currentIndex);  | 
 | 57 | +				const end = Math.max(lastIndex, currentIndex);  | 
 | 58 | +
  | 
 | 59 | +				const shouldSelect = !newSet.has(id);  | 
 | 60 | +
  | 
 | 61 | +				for (let i = start; i <= end; i++) {  | 
 | 62 | +					if (shouldSelect) {  | 
 | 63 | +						newSet.add(filteredConversations[i].id);  | 
 | 64 | +					} else {  | 
 | 65 | +						newSet.delete(filteredConversations[i].id);  | 
 | 66 | +					}  | 
 | 67 | +				}  | 
 | 68 | +
  | 
 | 69 | +				selectedIds = newSet;  | 
 | 70 | +				return;  | 
 | 71 | +			}  | 
 | 72 | +		}  | 
 | 73 | +
  | 
 | 74 | +		if (newSet.has(id)) {  | 
 | 75 | +			newSet.delete(id);  | 
 | 76 | +		} else {  | 
 | 77 | +			newSet.add(id);  | 
 | 78 | +		}  | 
 | 79 | +
  | 
 | 80 | +		selectedIds = newSet;  | 
 | 81 | +		lastClickedId = id;  | 
 | 82 | +	}  | 
 | 83 | +
  | 
 | 84 | +	function toggleAll() {  | 
 | 85 | +		if (allSelected) {  | 
 | 86 | +			const newSet = new SvelteSet(selectedIds);  | 
 | 87 | +
  | 
 | 88 | +			filteredConversations.forEach((conv) => newSet.delete(conv.id));  | 
 | 89 | +			selectedIds = newSet;  | 
 | 90 | +		} else {  | 
 | 91 | +			const newSet = new SvelteSet(selectedIds);  | 
 | 92 | +
  | 
 | 93 | +			filteredConversations.forEach((conv) => newSet.add(conv.id));  | 
 | 94 | +			selectedIds = newSet;  | 
 | 95 | +		}  | 
 | 96 | +	}  | 
 | 97 | +
  | 
 | 98 | +	function handleConfirm() {  | 
 | 99 | +		const selected = conversations.filter((conv) => selectedIds.has(conv.id));  | 
 | 100 | +		onConfirm(selected);  | 
 | 101 | +	}  | 
 | 102 | +
  | 
 | 103 | +	function handleCancel() {  | 
 | 104 | +		selectedIds = new SvelteSet(conversations.map((c) => c.id));  | 
 | 105 | +		searchQuery = '';  | 
 | 106 | +		lastClickedId = null;  | 
 | 107 | +
  | 
 | 108 | +		onCancel();  | 
 | 109 | +	}  | 
 | 110 | +
  | 
 | 111 | +	let previousOpen = $state(false);  | 
 | 112 | +
  | 
 | 113 | +	$effect(() => {  | 
 | 114 | +		if (open && !previousOpen) {  | 
 | 115 | +			selectedIds = new SvelteSet(conversations.map((c) => c.id));  | 
 | 116 | +			searchQuery = '';  | 
 | 117 | +			lastClickedId = null;  | 
 | 118 | +		} else if (!open && previousOpen) {  | 
 | 119 | +			onCancel();  | 
 | 120 | +		}  | 
 | 121 | +
  | 
 | 122 | +		previousOpen = open;  | 
 | 123 | +	});  | 
 | 124 | +</script>  | 
 | 125 | + | 
 | 126 | +<Dialog.Root bind:open>  | 
 | 127 | +	<Dialog.Portal>  | 
 | 128 | +		<Dialog.Overlay class="z-[1000000]" />  | 
 | 129 | + | 
 | 130 | +		<Dialog.Content class="z-[1000001] max-w-2xl">  | 
 | 131 | +			<Dialog.Header>  | 
 | 132 | +				<Dialog.Title>  | 
 | 133 | +					Select Conversations to {mode === 'export' ? 'Export' : 'Import'}  | 
 | 134 | +				</Dialog.Title>  | 
 | 135 | + | 
 | 136 | +				<Dialog.Description>  | 
 | 137 | +					{#if mode === 'export'}  | 
 | 138 | +						Choose which conversations you want to export. Selected conversations will be downloaded  | 
 | 139 | +						as a JSON file.  | 
 | 140 | +					{:else}  | 
 | 141 | +						Choose which conversations you want to import. Selected conversations will be merged  | 
 | 142 | +						with your existing conversations.  | 
 | 143 | +					{/if}  | 
 | 144 | +				</Dialog.Description>  | 
 | 145 | +			</Dialog.Header>  | 
 | 146 | + | 
 | 147 | +			<div class="space-y-4">  | 
 | 148 | +				<div class="relative">  | 
 | 149 | +					<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />  | 
 | 150 | + | 
 | 151 | +					<Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />  | 
 | 152 | + | 
 | 153 | +					{#if searchQuery}  | 
 | 154 | +						<button  | 
 | 155 | +							class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"  | 
 | 156 | +							onclick={() => (searchQuery = '')}  | 
 | 157 | +							type="button"  | 
 | 158 | +						>  | 
 | 159 | +							<X class="h-4 w-4" />  | 
 | 160 | +						</button>  | 
 | 161 | +					{/if}  | 
 | 162 | +				</div>  | 
 | 163 | + | 
 | 164 | +				<div class="flex items-center justify-between text-sm text-muted-foreground">  | 
 | 165 | +					<span>  | 
 | 166 | +						{selectedIds.size} of {conversations.length} selected  | 
 | 167 | +						{#if searchQuery}  | 
 | 168 | +							({filteredConversations.length} shown)  | 
 | 169 | +						{/if}  | 
 | 170 | +					</span>  | 
 | 171 | +				</div>  | 
 | 172 | + | 
 | 173 | +				<div class="overflow-hidden rounded-md border">  | 
 | 174 | +					<ScrollArea class="h-[400px]">  | 
 | 175 | +						<table class="w-full">  | 
 | 176 | +							<thead class="sticky top-0 z-10 bg-muted">  | 
 | 177 | +								<tr class="border-b">  | 
 | 178 | +									<th class="w-12 p-3 text-left">  | 
 | 179 | +										<Checkbox  | 
 | 180 | +											checked={allSelected}  | 
 | 181 | +											indeterminate={someSelected}  | 
 | 182 | +											onCheckedChange={toggleAll}  | 
 | 183 | +										/>  | 
 | 184 | +									</th>  | 
 | 185 | + | 
 | 186 | +									<th class="p-3 text-left text-sm font-medium">Conversation Name</th>  | 
 | 187 | + | 
 | 188 | +									<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>  | 
 | 189 | +								</tr>  | 
 | 190 | +							</thead>  | 
 | 191 | +							<tbody>  | 
 | 192 | +								{#if filteredConversations.length === 0}  | 
 | 193 | +									<tr>  | 
 | 194 | +										<td colspan="3" class="p-8 text-center text-sm text-muted-foreground">  | 
 | 195 | +											{#if searchQuery}  | 
 | 196 | +												No conversations found matching "{searchQuery}"  | 
 | 197 | +											{:else}  | 
 | 198 | +												No conversations available  | 
 | 199 | +											{/if}  | 
 | 200 | +										</td>  | 
 | 201 | +									</tr>  | 
 | 202 | +								{:else}  | 
 | 203 | +									{#each filteredConversations as conv (conv.id)}  | 
 | 204 | +										<tr  | 
 | 205 | +											class="cursor-pointer border-b transition-colors hover:bg-muted/50"  | 
 | 206 | +											onclick={(e) => toggleConversation(conv.id, e.shiftKey)}  | 
 | 207 | +										>  | 
 | 208 | +											<td class="p-3">  | 
 | 209 | +												<Checkbox  | 
 | 210 | +													checked={selectedIds.has(conv.id)}  | 
 | 211 | +													onclick={(e) => {  | 
 | 212 | +														e.preventDefault();  | 
 | 213 | +														e.stopPropagation();  | 
 | 214 | +														toggleConversation(conv.id, e.shiftKey);  | 
 | 215 | +													}}  | 
 | 216 | +												/>  | 
 | 217 | +											</td>  | 
 | 218 | + | 
 | 219 | +											<td class="p-3 text-sm">  | 
 | 220 | +												<div  | 
 | 221 | +													class="max-w-[17rem] truncate"  | 
 | 222 | +													title={conv.name || 'Untitled conversation'}  | 
 | 223 | +												>  | 
 | 224 | +													{conv.name || 'Untitled conversation'}  | 
 | 225 | +												</div>  | 
 | 226 | +											</td>  | 
 | 227 | + | 
 | 228 | +											<td class="p-3 text-sm text-muted-foreground">  | 
 | 229 | +												{messageCountMap.get(conv.id) ?? 0}  | 
 | 230 | +											</td>  | 
 | 231 | +										</tr>  | 
 | 232 | +									{/each}  | 
 | 233 | +								{/if}  | 
 | 234 | +							</tbody>  | 
 | 235 | +						</table>  | 
 | 236 | +					</ScrollArea>  | 
 | 237 | +				</div>  | 
 | 238 | +			</div>  | 
 | 239 | + | 
 | 240 | +			<Dialog.Footer>  | 
 | 241 | +				<Button variant="outline" onclick={handleCancel}>Cancel</Button>  | 
 | 242 | + | 
 | 243 | +				<Button onclick={handleConfirm} disabled={selectedIds.size === 0}>  | 
 | 244 | +					{mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})  | 
 | 245 | +				</Button>  | 
 | 246 | +			</Dialog.Footer>  | 
 | 247 | +		</Dialog.Content>  | 
 | 248 | +	</Dialog.Portal>  | 
 | 249 | +</Dialog.Root>  | 
0 commit comments