src/lib/components/chat/Search.svelte (175 lines of code) (raw):
<script lang="ts" module>
export function toggleSearch() {
searchOpen = !searchOpen;
}
let searchOpen: boolean = $state(false);
</script>
<script lang="ts">
import { debounce } from "$lib/utils/debounce";
import NavConversationItem from "../NavConversationItem.svelte";
import { titles } from "../NavMenu.svelte";
import { beforeNavigate } from "$app/navigation";
import CarbonClose from "~icons/carbon/close";
import { fly } from "svelte/transition";
import InfiniteScroll from "../InfiniteScroll.svelte";
import { handleResponse, useAPIClient, type Success } from "$lib/APIClient";
const client = useAPIClient();
let searchContainer: HTMLDivElement | undefined = $state(undefined);
let inputElement: HTMLInputElement | undefined = $state(undefined);
let searchInput: string = $state("");
let debouncedInput: string = $state("");
let hasMore = $state(true);
let pending: boolean = $state(false);
let conversations: NonNullable<Success<typeof client.conversations.search.get>> = $state([]);
let page: number = $state(0);
const dateRanges = [
new Date().setDate(new Date().getDate() - 1),
new Date().setDate(new Date().getDate() - 7),
new Date().setMonth(new Date().getMonth() - 1),
];
let groupedConversations = $derived({
today: conversations.filter(({ updatedAt }) => updatedAt.getTime() > dateRanges[0]),
week: conversations.filter(
({ updatedAt }) => updatedAt.getTime() > dateRanges[1] && updatedAt.getTime() < dateRanges[0]
),
month: conversations.filter(
({ updatedAt }) => updatedAt.getTime() > dateRanges[2] && updatedAt.getTime() < dateRanges[1]
),
older: conversations.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),
});
const update = debounce(async (v: string) => {
if (debouncedInput !== v) {
conversations = [];
page = 0;
hasMore = true;
}
debouncedInput = v;
pending = true;
try {
await handleVisible(v);
} finally {
pending = false;
}
}, 300);
const handleBackdropClick = (event: MouseEvent) => {
if (!searchOpen || !searchContainer) return;
const target = event.target;
if (!(target instanceof Node) || !searchContainer.contains(target)) {
searchOpen = false;
}
};
async function handleVisible(v: string) {
const newConvs = await client.conversations.search
.get({
query: {
q: v,
p: page++,
},
})
.then(handleResponse)
.catch(() => []);
if (newConvs.length === 0) {
hasMore = false;
}
conversations = [...conversations, ...newConvs];
}
$effect(() => update(searchInput));
function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
if (!searchOpen) {
searchOpen = true;
}
event.preventDefault();
event.stopPropagation();
}
if (searchOpen && event.key === "Escape") {
if (searchOpen) {
searchOpen = false;
}
event.preventDefault();
}
}
beforeNavigate(() => {
searchOpen = false;
searchInput = "";
});
$effect(() => {
if (searchOpen) {
inputElement?.focus();
}
});
$effect(() => {
if (!searchOpen) {
searchInput = "";
debouncedInput = ""; // reset debouncedInput on search bar close
}
});
</script>
<svelte:window onkeydown={handleKeydown} onmousedown={handleBackdropClick} />
{#if searchOpen}
<div
bind:this={searchContainer}
class="fixed bottom-0 left-[5%] right-[5%] top-[10%] z-50
m-4 mx-auto h-fit max-w-2xl
overflow-hidden rounded-xl
border border-gray-500/50 bg-gray-200 text-gray-800
shadow-[0_10px_40px_rgba(100,100,100,0.2)]
dark:bg-gray-800
dark:text-gray-200 dark:shadow-[0_10px_40px_rgba(255,255,255,0.1)] lg:top-[20%]"
in:fly={{ y: 100 }}
>
<button
class="absolute right-1 top-2.5 rounded-full p-1 hover:bg-gray-500/50"
onclick={toggleSearch}
>
<CarbonClose class="text-lg text-gray-400/80" />
</button>
<input
bind:value={searchInput}
bind:this={inputElement}
type="text"
name="searchbar"
placeholder="Search for chats..."
autocomplete="off"
class={{
"h-12 w-full p-4 text-lg dark:bg-gray-800 dark:text-gray-200": true,
"border-b border-b-gray-500/50": searchInput && searchInput.length >= 3,
}}
/>
<div class="scrollbar-custom max-h-[40dvh] overflow-y-scroll">
{#if debouncedInput && debouncedInput.length >= 3}
{#if pending}
{#each Array(5) as _}
<div
class="m-2 h-6 w-full animate-pulse gap-5 rounded bg-gray-300 first:mt-4 dark:bg-gray-700"
></div>
{/each}
{:else if conversations.length === 0}
<p class="bg-gray-200 p-2 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
No conversations found matching that query
</p>
{:else}
{#each Object.entries(groupedConversations) as [group, convs]}
{#if convs.length}
<h4 class="mb-1.5 mt-4 pl-1.5 text-sm text-gray-700 dark:text-gray-300">
{titles[group]}
</h4>
{#each convs as conv}
<NavConversationItem
{conv}
readOnly={true}
showDescription={true}
description={conv.content}
searchInput={conv.matchedText}
/>
{/each}
{/if}
{/each}
{#if hasMore}
<InfiniteScroll on:visible={() => handleVisible(searchInput)} />
{/if}
{/if}
{/if}
</div>
</div>
{/if}