mirror of
https://github.com/gevera/hot-cold-cities.git
synced 2025-12-06 07:08:20 +00:00
Added DB. Added combobox with fuzzy search
This commit is contained in:
parent
c91b94b392
commit
77635600aa
13
README.md
13
README.md
@ -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 != '');
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
6
bun.lock
6
bun.lock
@ -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=="],
|
||||
|
||||
@ -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
5
rest.http
Normal file
@ -0,0 +1,5 @@
|
||||
@PORT=3000
|
||||
@HOSTNAME=localhost
|
||||
@HOST={{HOSTNAME}}:{{PORT}}
|
||||
|
||||
GET http://{{HOST}}/api
|
||||
123
src/lib/components/ComboBox.svelte
Normal file
123
src/lib/components/ComboBox.svelte
Normal 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>
|
||||
10
src/lib/components/icons/ChevronDown.svelte
Normal file
10
src/lib/components/icons/ChevronDown.svelte
Normal 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 |
10
src/lib/components/icons/ChevronUp.svelte
Normal file
10
src/lib/components/icons/ChevronUp.svelte
Normal 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 |
10
src/lib/components/icons/XMark.svelte
Normal file
10
src/lib/components/icons/XMark.svelte
Normal 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 |
@ -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 != '')");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = () => {
|
||||
try {
|
||||
const countries = allCountries.all() as CountryBasics[]
|
||||
return {
|
||||
countries
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return {
|
||||
countries: [] as CountryBasics[]
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
export const load: PageServerLoad = () => {
|
||||
try {
|
||||
const countries = allCountries.all() as CountryBasic[];
|
||||
const cities = allCities.all() as CityBasic[];
|
||||
return {
|
||||
countries,
|
||||
cities
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return {
|
||||
countries: [],
|
||||
cities: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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> -->
|
||||
|
||||
<ul>
|
||||
{#each data.countries as country, idx (country.id)}
|
||||
<li>{country.name} - {country.emoji}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<!-- <code>{JSON.stringify(data, null, 2)}</code> -->
|
||||
<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 filteredCities as city (city.id)}
|
||||
<li>{city.city}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<!-- <code>{JSON.stringify(filteredCities, null, 2)}</code> -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
0
src/server/meteoService.ts
Normal file
0
src/server/meteoService.ts
Normal file
Loading…
x
Reference in New Issue
Block a user