feat: implement fuzzy search and optimize sorting

This commit is contained in:
Samuel 2025-01-26 06:45:59 +01:00
parent b5207c4bf0
commit 07f474b1b0
3 changed files with 81 additions and 56 deletions

View File

@ -43,6 +43,7 @@
"@upstash/redis": "1.34.0", "@upstash/redis": "1.34.0",
"clsx": "2.1.1", "clsx": "2.1.1",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",
"fuse.js": "^7.0.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lucide-svelte": "0.445.0", "lucide-svelte": "0.445.0",
"mode-watcher": "0.4.1", "mode-watcher": "0.4.1",

10
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
downloadjs: downloadjs:
specifier: 1.4.7 specifier: 1.4.7
version: 1.4.7 version: 1.4.7
fuse.js:
specifier: ^7.0.0
version: 7.0.0
jszip: jszip:
specifier: 3.10.1 specifier: 3.10.1
version: 3.10.1 version: 3.10.1
@ -1459,6 +1462,10 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
fuse.js@7.0.0:
resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==}
engines: {node: '>=10'}
gensync@1.0.0-beta.2: gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -2296,6 +2303,7 @@ packages:
shikiji-core@0.10.2: shikiji-core@0.10.2:
resolution: {integrity: sha512-9Of8HMlF96usXJHmCL3Gd0Fcf0EcyJUF9m8EoAKKd98mHXi0La2AZl1h6PegSFGtiYcBDK/fLuKbDa1l16r1fA==} resolution: {integrity: sha512-9Of8HMlF96usXJHmCL3Gd0Fcf0EcyJUF9m8EoAKKd98mHXi0La2AZl1h6PegSFGtiYcBDK/fLuKbDa1l16r1fA==}
deprecated: Shikiji is merged back to Shiki v1.0, please migrate over to get the latest updates
shikiji@0.10.2: shikiji@0.10.2:
resolution: {integrity: sha512-wtZg3T0vtYV2PnqusWQs3mDaJBdCPWxFDrBM/SE5LfrX92gjUvfEMlc+vJnoKY6Z/S44OWaCRzNIsdBRWcTAiw==} resolution: {integrity: sha512-wtZg3T0vtYV2PnqusWQs3mDaJBdCPWxFDrBM/SE5LfrX92gjUvfEMlc+vJnoKY6Z/S44OWaCRzNIsdBRWcTAiw==}
@ -3995,6 +4003,8 @@ snapshots:
function-bind@1.1.2: {} function-bind@1.1.2: {}
fuse.js@7.0.0: {}
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}

View File

@ -1,12 +1,27 @@
<script lang="ts"> <script lang="ts">
import type { iSVG } from '@/types/svg'; import type { iSVG } from '@/types/svg';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { onMount } from 'svelte';
import { queryParam } from 'sveltekit-search-params'; import { queryParam } from 'sveltekit-search-params';
import Fuse from 'fuse.js';
// Get all svgs: // Get all svgs:
import { svgsData } from '@/data'; import { svgsData } from '@/data';
const allSvgs = JSON.parse(JSON.stringify(svgsData)); const allSvgs = JSON.parse(JSON.stringify(svgsData));
// Cache sorted arrays
const latestSorted = [...allSvgs].sort((a, b) => b.id! - a.id!);
const alphabeticallySorted = [...allSvgs].sort((a, b) => a.title.localeCompare(b.title));
// Fuzzy search setup:
const fuse = new Fuse<iSVG>(allSvgs, {
keys: ['title'],
threshold: 0.35,
ignoreLocation: true,
isCaseSensitive: false,
shouldSort: true
});
// Components: // Components:
import Search from '@/components/search.svelte'; import Search from '@/components/search.svelte';
import Container from '@/components/container.svelte'; import Container from '@/components/container.svelte';
@ -22,73 +37,66 @@
import { buttonStyles } from '@/ui/styles'; import { buttonStyles } from '@/ui/styles';
let sorted: boolean = false; let sorted: boolean = false;
let isFirstLoad: boolean = true;
let showAll: boolean = false; let showAll: boolean = false;
// Search: // Search:
let searchTerm = $searchParam || ''; let searchTerm = '';
let filteredSvgs: iSVG[] = []; let filteredSvgs: iSVG[] = [];
let displaySvgs: iSVG[] = [];
// Order by last added: const updateDisplaySvgs = () => {
if (searchTerm.length === 0) { displaySvgs = showAll ? filteredSvgs : filteredSvgs.slice(0, 30);
filteredSvgs = allSvgs.sort((a: iSVG, b: iSVG) => {
return b.id! - a.id!;
});
}
const loadSvgs = () => {
if (isFirstLoad || showAll) {
filteredSvgs = allSvgs;
isFirstLoad = false;
} else {
filteredSvgs = allSvgs.slice(0, 30);
}
}; };
// Search svgs: // Hybrid search strategy:
// - Simple string matching for queries < 3 chars
// - Fuzzy search for longer queries (handle typos and partial matches)
const searchSvgs = () => { const searchSvgs = () => {
$searchParam = searchTerm || null; $searchParam = searchTerm || null;
loadSvgs();
filteredSvgs = allSvgs.filter((svg: iSVG) => { if (!searchTerm) {
let svgTitle = svg.title.toLowerCase(); filteredSvgs = sorted ? alphabeticallySorted : latestSorted;
return svgTitle.includes(searchTerm.toLowerCase()); updateDisplaySvgs();
}); return;
}
if (searchTerm.length < 3) {
filteredSvgs = allSvgs.filter((svg: iSVG) =>
svg.title.toLowerCase().includes(searchTerm.toLowerCase())
);
} else {
filteredSvgs = fuse.search(searchTerm).map((result) => result.item);
}
updateDisplaySvgs();
}; };
// Clear search: // Clear search:
const clearSearch = () => { const clearSearch = () => {
searchTerm = ''; searchTerm = '';
searchSvgs(); // Use current sort state to determine order
filteredSvgs = sorted ? alphabeticallySorted : latestSorted;
updateDisplaySvgs();
}; };
// Sort: // Sort:
const sort = () => { const sort = () => {
if (sorted) {
sortByLatest();
} else {
sortAlphabetically();
}
sorted = !sorted; sorted = !sorted;
filteredSvgs = sorted ? alphabeticallySorted : latestSorted;
updateDisplaySvgs();
}; };
// Sort alphabetically: onMount(() => {
const sortAlphabetically = () => { if ($searchParam) {
filteredSvgs = allSvgs.sort((a: iSVG, b: iSVG) => { searchTerm = $searchParam;
return a.title.localeCompare(b.title); }
});
};
// Sort by latest:
const sortByLatest = () => {
filteredSvgs = filteredSvgs.sort((a: iSVG, b: iSVG) => {
return b.id! - a.id!;
});
};
if ($searchParam) {
searchSvgs(); searchSvgs();
} else { });
loadSvgs();
$: {
if (showAll || filteredSvgs) {
updateDisplaySvgs();
}
} }
</script> </script>
@ -100,15 +108,15 @@
bind:searchTerm bind:searchTerm
on:input={searchSvgs} on:input={searchSvgs}
clearSearch={() => clearSearch()} clearSearch={() => clearSearch()}
placeholder={`Search ${filteredSvgs.length} logos...`} placeholder={`Search ${allSvgs.length} logos...`}
/> />
<Container> <Container>
<div class={cn('flex items-center mb-4 justify-end', searchTerm.length > 0 && 'justify-between')}> <div class={cn('mb-4 flex items-center justify-end', searchTerm.length > 0 && 'justify-between')}>
{#if searchTerm.length > 0} {#if searchTerm.length > 0}
<button <button
class={cn( class={cn(
'flex items-center justify-center space-x-1 rounded-md py-1.5 text-sm font-medium opacity-80 hover:opacity-100 transition-opacity', 'flex items-center justify-center space-x-1 rounded-md py-1.5 text-sm font-medium opacity-80 transition-opacity hover:opacity-100',
filteredSvgs.length === 0 && 'hidden' filteredSvgs.length === 0 && 'hidden'
)} )}
on:click={() => clearSearch()} on:click={() => clearSearch()}
@ -119,7 +127,7 @@
{/if} {/if}
<button <button
class={cn( class={cn(
'flex items-center justify-center space-x-1 rounded-md py-1.5 text-sm font-medium opacity-80 hover:opacity-100 transition-opacity', 'flex items-center justify-center space-x-1 rounded-md py-1.5 text-sm font-medium opacity-80 transition-opacity hover:opacity-100',
filteredSvgs.length === 0 && 'hidden' filteredSvgs.length === 0 && 'hidden'
)} )}
on:click={() => sort()} on:click={() => sort()}
@ -129,18 +137,24 @@
{:else} {:else}
<ArrowUpDownIcon size={16} strokeWidth={2} class="mr-1" /> <ArrowUpDownIcon size={16} strokeWidth={2} class="mr-1" />
{/if} {/if}
<span>{sorted ? 'Sort by latest' : 'Sort alphabetically'}</span> <span>{sorted ? 'Sort by latest' : 'Sort A-Z'}</span>
</button> </button>
</div> </div>
<Grid> <Grid>
{#each filteredSvgs.slice(0, showAll ? undefined : 30) as svg} {#each displaySvgs as svg}
<SvgCard svgInfo={svg} searchTerm={searchTerm} /> <SvgCard svgInfo={svg} {searchTerm} />
{/each} {/each}
</Grid> </Grid>
{#if filteredSvgs.length > 30 && !showAll} {#if filteredSvgs.length > 30 && !showAll}
<div class="flex items-center justify-center mt-4"> <div class="mt-4 flex items-center justify-center">
<button class={buttonStyles} on:click={() => (showAll = true)}> <button
<div class="flex items-center space-x-2 relative"> class={buttonStyles}
on:click={() => {
showAll = true;
updateDisplaySvgs();
}}
>
<div class="relative flex items-center space-x-2">
<ArrowDown size={16} strokeWidth={2} /> <ArrowDown size={16} strokeWidth={2} />
<span>Load All SVGs</span> <span>Load All SVGs</span>
<span class="opacity-70"> <span class="opacity-70">