Browse Source

enh: pinned chats support

Timothy J. Baek 10 months ago
parent
commit
05ec71beb9

+ 13 - 6
src/lib/components/chat/Tags.svelte

@@ -8,7 +8,7 @@
 		getTagsById,
 		updateChatById
 	} from '$lib/apis/chats';
-	import { tags as _tags, chats } from '$lib/stores';
+	import { tags as _tags, chats, pinnedChats } from '$lib/stores';
 	import { createEventDispatcher, onMount } from 'svelte';
 
 	const dispatch = createEventDispatcher();
@@ -19,9 +19,11 @@
 	let tags = [];
 
 	const getTags = async () => {
-		return await getTagsById(localStorage.token, chatId).catch(async (error) => {
-			return [];
-		});
+		return (
+			await getTagsById(localStorage.token, chatId).catch(async (error) => {
+				return [];
+			})
+		).filter((tag) => tag.name !== 'pinned');
 	};
 
 	const addTag = async (tagName) => {
@@ -33,6 +35,7 @@
 		});
 
 		_tags.set(await getAllChatTags(localStorage.token));
+		await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
 	};
 
 	const deleteTag = async (tagName) => {
@@ -44,19 +47,23 @@
 		});
 
 		console.log($_tags);
-
 		await _tags.set(await getAllChatTags(localStorage.token));
 
 		console.log($_tags);
 
 		if ($_tags.map((t) => t.name).includes(tagName)) {
-			await chats.set(await getChatListByTagName(localStorage.token, tagName));
+			if (tagName === 'pinned') {
+				await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
+			} else {
+				await chats.set(await getChatListByTagName(localStorage.token, tagName));
+			}
 
 			if ($chats.find((chat) => chat.id === chatId)) {
 				dispatch('close');
 			}
 		} else {
 			await chats.set(await getChatList(localStorage.token));
+			await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
 		}
 	};
 

+ 19 - 0
src/lib/components/icons/Bookmark.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
+	/>
+</svg>

+ 19 - 0
src/lib/components/icons/BookmarkSlash.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="m3 3 1.664 1.664M21 21l-1.5-1.5m-5.485-1.242L12 17.25 4.5 21V8.742m.164-4.078a2.15 2.15 0 0 1 1.743-1.342 48.507 48.507 0 0 1 11.186 0c1.1.128 1.907 1.077 1.907 2.185V19.5M4.664 4.664 19.5 19.5"
+	/>
+</svg>

+ 124 - 0
src/lib/components/icons/ChatMenu.svelte

@@ -0,0 +1,124 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { flyAndScale } from '$lib/utils/transitions';
+	import { getContext } from 'svelte';
+
+	import Dropdown from '$lib/components/common/Dropdown.svelte';
+	import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
+	import Pencil from '$lib/components/icons/Pencil.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Tags from '$lib/components/chat/Tags.svelte';
+	import Share from '$lib/components/icons/Share.svelte';
+	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
+	import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
+	import Star from '$lib/components/icons/Star.svelte';
+
+	const i18n = getContext('i18n');
+
+	export let pinHandler: Function;
+	export let shareHandler: Function;
+	export let cloneChatHandler: Function;
+	export let archiveChatHandler: Function;
+	export let renameHandler: Function;
+	export let deleteHandler: Function;
+	export let onClose: Function;
+
+	export let chatId = '';
+
+	let show = false;
+</script>
+
+<Dropdown
+	bind:show
+	on:change={(e) => {
+		if (e.detail === false) {
+			onClose();
+		}
+	}}
+>
+	<Tooltip content={$i18n.t('More')}>
+		<slot />
+	</Tooltip>
+
+	<div slot="content">
+		<DropdownMenu.Content
+			class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
+			sideOffset={-2}
+			side="bottom"
+			align="start"
+			transition={flyAndScale}
+		>
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					pinHandler();
+				}}
+			>
+				<Star strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Pin')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					renameHandler();
+				}}
+			>
+				<Pencil strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Rename')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					cloneChatHandler();
+				}}
+			>
+				<DocumentDuplicate strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Clone')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					archiveChatHandler();
+				}}
+			>
+				<ArchiveBox strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Archive')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
+				on:click={() => {
+					shareHandler();
+				}}
+			>
+				<Share />
+				<div class="flex items-center">{$i18n.t('Share')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					deleteHandler();
+				}}
+			>
+				<GarbageBin strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Delete')}</div>
+			</DropdownMenu.Item>
+
+			<hr class="border-gray-100 dark:border-gray-800 mt-2.5 mb-1.5" />
+
+			<div class="flex p-1">
+				<Tags
+					{chatId}
+					on:close={() => {
+						show = false;
+						onClose();
+					}}
+				/>
+			</div>
+		</DropdownMenu.Content>
+	</div>
+</Dropdown>

+ 19 - 0
src/lib/components/icons/Star.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"
+	/>
+</svg>

+ 39 - 3
src/lib/components/layout/Sidebar.svelte

@@ -10,7 +10,8 @@
 		tags,
 		showSidebar,
 		mobile,
-		showArchivedChats
+		showArchivedChats,
+		pinnedChats
 	} from '$lib/stores';
 	import { onMount, getContext, tick } from 'svelte';
 
@@ -46,6 +47,7 @@
 
 	let showDeleteConfirm = false;
 	let showDropdown = false;
+
 	let filteredChatList = [];
 
 	$: filteredChatList = $chats.filter((chat) => {
@@ -80,6 +82,8 @@
 		});
 
 		showSidebar.set(window.innerWidth > BREAKPOINT);
+
+		await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
 		await chats.set(await getChatList(localStorage.token));
 
 		let touchstart;
@@ -412,7 +416,7 @@
 				</div>
 			</div>
 
-			{#if $tags.length > 0}
+			{#if $tags.filter((t) => t.name !== 'pinned').length > 0}
 				<div class="px-2.5 mb-2 flex gap-1 flex-wrap">
 					<button
 						class="px-2.5 text-xs font-medium bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800 transition rounded-full"
@@ -422,7 +426,7 @@
 					>
 						{$i18n.t('all')}
 					</button>
-					{#each $tags as tag}
+					{#each $tags.filter((t) => t.name !== 'pinned') as tag}
 						<button
 							class="px-2.5 text-xs font-medium bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800 transition rounded-full"
 							on:click={async () => {
@@ -440,6 +444,38 @@
 				</div>
 			{/if}
 
+			{#if $pinnedChats.length > 0}
+				<div class="pl-2 py-2 flex flex-col space-y-1">
+					<div class="">
+						<div class="w-full pl-2.5 text-xs text-gray-500 dark:text-gray-500 font-medium pb-1.5">
+							{$i18n.t('Pinned')}
+						</div>
+
+						{#each $pinnedChats as chat, idx}
+							<ChatItem
+								{chat}
+								{shiftKey}
+								selected={selectedChatId === chat.id}
+								on:select={() => {
+									selectedChatId = chat.id;
+								}}
+								on:unselect={() => {
+									selectedChatId = null;
+								}}
+								on:delete={(e) => {
+									if ((e?.detail ?? '') === 'shift') {
+										deleteChatHandler(chat.id);
+									} else {
+										deleteChat = chat;
+										showDeleteConfirm = true;
+									}
+								}}
+							/>
+						{/each}
+					</div>
+				</div>
+			{/if}
+
 			<div class="pl-2 my-2 flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
 				{#each filteredChatList as chat, idx}
 					{#if idx === 0 || (idx > 0 && chat.time_range !== filteredChatList[idx - 1].time_range)}

+ 8 - 1
src/lib/components/layout/Sidebar/ChatItem.svelte

@@ -11,9 +11,10 @@
 		cloneChatById,
 		deleteChatById,
 		getChatList,
+		getChatListByTagName,
 		updateChatById
 	} from '$lib/apis/chats';
-	import { chatId, chats, mobile, showSidebar } from '$lib/stores';
+	import { chatId, chats, mobile, pinnedChats, showSidebar } from '$lib/stores';
 
 	import ChatMenu from './ChatMenu.svelte';
 	import ShareChatModal from '$lib/components/chat/ShareChatModal.svelte';
@@ -40,6 +41,7 @@
 				title: _title
 			});
 			await chats.set(await getChatList(localStorage.token));
+			await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
 		}
 	};
 
@@ -52,12 +54,14 @@
 		if (res) {
 			goto(`/c/${res.id}`);
 			await chats.set(await getChatList(localStorage.token));
+			await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
 		}
 	};
 
 	const archiveChatHandler = async (id) => {
 		await archiveChatById(localStorage.token, id);
 		await chats.set(await getChatList(localStorage.token));
+		await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
 	};
 
 	const focusEdit = async (node: HTMLInputElement) => {
@@ -233,6 +237,9 @@
 					onClose={() => {
 						dispatch('unselect');
 					}}
+					on:change={async () => {
+						await pinnedChats.set(await getChatListByTagName(localStorage.token, 'pinned'));
+					}}
 				>
 					<button
 						aria-label="Chat Menu"

+ 43 - 1
src/lib/components/layout/Sidebar/ChatMenu.svelte

@@ -1,7 +1,9 @@
 <script lang="ts">
 	import { DropdownMenu } from 'bits-ui';
 	import { flyAndScale } from '$lib/utils/transitions';
-	import { getContext } from 'svelte';
+	import { getContext, createEventDispatcher } from 'svelte';
+
+	const dispatch = createEventDispatcher();
 
 	import Dropdown from '$lib/components/common/Dropdown.svelte';
 	import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
@@ -11,6 +13,9 @@
 	import Share from '$lib/components/icons/Share.svelte';
 	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
 	import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
+	import Bookmark from '$lib/components/icons/Bookmark.svelte';
+	import BookmarkSlash from '$lib/components/icons/BookmarkSlash.svelte';
+	import { addTagById, deleteTagById, getTagsById } from '$lib/apis/chats';
 
 	const i18n = getContext('i18n');
 
@@ -24,6 +29,28 @@
 	export let chatId = '';
 
 	let show = false;
+	let pinned = false;
+
+	const pinHandler = async () => {
+		if (pinned) {
+			await deleteTagById(localStorage.token, chatId, 'pinned');
+		} else {
+			await addTagById(localStorage.token, chatId, 'pinned');
+		}
+		dispatch('change');
+	};
+
+	const checkPinned = async () => {
+		pinned = (
+			await getTagsById(localStorage.token, chatId).catch(async (error) => {
+				return [];
+			})
+		).find((tag) => tag.name === 'pinned');
+	};
+
+	$: if (show) {
+		checkPinned();
+	}
 </script>
 
 <Dropdown
@@ -46,6 +73,21 @@
 			align="start"
 			transition={flyAndScale}
 		>
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					pinHandler();
+				}}
+			>
+				{#if pinned}
+					<BookmarkSlash strokeWidth="2" />
+					<div class="flex items-center">{$i18n.t('Unpin')}</div>
+				{:else}
+					<Bookmark strokeWidth="2" />
+					<div class="flex items-center">{$i18n.t('Pin')}</div>
+				{/if}
+			</DropdownMenu.Item>
+
 			<DropdownMenu.Item
 				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {

+ 1 - 0
src/lib/stores/index.ts

@@ -22,6 +22,7 @@ export const theme = writable('system');
 export const chatId = writable('');
 
 export const chats = writable([]);
+export const pinnedChats = writable([]);
 export const tags = writable([]);
 
 export const models: Writable<Model[]> = writable([]);