mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-28 06:22:33 +00:00
refac
This commit is contained in:
parent
93779e2451
commit
acc231774d
3 changed files with 241 additions and 12 deletions
|
|
@ -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<string>());
|
||||
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<HTMLElement>('[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<string>();
|
||||
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);
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteSelectedConfirm}
|
||||
title={$i18n.t('Delete {{count}} chats?', { count: deleteSelectedIds.length })}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
on:confirm={() => applySelectedChatAction('delete', deleteSelectedIds)}
|
||||
/>
|
||||
|
||||
<ChannelModal
|
||||
bind:show={showCreateChannel}
|
||||
onSubmit={async (payload: any) => {
|
||||
|
|
@ -1720,6 +1840,56 @@
|
|||
</div>
|
||||
|
||||
<div class="px-1 pt-1 pb-1.5 sticky bottom-0 z-10 -mt-2 sidebar">
|
||||
{#if $selectedChatIds.size > 0}
|
||||
<div
|
||||
class="flex items-center justify-between gap-1 px-2 py-0.5 text-[0.6875rem] text-gray-500 dark:text-gray-400"
|
||||
aria-busy={$selectionBusy}
|
||||
>
|
||||
<span role="status"
|
||||
>{$i18n.t('{{count}} selected', { count: $selectedChatIds.size })}</span
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<Tooltip content={$i18n.t('Archive')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Archive selected chats')}
|
||||
disabled={$selectionBusy}
|
||||
class="flex size-6 items-center justify-center rounded-md hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
|
||||
on:click={() => applySelectedChatAction('archive', [...$selectedChatIds])}
|
||||
>
|
||||
<ArchiveBoxIcon className="size-3.5" strokeWidth="1.7" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{#if canDeleteChats}
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Delete selected chats')}
|
||||
disabled={$selectionBusy}
|
||||
class="flex size-6 items-center justify-center rounded-md hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
|
||||
on:click={() => {
|
||||
deleteSelectedIds = [...$selectedChatIds];
|
||||
showDeleteSelectedConfirm = true;
|
||||
}}
|
||||
>
|
||||
<GarbageBinIcon className="size-3.5" strokeWidth="1.7" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<Tooltip content={$i18n.t('Clear selection')}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={$selectionBusy}
|
||||
class="flex size-6 items-center justify-center rounded-md hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
|
||||
aria-label={$i18n.t('Clear selection')}
|
||||
on:click={chatSelection.clear}
|
||||
>
|
||||
<XMarkIcon className="size-3.5" strokeWidth="1.7" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class=" sidebar-bg-gradient-to-t bg-linear-to-t from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mt-6"
|
||||
></div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
import { goto, invalidate, invalidateAll } from '$app/navigation';
|
||||
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
import { readable, type Readable } from 'svelte/store';
|
||||
import CheckIcon from '$lib/components/icons/Check.svelte';
|
||||
import {
|
||||
archiveChatById,
|
||||
cloneChatById,
|
||||
|
|
@ -63,6 +65,20 @@
|
|||
const i18n = getContext('i18n');
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const chatSelection = getContext<
|
||||
| {
|
||||
ids: Readable<Set<string>>;
|
||||
busy: Readable<boolean>;
|
||||
setAnchor: (id: string) => void;
|
||||
select: (id: string, shift?: boolean) => void;
|
||||
}
|
||||
| undefined
|
||||
>('sidebar-chat-selection');
|
||||
const selectedChatIds = chatSelection?.ids ?? readable(new Set<string>());
|
||||
const selectionBusy = chatSelection?.busy ?? readable(false);
|
||||
$: selecting = $selectedChatIds.size > 0;
|
||||
$: multiSelected = $selectedChatIds.has(id);
|
||||
$: if (selecting) openPreview = false;
|
||||
|
||||
export let className = '';
|
||||
|
||||
|
|
@ -141,13 +157,19 @@
|
|||
!active &&
|
||||
(effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt));
|
||||
$: showInlineActions =
|
||||
id === $chatId || confirmEdit || mouseOver || focusWithin || menuOpen || selected;
|
||||
id === $chatId ||
|
||||
confirmEdit ||
|
||||
mouseOver ||
|
||||
focusWithin ||
|
||||
menuOpen ||
|
||||
selected ||
|
||||
multiSelected;
|
||||
$: chatItemClass = ` w-full flex justify-between rounded-xl px-2 py-1.5 ${
|
||||
id === $chatId || confirmEdit
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: selected
|
||||
: selected || multiSelected
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
|
|
@ -158,6 +180,15 @@
|
|||
|
||||
const selectChatHandler = (event?: MouseEvent) => {
|
||||
openPreview = false;
|
||||
if (event && chatSelection && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
if (event.shiftKey || selecting) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!readonly) chatSelection.select(id, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
chatSelection.setAnchor(id);
|
||||
}
|
||||
dispatch('select');
|
||||
|
||||
if ($selectedFolder) {
|
||||
|
|
@ -176,7 +207,7 @@
|
|||
};
|
||||
|
||||
const renameChatFromDoubleClick = async (e: MouseEvent) => {
|
||||
if (readonly) return;
|
||||
if (readonly || selecting || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -538,7 +569,7 @@
|
|||
dir="auto"
|
||||
class="text-left self-center overflow-hidden w-full h-5 truncate {unread
|
||||
? 'font-normal text-gray-800 dark:text-gray-200'
|
||||
: ''} {($mobile || showInlineActions) && !readonly ? 'pr-12' : ''}"
|
||||
: ''} {($mobile || showInlineActions) && !readonly && !selecting ? 'pr-12' : ''}"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -582,9 +613,11 @@
|
|||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
id="sidebar-chat-group"
|
||||
data-sidebar-chat-id={id}
|
||||
data-sidebar-chat-readonly={readonly}
|
||||
bind:this={itemElement}
|
||||
class=" w-full {className} relative group"
|
||||
draggable={!confirmEdit && !readonly}
|
||||
draggable={!confirmEdit && !readonly && !selecting}
|
||||
on:mouseenter={() => {
|
||||
mouseOver = true;
|
||||
}}
|
||||
|
|
@ -605,7 +638,7 @@
|
|||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: selected
|
||||
: selected || multiSelected
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
|
|
@ -639,6 +672,21 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div id="sidebar-chat-item" class={chatItemClass}>
|
||||
{#if selecting}
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={multiSelected}
|
||||
disabled={readonly || $selectionBusy}
|
||||
aria-label={$i18n.t('Select {{name}}', { name: title })}
|
||||
class="mr-2 flex size-3.5 shrink-0 self-center items-center justify-center rounded border disabled:opacity-40 {multiSelected
|
||||
? 'border-gray-900 bg-gray-900 text-white dark:border-gray-200 dark:bg-gray-200 dark:text-gray-900'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
on:click|stopPropagation={(event) => chatSelection?.select(id, event.shiftKey)}
|
||||
>
|
||||
{#if multiSelected}<CheckIcon className="size-2.5" strokeWidth="2" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if ownerUserId || participants.length > 1}
|
||||
<div class="flex items-center self-center shrink-0 mr-2">
|
||||
<ChatParticipants
|
||||
|
|
@ -664,7 +712,11 @@
|
|||
<LinkPreview.Root
|
||||
openDelay={300}
|
||||
closeDelay={0}
|
||||
disabled={confirmEdit || dragged || !($settings?.chatHoverPreview ?? true)}
|
||||
disabled={confirmEdit ||
|
||||
dragged ||
|
||||
selecting ||
|
||||
shiftKey ||
|
||||
!($settings?.chatHoverPreview ?? true)}
|
||||
bind:open={openPreview}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
|
|
@ -696,7 +748,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !readonly}
|
||||
{#if !readonly && !selecting}
|
||||
<div
|
||||
id="sidebar-chat-item-menu"
|
||||
class="{$mobile
|
||||
|
|
@ -724,7 +776,7 @@
|
|||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else if shiftKey && mouseOver}
|
||||
{:else if shiftKey && mouseOver && !chatSelection}
|
||||
<div class=" flex items-center self-center space-x-1.5">
|
||||
<Tooltip content={$i18n.t('Archive')} className="flex items-center">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { getContext, createEventDispatcher } from 'svelte';
|
||||
import { getContext, createEventDispatcher, onMount } from 'svelte';
|
||||
import { registerFolderRefreshHandler } from '$lib/stores/chatList';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
const i18n: Writable<any> = getContext('i18n');
|
||||
|
|
@ -28,6 +29,12 @@
|
|||
let page = 1;
|
||||
let hasMoreChats = false;
|
||||
|
||||
onMount(() =>
|
||||
registerFolderRefreshHandler((folderId) => {
|
||||
if (loaded && (folderId === undefined || folderId === folder.id)) return loadChats();
|
||||
})
|
||||
);
|
||||
|
||||
$: children = allSharedFolders.filter((f) => f.parent_id === folder.id);
|
||||
$: permission = folder.permission || 'read';
|
||||
$: isWritable = permission === 'write';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue