From acc231774d00d8856feefd2e7a3ef4b503a8224e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 12:53:00 +0900 Subject: [PATCH] refac --- src/lib/components/layout/Sidebar.svelte | 174 +++++++++++++++++- .../components/layout/Sidebar/ChatItem.svelte | 70 ++++++- .../layout/Sidebar/SharedFolderItem.svelte | 9 +- 3 files changed, 241 insertions(+), 12 deletions(-) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 738c49722d..e0d84c47ca 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -31,12 +31,14 @@ allLoaded, loadNextChatListPage, refreshChatList, + refreshSidebar, registerFolderRefreshHandler, setAllChatsRead, setChatActive, setChatReadAt } from '$lib/stores/chatList'; - import { onMount, getContext, tick, onDestroy } from 'svelte'; + import { onMount, getContext, setContext, tick, onDestroy } from 'svelte'; + import { writable } from 'svelte/store'; const i18n: any = getContext('i18n'); @@ -49,6 +51,8 @@ updateChatFolderIdById, importChats, deleteAllChats, + archiveChatById, + deleteChatById, getChatListBySearchText, markChatsRead } from '$lib/apis/chats'; @@ -91,6 +95,10 @@ import Dropdown from '../common/Dropdown.svelte'; import DropdownMenu from '../common/DropdownMenu.svelte'; import CheckIcon from '../icons/Check.svelte'; + import ArchiveBoxIcon from '../icons/ArchiveBox.svelte'; + import GarbageBinIcon from '../icons/GarbageBin.svelte'; + import XMarkIcon from '../icons/XMark.svelte'; + import ConfirmDialog from '../common/ConfirmDialog.svelte'; import MoreHorizontalIcon from './Sidebar/icons/MoreHorizontal.svelte'; import MobileSwipePanel from '../common/MobileSwipePanel.svelte'; @@ -99,10 +107,111 @@ let scrollTop = 0; - let navElement; + let navElement: HTMLDivElement; let shiftKey = false; let selectedChatId = null; + const selectedChatIds = writable(new Set()); + const selectionBusy = writable(false); + let selectionAnchor: string | null = null; + const chatSelection = { + ids: selectedChatIds, + busy: selectionBusy, + setAnchor(id: string) { + if (!$selectionBusy) selectionAnchor = id; + }, + clear() { + if ($selectionBusy) return; + selectedChatIds.set(new Set()); + selectionAnchor = null; + }, + select(id: string, shift = false) { + if ($selectionBusy) return; + const visible = [ + ...new Set( + Array.from(navElement?.querySelectorAll('[data-sidebar-chat-id]') ?? []) + .filter( + (element) => + element.getClientRects().length > 0 && + element.dataset.sidebarChatReadonly !== 'true' + ) + .map((element) => element.dataset.sidebarChatId!) + ) + ]; + const index = visible.indexOf(id); + if (index < 0) return; + const next = new Set([...$selectedChatIds].filter((id) => visible.includes(id))); + const remove = next.has(id); + const start = selectionAnchor === null ? -1 : visible.indexOf(selectionAnchor); + if (shift && !remove && start >= 0) { + for (const id of visible.slice(Math.min(start, index), Math.max(start, index) + 1)) { + next.add(id); + } + } else if (remove) { + next.delete(id); + } else { + next.add(id); + } + if (!shift || start < 0) selectionAnchor = id; + selectedChatIds.set(next); + } + }; + let showDeleteSelectedConfirm = false; + let deleteSelectedIds: string[] = []; + $: canDeleteChats = $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true); + setContext('sidebar-chat-selection', chatSelection); + + const applySelectedChatAction = async (action: 'archive' | 'delete', ids: string[]) => { + if ($selectionBusy || ids.length === 0 || (action === 'delete' && !canDeleteChats)) return; + selectionBusy.set(true); + const succeeded = new Set(); + try { + for (const id of ids) { + try { + const result = await (action === 'archive' ? archiveChatById : deleteChatById)( + localStorage.token, + id + ); + if (!result) throw new Error('Chat action failed'); + succeeded.add(id); + } catch (error) { + console.error(`Failed to ${action} chat:`, error); + } + } + selectedChatIds.update( + (selected) => new Set([...selected].filter((id) => !succeeded.has(id))) + ); + if (succeeded.size > 0) { + toast.success( + $i18n.t(action === 'archive' ? '{{count}} chats archived.' : '{{count}} chats deleted.', { + count: succeeded.size + }) + ); + if (succeeded.has($chatId)) { + await goto('/'); + chatId.set(''); + } + await Promise.all([ + refreshSidebar(localStorage.token), + ...(action === 'delete' + ? [getAllTags(localStorage.token).then((value) => tags.set(value))] + : []) + ]); + } + } catch (error) { + toast.error(`${error}`); + } finally { + if (succeeded.size < ids.length) { + toast.error( + $i18n.t('Could not update {{count}} chats. They remain selected so you can try again.', { + count: ids.length - succeeded.size + }) + ); + } + selectionBusy.set(false); + if ($selectedChatIds.size === 0) chatSelection.clear(); + } + }; // Keep the optimistic sidebar highlight in sync with the active chat. Leaving the // chat view (e.g. navigating to an admin page) clears chatId, and programmatic @@ -574,6 +683,10 @@ }; const onKeyDown = (e) => { + if (e.key === 'Escape' && $selectedChatIds.size > 0 && !showDeleteSelectedConfirm) { + e.preventDefault(); + chatSelection.clear(); + } if (e.key === 'Shift') { shiftKey = true; } @@ -841,6 +954,13 @@ const isWindows = /Windows/i.test(navigator.userAgent); + applySelectedChatAction('delete', deleteSelectedIds)} +/> + { @@ -1720,6 +1840,56 @@ @@ -582,9 +613,11 @@ {:else}