Added DB. Added combobox with fuzzy search

This commit is contained in:
Denis Donici 2025-03-05 21:54:59 +02:00
parent c91b94b392
commit 77635600aa
13 changed files with 263 additions and 100 deletions

View File

@ -36,3 +36,16 @@ npm run build
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
```sql
select count(*) from cities where cities.country_id IS NOT NULL;
select distinct country from cities c where country_id IS NULL;
SELECT DISTINCT c.id, c.name, c.emoji
FROM countries c
INNER JOIN cities ci ON c.id = ci.country_id ORDER BY c.name;
select count(*) from cities where capital in ('capital', 'admin') or (population >= 100000 and population != '');
```
```
```

View File

@ -12,11 +12,13 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/fuzzy-search": "^2.1.5",
"bun-types": "^1.2.4",
"daisyui": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"fuzzy-search": "^3.2.1",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
@ -217,6 +219,8 @@
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
"@types/fuzzy-search": ["@types/fuzzy-search@2.1.5", "", {}, "sha512-Yw8OsjhVKbKw83LMDOZ9RXc+N+um48DmZYMrz7QChpHkQuygsc5O40oCL7SfvWgpaaviCx2TbNXYUBwhMtBH5w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.13.8", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ=="],
@ -359,6 +363,8 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuzzy-search": ["fuzzy-search@3.2.1", "", {}, "sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.0.0", "", {}, "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A=="],

View File

@ -10,11 +10,13 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/fuzzy-search": "^2.1.5",
"bun-types": "^1.2.4",
"daisyui": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"fuzzy-search": "^3.2.1",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",

5
rest.http Normal file
View File

@ -0,0 +1,5 @@
@PORT=3000
@HOSTNAME=localhost
@HOST={{HOSTNAME}}:{{PORT}}
GET http://{{HOST}}/api

View File

@ -0,0 +1,123 @@
<script lang="ts">
import type { ComboOption, CountryBasic } from '$lib/types';
import ChevronDown from './icons/ChevronDown.svelte';
import ChevronUp from './icons/ChevronUp.svelte';
import FuzzySearch from 'fuzzy-search';
import XMark from './icons/XMark.svelte';
type ComboBoxProps = {
options: ComboOption[] | CountryBasic[];
onSelect: (data: ComboOption | CountryBasic) => void;
onClear: () => void;
};
let { options, onSelect, onClear }: ComboBoxProps = $props();
let focused = $state(false);
let selectSet = $state(false);
let inputElement: HTMLInputElement;
let dropdownElement: HTMLUListElement;
let filterInput = $state('');
const searcher = $state(new FuzzySearch(options, ['name'], {
caseSensitive: false,
sort: true
}));
let filteredOptions = $derived(filterInput ? searcher.search(filterInput) : options);
const handleSelect = (option: ComboOption) => {
filterInput = option?.emoji ? option.name + ' ' + option?.emoji : option.name;
focused = false;
selectSet = true;
onSelect(option);
};
const handleClear = () => {
filterInput = '';
selectSet = false;
onClear();
};
const handleClickOutside = (event: MouseEvent) => {
if (
inputElement &&
dropdownElement &&
!inputElement.contains(event.target as Node) &&
!dropdownElement.contains(event.target as Node)
) {
focused = false;
}
};
$effect(() => {
if (focused) {
document.addEventListener('click', handleClickOutside);
} else {
document.removeEventListener('click', handleClickOutside);
}
return () => {
document.removeEventListener('click', handleClickOutside);
};
});
</script>
<div class="dropdown my-2 py-2">
<label class="input">
<input
type="text"
class="focus:ring-0"
onfocus={() => (focused = true)}
bind:this={inputElement}
bind:value={filterInput}
oninput={() => (selectSet = false)}
placeholder="Type to search"
/>
{#if !selectSet}
<label class="swap swap-rotate" class:swap-active={focused}>
<div class="swap-off">
<ChevronDown />
</div>
<div class="swap-on">
<ChevronUp />
</div>
</label>
{:else}
<label class="">
<button class="cursor-pointer" onclick={handleClear}><XMark /></button>
</label>
{/if}
</label>
{#if focused}
<ul
bind:this={dropdownElement}
class="dropdown-content rounded-box z-100 mt-1 w-full gap-1 bg-white p-2 shadow-sm"
>
{#each filteredOptions as option (option.id)}
<li class="mb-1">
<button
class="btn btn-soft w-full justify-between text-left"
onclick={() => handleSelect(option)}
>
<div>
{option.name}
</div>
<div>
{option?.emoji ?? ''}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
</style>

View File

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
</svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -1,3 +1,13 @@
import db from ".";
export const allCountries = db.prepare('SELECT id, name, native, emoji FROM countries')
// export const allCountries = db.query('SELECT id, name, native, emoji FROM countries');
export const allCountries = db.query(`
SELECT id, name, native, emoji
FROM countries
WHERE id IN(
SELECT DISTINCT country_id
FROM cities
WHERE capital IN('capital', 'admin') OR(population >= 100000 AND population IS NOT NULL)
)
`);
export const allCities = db.query("SELECT id, city, country_id FROM cities WHERE capital IN ('capital', 'admin') OR (population >= 100000 AND population != '')");

View File

@ -1,6 +1,18 @@
export type CountryBasics = {
export type CountryBasic = {
id: number;
name: string;
native: string;
emoji: string;
};
export type CityBasic = {
id: number;
city: string;
country_id: number;
};
export type ComboOption = {
id: number;
name: string;
emoji?: string;
}

View File

@ -1,20 +1,20 @@
import { allCountries } from "$lib/db/queries";
import type { CountryBasics } from "$lib/types";
import type { PageServerData } from "./$types";
import { allCities, allCountries } from '$lib/db/queries';
import type { CityBasic, CountryBasic } from '$lib/types';
import type { PageServerLoad } from './$types';
export const load: PageServerData = () => {
export const load: PageServerLoad = () => {
try {
const countries = allCountries.all() as CountryBasics[]
const countries = allCountries.all() as CountryBasic[];
const cities = allCities.all() as CityBasic[];
return {
countries
}
countries,
cities
};
} catch (error) {
console.log(error);
return {
countries: [] as CountryBasics[]
countries: [],
cities: []
};
}
}
}
};

View File

@ -1,25 +1,26 @@
<script lang="ts">
import ComboBox from '$lib/components/ComboBox.svelte';
import type { CityBasic, CountryBasic } from '$lib/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// let dropdownElement: HTMLDivElement;
// let searchTerm: string = $state('');
// let value: option = $state();
// let isOpen: boolean = $state(false);
// let option: any = $state();
// let filteredOptions = $derived(
// searchTerm
// ? data.countries.filter((c) => c.name.toLowerCase().startsWith(searchTerm.toLowerCase()))
// : data.countries
// );
//
// const handleInputChange = () => {};
// const handleOptionSelect = (item: any) => {};
// TODO: pick search library
// fuse - 6.2
// @nozbe/microfuzz 1.3
// fast-fuzzy 8.6
// fuzzy-search 1
let filteredCities: CityBasic[] = $state([]);
// TODO:
// - create a combo box
// - implement fuzzy-search
const setCountryCities = (id: number | null) => {
if (id) {
filteredCities = data.cities.filter((c) => c.country_id == id);
} else {
filteredCities = [];
}
};
const onSelect = (country: CountryBasic) => {
setCountryCities(country.id);
};
</script>
<main class="container mx-auto my-3 max-w-4xl p-4">
@ -29,66 +30,27 @@
with ease! 🎉✨
</h2>
<section>
<ComboBox options={data.countries} {onSelect} onClear={() => setCountryCities(null)} />
</section>
<button class="btn btn-lg btn-soft btn-primary"> Hi </button>
<!-- <section> -->
<!-- <div class="relative w-full" bind:this={dropdownElement}> -->
<!-- <div class="form-control w-full"> -->
<!-- <div class="input input-bordered flex items-center"> -->
<!-- <input -->
<!-- type="text" -->
<!-- class="flex-grow bg-transparent focus:outline-none" -->
<!-- placeholder="Type in to filter countries" -->
<!-- bind:value={searchTerm} -->
<!-- oninput={handleInputChange} -->
<!-- onfocus={() => (isOpen = true)} -->
<!-- /> -->
<!-- <button class="btn btn-ghost btn-sm" onclick={() => (isOpen = !isOpen)} type="button"> -->
<!-- <svg -->
<!-- xmlns="http://www.w3.org/2000/svg" -->
<!-- class="h-4 w-4" -->
<!-- fill="none" -->
<!-- viewBox="0 0 24 24" -->
<!-- stroke="currentColor" -->
<!-- > -->
<!-- <path -->
<!-- stroke-linecap="round" -->
<!-- stroke-linejoin="round" -->
<!-- stroke-width="2" -->
<!-- d={isOpen ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} -->
<!-- /> -->
<!-- </svg> -->
<!-- </button> -->
<!-- </div> -->
<!-- </div> -->
<!---->
<!-- {#if isOpen} -->
<!-- <ul -->
<!-- class="dropdown-content menu bg-base-100 rounded-box z-50 max-h-60 w-full overflow-auto p-2 shadow" -->
<!-- > -->
<!-- {#if filteredOptions.length > 0} -->
<!-- {#each filteredOptions as option, i} -->
<!-- <li> -->
<!-- <button -->
<!-- onclick={() => handleOptionSelect(option)} -->
<!-- class={value === option ? 'active' : ''} -->
<!-- > -->
<!-- {option} -->
<!-- </button> -->
<!-- </li> -->
<!-- {/each} -->
<!-- {:else} -->
<!-- <li class="p-2 text-gray-500">No options found</li> -->
<!-- {/if} -->
<!-- </ul> -->
<!-- {/if} -->
<!-- </div> -->
<!-- </section> -->
<div class="grid grid-cols-2 gap-5">
<!-- <ul>
{#each data.countries as country (country.id)}
<li>
<button class="btn btn-ghost" onclick={() => setCountryCities(country.id)}>
{country.name}
{country.emoji}
</button>
</li>
{/each}
</ul> -->
<ul>
{#each data.countries as country, idx (country.id)}
<li>{country.name} - {country.emoji}</li>
{#each filteredCities as city (city.id)}
<li>{city.city}</li>
{/each}
</ul>
<!-- <code>{JSON.stringify(data, null, 2)}</code> -->
<!-- <code>{JSON.stringify(filteredCities, null, 2)}</code> -->
</div>
</main>

View File