feat(web): supprt typesense style facet filter

This commit is contained in:
arkohut 2024-08-21 01:26:49 +08:00
parent 9267fdc018
commit 7b2ab563cc
2 changed files with 240 additions and 78 deletions

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
export let facet: { field_name: string; counts: { value: string; count: number }[] };
export let selectedItems: Record<string, boolean>;
export let onItemChange: (item: string, checked: boolean) => void;
$: title = facet.field_name === 'tags' ? 'Tags' : 'Created Date';
</script>
<div class="mb-4">
<h3 class="text-lg font-semibold mb-2">{title}</h3>
{#each facet.counts as item}
<div class="mb-2 items-top flex space-x-2">
<Checkbox
id={`${facet.field_name}-${item.value}`}
checked={selectedItems[item.value] || false}
onCheckedChange={(checked) => onItemChange(item.value, checked)}
/>
<Label for={`${facet.field_name}-${item.value}`} class="flex items-center text-sm">
{item.value} ({item.count})
</Label>
</div>
{/each}
</div>

View File

@ -1,9 +1,11 @@
<script>
<script lang="ts">
import { onMount } from 'svelte';
import Figure from '$lib/Figure.svelte';
import TimeFilter from '$lib/components/TimeFilter.svelte';
import LibraryFilter from '$lib/components/LibraryFilter.svelte';
import { Input } from '$lib/components/ui/input';
import { PUBLIC_API_ENDPOINT } from '$env/static/public';
import FacetFilter from '$lib/components/FacetFilter.svelte';
let searchString = '';
/**
@ -11,23 +13,52 @@
*/
let searchResults = [];
let isLoading = false;
let debounceTimer;
let debounceTimer: ReturnType<typeof setTimeout>;
let showModal = false;
let selectedImage = 0;
let startTimestamp = -1;
let endTimestamp = -1;
let selectedLibraries = [];
let selectedLibraries: number[] = [];
let searchResult: SearchResult | null = null;
interface FacetCount {
value: string;
count: number;
}
interface Facet {
field_name: string;
counts: FacetCount[];
}
interface SearchResult {
hits: any[];
facet_counts: Facet[];
found: number;
out_of: number;
search_time_ms: number;
}
let selectedTags: Record<string, boolean> = {};
let selectedDates: Record<string, boolean> = {};
const debounceDelay = 300;
const apiEndpoint =
typeof PUBLIC_API_ENDPOINT !== 'undefined' ? PUBLIC_API_ENDPOINT : window.location.origin;
/**
* @param {string} query
*/
async function searchItems(query, start, end, selectedLibraries) {
let facetCounts: Facet[] | null = null;
async function searchItems(
query: string,
start: number,
end: number,
selectedLibraries: number[],
selectedTags: string[],
selectedDates: string[],
updateFacets: boolean = false
) {
isLoading = true;
try {
@ -41,32 +72,129 @@
if (selectedLibraries.length > 0) {
url += `&library_ids=${selectedLibraries.join(',')}`;
}
if (selectedTags.length > 0) {
url += `&tags=${selectedTags.join(',')}`;
}
if (selectedDates.length > 0) {
url += `&created_dates=${selectedDates.join(',')}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
searchResults = await response.json();
console.log(searchResults);
const result = await response.json();
if (updateFacets) {
facetCounts = result.facet_counts;
selectedTags = Object.fromEntries(
result.facet_counts
.find((f) => f.field_name === 'tags')
?.counts.map((t) => [t.value, false]) || []
);
selectedDates = Object.fromEntries(
result.facet_counts
.find((f) => f.field_name === 'created_date')
?.counts.map((d) => [d.value, false]) || []
);
}
searchResult = {
...result,
facet_counts: updateFacets ? result.facet_counts : facetCounts
// Add other properties as needed
};
console.log(searchResult);
} catch (error) {
console.error('Search error:', error);
} finally {
isLoading = false;
}
}
function handleSearchStringChange() {
if (searchString.trim()) {
debounceSearch(
searchString,
startTimestamp,
endTimestamp,
selectedLibraries,
Object.keys(selectedTags).filter((tag) => selectedTags[tag]),
Object.keys(selectedDates).filter((date) => selectedDates[date]),
true // 更新 facets
);
} else {
searchResults = [];
searchResult = null;
facetCounts = null;
}
}
function handleFiltersChange() {
if (searchString.trim()) {
debounceSearch(
searchString,
startTimestamp,
endTimestamp,
selectedLibraries,
Object.keys(selectedTags).filter((tag) => selectedTags[tag]),
Object.keys(selectedDates).filter((date) => selectedDates[date]),
false // 不更新 facets
);
}
}
$: {
if (searchString.trim()) {
handleSearchStringChange();
} else {
searchResults = [];
searchResult = null;
facetCounts = null;
}
}
$: {
if (startTimestamp !== -1 || endTimestamp !== -1 || selectedLibraries.length > 0) {
handleFiltersChange();
}
}
function handleTagChange(tag: string, checked: boolean) {
selectedTags[tag] = checked;
handleFiltersChange();
}
function handleDateChange(date: string, checked: boolean) {
selectedDates[date] = checked;
handleFiltersChange();
}
/**
* @param {string} query
* @param {number} start
* @param {number} end
* @param {number[]} selectedLibraries
* @param {string[]} selectedTags
* @param {string[]} selectedDates
* @param {boolean} updateFacets
*/
function debounceSearch(query, start, end, selectedLibraries) {
function debounceSearch(
query: string,
start: number,
end: number,
selectedLibraries: number[],
selectedTags: string[],
selectedDates: string[],
updateFacets: boolean = true
) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
searchItems(query, start, end, selectedLibraries);
searchItems(query, start, end, selectedLibraries, selectedTags, selectedDates, updateFacets);
}, debounceDelay);
}
/**
* @param {string} path
*/
function filename(path) {
function filename(path: string): string {
let splits = path.split('/');
return splits[splits.length - 1];
}
@ -74,8 +202,7 @@
/**
* @param {number} index
*/
function openModal(index) {
// @ts-ignore
function openModal(index: number) {
showModal = true;
selectedImage = index;
disableScroll();
@ -89,14 +216,14 @@
/**
* @param {{ key: string; }} event
*/
function handleKeydown(event) {
if (showModal) {
function handleKeydown(event: KeyboardEvent) {
if (showModal && searchResult) {
if (event.key === 'Escape') {
closeModal();
} else if (event.key === 'ArrowRight') {
selectedImage = (selectedImage + 1) % searchResults.length;
selectedImage = (selectedImage + 1) % searchResult.hits.length;
} else if (event.key === 'ArrowLeft') {
selectedImage = (selectedImage - 1 + searchResults.length) % searchResults.length;
selectedImage = (selectedImage - 1 + searchResult.hits.length) % searchResult.hits.length;
}
}
}
@ -108,24 +235,6 @@
const enableScroll = () => {
document.body.style.overflow = '';
};
// $: if (searchString.trim()) {
// debounceSearch(searchString, startTimestamp, endTimestamp, selectedLibraries);
// } else {
// searchResults = [];
// }
// $: if ((startTimestamp !== -1 || endTimestamp !== -1) && searchString.trim()) {
// debounceSearch(searchString, startTimestamp, endTimestamp, selectedLibraries);
// }
$: {
if (searchString.trim()) {
debounceSearch(searchString, startTimestamp, endTimestamp, selectedLibraries);
} else {
searchResults = [];
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
@ -137,58 +246,85 @@
bind:value={searchString}
placeholder="Type to search..."
/>
<div class="flex">
<div class="flex space-x-2">
<LibraryFilter bind:selectedLibraryIds={selectedLibraries} />
<TimeFilter bind:start={startTimestamp} bind:end={endTimestamp} />
</div>
</div>
<div class="container mx-auto">
{#if isLoading}
<p>Loading...</p>
{:else if searchString}
<div class="grid grid-cols-4 gap-4">
{#each searchResults as item, index}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300 ease-in-out"
on:click={() => openModal(index)}
>
<figure class="px-5 pt-5">
<img
class="w-full h-48 object-cover"
src={`${apiEndpoint}/files/${item.filepath}`}
alt=""
/>
</figure>
<div class="p-4">
<h2 class="text-lg font-bold mb-2">{filename(item.filepath)}</h2>
<p class="text-gray-700 line-clamp-5">{''}</p>
</div>
</div>
<div class="container mx-auto flex">
<!-- Left panel for tags and created_date -->
<div class="w-1/4 pr-4">
{#if searchResult && searchResult.facet_counts}
{#each searchResult.facet_counts as facet}
{#if facet.field_name === 'tags' || facet.field_name === 'created_date'}
<FacetFilter
{facet}
selectedItems={facet.field_name === 'tags' ? selectedTags : selectedDates}
onItemChange={facet.field_name === 'tags' ? handleTagChange : handleDateChange}
/>
{/if}
{/each}
</div>
{:else}
<p>Type something to start searching...</p>
{/if}
{/if}
</div>
<!-- Right panel for search results -->
<div class="w-3/4">
{#if isLoading}
<p>Loading...</p>
{:else if searchResult && searchResult.hits.length > 0}
<p class="search-summary">
{searchResult['found'].toLocaleString()} results found - Searched {searchResult[
'out_of'
].toLocaleString()} recipes in {searchResult['search_time_ms']}ms.
</p>
<div class="grid grid-cols-4 gap-4">
{#each searchResult.hits as hit, index}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300 ease-in-out"
on:click={() => openModal(index)}
>
<figure class="px-5 pt-5">
<img
class="w-full h-48 object-cover"
src={`${apiEndpoint}/files/${hit.document.filepath}`}
alt=""
/>
</figure>
<div class="p-4">
<h2 class="text-lg font-bold mb-2">{filename(hit.document.filepath)}</h2>
<p class="text-gray-700 line-clamp-5">{''}</p>
</div>
</div>
{/each}
</div>
{:else if searchString}
<p>No results found.</p>
{:else}
<p>Type something to start searching...</p>
{/if}
</div>
</div>
{#if searchResults.length && showModal}
{#if searchResult && searchResult.hits.length && showModal}
<Figure
id={searchResults[selectedImage].id}
library_id={searchResults[selectedImage].library_id}
folder_id={searchResults[selectedImage].folder_id}
image={`${apiEndpoint}/files/${searchResults[selectedImage].filepath}`}
video={`${apiEndpoint}/files/video/${searchResults[selectedImage].filepath}`}
created_at={searchResults[selectedImage].file_created_at}
filepath={searchResults[selectedImage].filepath}
title={filename(searchResults[selectedImage].filepath)}
tags={searchResults[selectedImage].tags}
metadata_entries={searchResults[selectedImage].metadata_entries}
id={searchResult.hits[selectedImage].document.id}
library_id={searchResult.hits[selectedImage].document.library_id}
folder_id={searchResult.hits[selectedImage].document.folder_id}
image={`${apiEndpoint}/files/${searchResult.hits[selectedImage].document.filepath}`}
video={`${apiEndpoint}/files/video/${searchResult.hits[selectedImage].document.filepath}`}
created_at={searchResult.hits[selectedImage].document.file_created_at * 1000}
filepath={searchResult.hits[selectedImage].document.filepath}
title={filename(searchResult.hits[selectedImage].document.filepath)}
tags={searchResult.hits[selectedImage].document.tags}
metadata_entries={searchResult.hits[selectedImage].document.metadata_entries}
onClose={closeModal}
onNext={() => openModal((selectedImage + 1) % searchResults.length)}
onPrevious={() => openModal((selectedImage - 1 + searchResults.length) % searchResults.length)}
onNext={() => searchResult && openModal((selectedImage + 1) % searchResult.hits.length)}
onPrevious={() =>
searchResult &&
openModal((selectedImage - 1 + searchResult.hits.length) % searchResult.hits.length)}
/>
{/if}