mirror of
https://github.com/tcsenpai/pensieve.git
synced 2025-06-06 19:25:24 +00:00
feat(web): supprt typesense style facet filter
This commit is contained in:
parent
9267fdc018
commit
7b2ab563cc
26
web/src/lib/components/FacetFilter.svelte
Normal file
26
web/src/lib/components/FacetFilter.svelte
Normal 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>
|
@ -1,9 +1,11 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import Figure from '$lib/Figure.svelte';
|
import Figure from '$lib/Figure.svelte';
|
||||||
import TimeFilter from '$lib/components/TimeFilter.svelte';
|
import TimeFilter from '$lib/components/TimeFilter.svelte';
|
||||||
import LibraryFilter from '$lib/components/LibraryFilter.svelte';
|
import LibraryFilter from '$lib/components/LibraryFilter.svelte';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { PUBLIC_API_ENDPOINT } from '$env/static/public';
|
import { PUBLIC_API_ENDPOINT } from '$env/static/public';
|
||||||
|
import FacetFilter from '$lib/components/FacetFilter.svelte';
|
||||||
|
|
||||||
let searchString = '';
|
let searchString = '';
|
||||||
/**
|
/**
|
||||||
@ -11,23 +13,52 @@
|
|||||||
*/
|
*/
|
||||||
let searchResults = [];
|
let searchResults = [];
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let debounceTimer;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let showModal = false;
|
let showModal = false;
|
||||||
let selectedImage = 0;
|
let selectedImage = 0;
|
||||||
|
|
||||||
let startTimestamp = -1;
|
let startTimestamp = -1;
|
||||||
let endTimestamp = -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 debounceDelay = 300;
|
||||||
const apiEndpoint =
|
const apiEndpoint =
|
||||||
typeof PUBLIC_API_ENDPOINT !== 'undefined' ? PUBLIC_API_ENDPOINT : window.location.origin;
|
typeof PUBLIC_API_ENDPOINT !== 'undefined' ? PUBLIC_API_ENDPOINT : window.location.origin;
|
||||||
|
|
||||||
/**
|
let facetCounts: Facet[] | null = null;
|
||||||
* @param {string} query
|
|
||||||
*/
|
async function searchItems(
|
||||||
async function searchItems(query, start, end, selectedLibraries) {
|
query: string,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
selectedLibraries: number[],
|
||||||
|
selectedTags: string[],
|
||||||
|
selectedDates: string[],
|
||||||
|
updateFacets: boolean = false
|
||||||
|
) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -41,32 +72,129 @@
|
|||||||
if (selectedLibraries.length > 0) {
|
if (selectedLibraries.length > 0) {
|
||||||
url += `&library_ids=${selectedLibraries.join(',')}`;
|
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);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Network response was not ok');
|
throw new Error('Network response was not ok');
|
||||||
}
|
}
|
||||||
searchResults = await response.json();
|
const result = await response.json();
|
||||||
console.log(searchResults);
|
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) {
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
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 {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);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
searchItems(query, start, end, selectedLibraries);
|
searchItems(query, start, end, selectedLibraries, selectedTags, selectedDates, updateFacets);
|
||||||
}, debounceDelay);
|
}, debounceDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
*/
|
*/
|
||||||
function filename(path) {
|
function filename(path: string): string {
|
||||||
let splits = path.split('/');
|
let splits = path.split('/');
|
||||||
return splits[splits.length - 1];
|
return splits[splits.length - 1];
|
||||||
}
|
}
|
||||||
@ -74,8 +202,7 @@
|
|||||||
/**
|
/**
|
||||||
* @param {number} index
|
* @param {number} index
|
||||||
*/
|
*/
|
||||||
function openModal(index) {
|
function openModal(index: number) {
|
||||||
// @ts-ignore
|
|
||||||
showModal = true;
|
showModal = true;
|
||||||
selectedImage = index;
|
selectedImage = index;
|
||||||
disableScroll();
|
disableScroll();
|
||||||
@ -89,14 +216,14 @@
|
|||||||
/**
|
/**
|
||||||
* @param {{ key: string; }} event
|
* @param {{ key: string; }} event
|
||||||
*/
|
*/
|
||||||
function handleKeydown(event) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (showModal) {
|
if (showModal && searchResult) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
closeModal();
|
closeModal();
|
||||||
} else if (event.key === 'ArrowRight') {
|
} else if (event.key === 'ArrowRight') {
|
||||||
selectedImage = (selectedImage + 1) % searchResults.length;
|
selectedImage = (selectedImage + 1) % searchResult.hits.length;
|
||||||
} else if (event.key === 'ArrowLeft') {
|
} 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 = () => {
|
const enableScroll = () => {
|
||||||
document.body.style.overflow = '';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
@ -137,58 +246,85 @@
|
|||||||
bind:value={searchString}
|
bind:value={searchString}
|
||||||
placeholder="Type to search..."
|
placeholder="Type to search..."
|
||||||
/>
|
/>
|
||||||
<div class="flex">
|
<div class="flex space-x-2">
|
||||||
<LibraryFilter bind:selectedLibraryIds={selectedLibraries} />
|
<LibraryFilter bind:selectedLibraryIds={selectedLibraries} />
|
||||||
<TimeFilter bind:start={startTimestamp} bind:end={endTimestamp} />
|
<TimeFilter bind:start={startTimestamp} bind:end={endTimestamp} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container mx-auto">
|
<div class="container mx-auto flex">
|
||||||
{#if isLoading}
|
<!-- Left panel for tags and created_date -->
|
||||||
<p>Loading...</p>
|
<div class="w-1/4 pr-4">
|
||||||
{:else if searchString}
|
{#if searchResult && searchResult.facet_counts}
|
||||||
<div class="grid grid-cols-4 gap-4">
|
{#each searchResult.facet_counts as facet}
|
||||||
{#each searchResults as item, index}
|
{#if facet.field_name === 'tags' || facet.field_name === 'created_date'}
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<FacetFilter
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
{facet}
|
||||||
<div
|
selectedItems={facet.field_name === 'tags' ? selectedTags : selectedDates}
|
||||||
class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300 ease-in-out"
|
onItemChange={facet.field_name === 'tags' ? handleTagChange : handleDateChange}
|
||||||
on:click={() => openModal(index)}
|
/>
|
||||||
>
|
{/if}
|
||||||
<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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
<p>Type something to start searching...</p>
|
|
||||||
{/if}
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
{#if searchResults.length && showModal}
|
{#if searchResult && searchResult.hits.length && showModal}
|
||||||
<Figure
|
<Figure
|
||||||
id={searchResults[selectedImage].id}
|
id={searchResult.hits[selectedImage].document.id}
|
||||||
library_id={searchResults[selectedImage].library_id}
|
library_id={searchResult.hits[selectedImage].document.library_id}
|
||||||
folder_id={searchResults[selectedImage].folder_id}
|
folder_id={searchResult.hits[selectedImage].document.folder_id}
|
||||||
image={`${apiEndpoint}/files/${searchResults[selectedImage].filepath}`}
|
image={`${apiEndpoint}/files/${searchResult.hits[selectedImage].document.filepath}`}
|
||||||
video={`${apiEndpoint}/files/video/${searchResults[selectedImage].filepath}`}
|
video={`${apiEndpoint}/files/video/${searchResult.hits[selectedImage].document.filepath}`}
|
||||||
created_at={searchResults[selectedImage].file_created_at}
|
created_at={searchResult.hits[selectedImage].document.file_created_at * 1000}
|
||||||
filepath={searchResults[selectedImage].filepath}
|
filepath={searchResult.hits[selectedImage].document.filepath}
|
||||||
title={filename(searchResults[selectedImage].filepath)}
|
title={filename(searchResult.hits[selectedImage].document.filepath)}
|
||||||
tags={searchResults[selectedImage].tags}
|
tags={searchResult.hits[selectedImage].document.tags}
|
||||||
metadata_entries={searchResults[selectedImage].metadata_entries}
|
metadata_entries={searchResult.hits[selectedImage].document.metadata_entries}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
onNext={() => openModal((selectedImage + 1) % searchResults.length)}
|
onNext={() => searchResult && openModal((selectedImage + 1) % searchResult.hits.length)}
|
||||||
onPrevious={() => openModal((selectedImage - 1 + searchResults.length) % searchResults.length)}
|
onPrevious={() =>
|
||||||
|
searchResult &&
|
||||||
|
openModal((selectedImage - 1 + searchResult.hits.length) % searchResult.hits.length)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user