blog / src /components /controls /Search.svelte
cacode's picture
Upload 434 files
96dd062 verified
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import { navigateToPage } from "@utils/navigation-utils";
import { onMount } from "svelte";
import Icon from "@/components/common/Icon.svelte";
import type { SearchResult } from "@/global";
import { url as formatUrl, getSearchUrl } from "@/utils/url-utils";
// --- State ---
let keywordDesktop = "";
let keywordMobile = "";
let result: SearchResult[] = [];
let isSearching = false;
let initialized = false;
let debounceTimer: NodeJS.Timeout;
// --- Mocks for Dev Mode ---
const fakeResult: SearchResult[] = [
{
url: formatUrl("/"),
meta: { title: "This Is a Fake Search Result" },
excerpt:
"Because Pagefind cannot work in the <mark>dev</mark> environment.",
},
{
url: formatUrl("/"),
meta: { title: "If You Want to Test the Search" },
excerpt: "Try running <mark>npm build && npm preview</mark> instead.",
},
];
// --- UI Logic ---
const togglePanel = () => {
document
.getElementById("search-panel")
?.classList.toggle("float-panel-closed");
};
const setPanelVisibility = (show: boolean, isDesktop: boolean): void => {
const panel = document.getElementById("search-panel");
if (
!panel ||
(isDesktop && !keywordDesktop) ||
(!isDesktop && !keywordMobile)
)
return;
show
? panel.classList.remove("float-panel-closed")
: panel.classList.add("float-panel-closed");
};
const closeSearchPanel = (): void => {
document.getElementById("search-panel")?.classList.add("float-panel-closed");
keywordDesktop = "";
keywordMobile = "";
result = [];
};
const handleResultClick = (event: Event, url: string): void => {
event.preventDefault();
closeSearchPanel();
navigateToPage(url);
};
// --- Core Search Logic ---
const search = async (keyword: string, isDesktop: boolean): Promise<void> => {
if (!keyword) {
setPanelVisibility(false, isDesktop);
result = [];
return;
}
if (!initialized) return;
isSearching = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
try {
let searchResults: SearchResult[] = [];
if (import.meta.env.PROD && window.pagefind) {
const response = await window.pagefind.search(keyword);
searchResults = await Promise.all(
response.results.map((item) => item.data()),
);
} else if (import.meta.env.DEV) {
searchResults = fakeResult;
}
result = searchResults;
setPanelVisibility(true, isDesktop);
} catch (error) {
console.error("Search error:", error);
result = [];
setPanelVisibility(false, isDesktop);
} finally {
isSearching = false;
}
}, 300); // 300ms debounce
};
// --- Initialization onMount ---
onMount(() => {
const initializePagefind = () => {
initialized = true;
if (keywordDesktop) search(keywordDesktop, true);
if (keywordMobile) search(keywordMobile, false);
};
if (import.meta.env.DEV) {
console.log("Pagefind mock enabled in development mode.");
initializePagefind();
} else {
if (window.pagefind) {
// If script already loaded
initializePagefind();
} else {
// Listen for the event
document.addEventListener("pagefindready", initializePagefind, {
once: true,
});
document.addEventListener("pagefindloaderror", initializePagefind, {
once: true,
});
}
}
});
// --- Reactive Statements ---
$: if (initialized && (keywordDesktop || keywordDesktop === "")) {
search(keywordDesktop, true);
}
$: if (initialized && (keywordMobile || keywordMobile === "")) {
search(keywordMobile, false);
}
</script>
<!-- search bar for desktop view -->
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
bg-black/4 hover:bg-black/6 focus-within:bg-black/6
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search"
class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop}
on:focus={() => search(keywordDesktop, true)}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- toggle btn for phone/tablet view -->
<button on:click={togglePanel} aria-label="Search Panel" id="search-switch"
class="btn-plain scale-animation lg:hidden! rounded-lg w-11 h-11 active:scale-90">
<Icon icon="material-symbols:search" class="text-[1.25rem]"></Icon>
</button>
<!-- search panel -->
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-120
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
<!-- search bar inside panel for phone/tablet -->
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
bg-black/4 hover:bg-black/6 focus-within:bg-black/6
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search"
class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder={i18n(I18nKey.search)} bind:value={keywordMobile}
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- search results -->
{#if isSearching}
<div class="transition first-of-type:mt-2 lg:first-of-type:mt-0 block rounded-xl text-lg px-3 py-2 text-50">
{i18n(I18nKey.searchLoading)}
</div>
{:else if result.length > 0}
{#each result.slice(0, 5) as item}
<a href={item.url}
on:click={(e) => handleResultClick(e, item.url)}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
rounded-xl text-lg px-3 py-2 hover:bg-(--btn-plain-bg-hover) active:bg-(--btn-plain-bg-active)">
<div class="transition text-90 inline-flex font-bold group-hover:text-(--primary)">
{@html item.meta.title}
<Icon icon="fa7-solid:chevron-right"
class="transition text-[0.75rem] translate-x-1 my-auto text-(--primary)"></Icon>
</div>
{#if item.excerpt.includes('<mark>')}
<div class="transition text-sm text-50" style="display: flex; align-items: flex-start; margin-top: 0.1rem">
<span style="display: inline-block; background-color: var(--btn-plain-bg-hover); color: var(--primary); padding: 0.1em 0.4em; border-radius: 5px; font-size: 0.75em; font-weight: 600; margin-right: 0.5em; shrink: 0;">
{i18n(I18nKey.searchSummary)}
</span>
<div>
{@html item.excerpt}
</div>
</div>
{/if}
{#if item.content && item.content.includes('<mark>')}
<div class="transition text-sm text-30" style="display: flex; align-items: flex-start; margin-top: 0.1rem">
<span style="display: inline-block; background-color: var(--btn-plain-bg-active); color: var(--primary); padding: 0.1em 0.4em; border-radius: 5px; font-size: 0.75em; font-weight: 600; margin-right: 0.5em; shrink: 0;">
{i18n(I18nKey.searchContent)}
</span>
<div>
{@html item.content}
</div>
</div>
{/if}
</a>
{/each}
{#if result.length > 5}
<a href={getSearchUrl(keywordDesktop || keywordMobile)}
on:click={(e) => handleResultClick(e, getSearchUrl(keywordDesktop || keywordMobile))}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block rounded-xl text-lg px-3 py-2 hover:bg-(--btn-plain-bg-hover) active:bg-(--btn-plain-bg-active) text-(--primary) font-bold text-center">
<span class="inline-flex items-center">
{i18n(I18nKey.searchViewMore).replace('{count}', (result.length - 5).toString())}
<Icon icon="fa7-solid:arrow-right" class="transition text-[0.75rem] ml-1"></Icon>
</span>
</a>
{/if}
{:else if result.length === 0}
<div class="transition first-of-type:mt-2 lg:first-of-type:mt-0 block rounded-xl text-lg px-3 py-2 text-50">
{i18n(I18nKey.searchNoResults)}
</div>
{:else if keywordDesktop || keywordMobile}
<div class="transition first-of-type:mt-2 lg:first-of-type:mt-0 block rounded-xl text-lg px-3 py-2 text-50">
{i18n(I18nKey.searchTypeSomething)}
</div>
{/if}
</div>
<style>
input:focus {
outline: 0;
}
.search-panel {
max-height: calc(100vh - 100px);
overflow-y: auto;
}
</style>