Added OpenMeteo Service

This commit is contained in:
Denis Donici 2025-03-06 12:32:44 +02:00
parent 77635600aa
commit dde2580b99
10 changed files with 341 additions and 78 deletions

View File

@ -2,6 +2,61 @@
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
@plugin "daisyui/theme" {
name: "hotcold";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0.019 200.873);
--color-base-200: oklch(95% 0.045 203.388);
--color-base-300: oklch(91% 0.08 205.041);
--color-base-content: oklch(39% 0.07 227.392);
--color-primary: oklch(60% 0.25 292.717);
--color-primary-content: oklch(96% 0.016 293.756);
--color-secondary: oklch(62% 0.214 259.815);
--color-secondary-content: oklch(97% 0.014 254.604);
--color-accent: oklch(71% 0.143 215.221);
--color-accent-content: oklch(98% 0.019 200.873);
--color-neutral: oklch(52% 0.105 223.128);
--color-neutral-content: oklch(98% 0.019 200.873);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(79% 0.184 86.047);
--color-warning-content: oklch(98% 0.026 102.212);
--color-error: oklch(65% 0.241 354.308);
--color-error-content: oklch(97% 0.014 343.198);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
@plugin "daisyui" {
themes: winter --default;
themes: hotcold --default;
}
html, body {
height: 100%;
padding: 0;
margin: 0;
background-color: #fff;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}

View File

@ -1,36 +1,38 @@
<script lang="ts">
import type { ComboOption, CountryBasic } from '$lib/types';
import type { CityBasic, 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';
import FuzzySearch from 'fuzzy-search';
// import Fuse from 'fuse.js'
type ComboBoxProps = {
options: ComboOption[] | CountryBasic[];
onSelect: (data: ComboOption | CountryBasic) => void;
options: ComboOption[] | CountryBasic[] | CityBasic[];
onSelect: (data: ComboOption | CountryBasic | CityBasic) => void;
onClear: () => void;
disabledItems?: number[];
};
let { options, onSelect, onClear }: ComboBoxProps = $props();
let { options, onSelect, onClear, disabledItems }: ComboBoxProps = $props();
let focused = $state(false);
let selectSet = $state(false);
let inputElement: HTMLInputElement;
let dropdownElement: HTMLUListElement;
let dropdownElement: HTMLUListElement | null = $state(null);
let filterInput = $state('');
let emoji: string = $state('');
const searcher = $state(new FuzzySearch(options, ['name'], {
caseSensitive: false,
sort: true
}));
let searcher = $state(new FuzzySearch(options, ['name'], { caseSensitive: false, sort: true }));
$effect(() => {
searcher = 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;
const handleSelect = (option: ComboOption | CountryBasic | CityBasic) => {
filterInput = option.name;
emoji = 'emoji' in option ? (option.emoji ?? '') : '';
focused = false;
selectSet = true;
onSelect(option);
@ -39,10 +41,10 @@
const handleClear = () => {
filterInput = '';
selectSet = false;
emoji = '';
onClear();
};
const handleClickOutside = (event: MouseEvent) => {
if (
inputElement &&
@ -68,10 +70,10 @@
</script>
<div class="dropdown my-2 py-2">
<label class="input">
<label class="input !outline-none focus:ring-0">
<input
type="text"
class="focus:ring-0"
class="focus:ring-0 focus:outline-none"
onfocus={() => (focused = true)}
bind:this={inputElement}
bind:value={filterInput}
@ -79,45 +81,44 @@
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>
<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>
<label class="relative">
{#if emoji}
<span class="absolute -top-0.5 right-5">{emoji}</span>
{/if}
<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"
class={`dropdown-content rounded-box relative z-100 mt-2 max-h-[50vh] w-full gap-1 overflow-y-auto border border-gray-100 bg-white p-2 shadow-md
${filteredOptions.length >= 5 ? '' : ''}`}
>
{#each filteredOptions as option (option.id)}
<li class="mb-1">
<button
<button
class="btn btn-soft w-full justify-between text-left"
onclick={() => handleSelect(option)}
>
<div>
{option.name}
</div>
<div>
{option?.emoji ?? ''}
</div>
<div class="no-scrollbar overflow-x-auto text-ellipsis whitespace-nowrap">
{option.name}
</div>
<div>
{(option as CountryBasic)?.emoji ?? ''}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
</style>

View File

@ -0,0 +1,58 @@
<footer class="footer footer-horizontal footer-center bg-accent text-accent-content p-10 z-1">
<aside>
<svg
width="50"
height="50"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
class="inline-block fill-current">
<path
d="M22.672 15.226l-2.432.811.841 2.515c.33 1.019-.209 2.127-1.23 2.456-1.15.325-2.148-.321-2.463-1.226l-.84-2.518-5.013 1.677.84 2.517c.391 1.203-.434 2.542-1.831 2.542-.88 0-1.601-.564-1.86-1.314l-.842-2.516-2.431.809c-1.135.328-2.145-.317-2.463-1.229-.329-1.018.211-2.127 1.231-2.456l2.432-.809-1.621-4.823-2.432.808c-1.355.384-2.558-.59-2.558-1.839 0-.817.509-1.582 1.327-1.846l2.433-.809-.842-2.515c-.33-1.02.211-2.129 1.232-2.458 1.02-.329 2.13.209 2.461 1.229l.842 2.515 5.011-1.677-.839-2.517c-.403-1.238.484-2.553 1.843-2.553.819 0 1.585.509 1.85 1.326l.841 2.517 2.431-.81c1.02-.33 2.131.211 2.461 1.229.332 1.018-.21 2.126-1.23 2.456l-2.433.809 1.622 4.823 2.433-.809c1.242-.401 2.557.484 2.557 1.838 0 .819-.51 1.583-1.328 1.847m-8.992-6.428l-5.01 1.675 1.619 4.828 5.011-1.674-1.62-4.829z"></path>
</svg>
<p class="">
<span class="font-bold text-xl">HotColdCities</span>
<br />
Powered by <a href="https://open-meteo.com/" target="_blank">OpenMeteo</a>
</p>
<p>Copyright © {new Date().getFullYear()} - All right reserved</p>
</aside>
<!-- <nav>
<div class="grid grid-flow-col gap-4">
<a>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
class="fill-current">
<path
d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"></path>
</svg>
</a>
<a>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
class="fill-current">
<path
d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"></path>
</svg>
</a>
<a>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
class="fill-current">
<path
d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z"></path>
</svg>
</a>
</div>
</nav> -->
</footer>

View File

@ -0,0 +1,51 @@
<script lang="ts">
import type { CityBasic, ComboOption, CountryBasic } from '$lib/types';
import ComboBox from './ComboBox.svelte';
let selectedFirstCity: CityBasic | null = $state(null);
let selectedSecondCity: CityBasic | null = $state(null);
let { cities }: { cities: CityBasic[] } = $props();
const isCityBasic = (city: CityBasic | ComboOption | CountryBasic): city is CityBasic => {
return 'country_id' in city;
};
let comboFirstFilteredCities: CityBasic[] = $derived(
selectedSecondCity ?
cities.filter((c) => c.id !== selectedSecondCity?.id) :
cities
);
let comboSecondFilteredCities: CityBasic[] = $derived(
selectedFirstCity ?
cities.filter((c) => c.id !== selectedFirstCity?.id) :
cities
);
const onFirstCitySelect = (city: CityBasic | ComboOption | CountryBasic) => {
if (isCityBasic(city)) {
selectedFirstCity = city;
}
};
const onSecondCitySelect = (city: CityBasic | ComboOption | CountryBasic) => {
if (isCityBasic(city)) {
selectedSecondCity = city;
}
};
</script>
<section class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<ComboBox
options={comboFirstFilteredCities}
onSelect={onFirstCitySelect}
onClear={() => (selectedFirstCity = null)}
/>
</div>
<div>
<ComboBox
options={comboSecondFilteredCities}
onSelect={onSecondCitySelect}
onClear={() => selectedSecondCity = null}
/>
</div>
</section>

View File

@ -10,4 +10,4 @@ export const allCountries = db.query(`
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 != '')");
export const allCities = db.query("SELECT id, city as name, country_id, lat as latitude, lng as longitude FROM cities WHERE capital IN ('capital', 'admin') OR (population >= 100000 AND population != '')");

View File

@ -0,0 +1,56 @@
// import { fetchWeatherApi } from 'openmeteo';
export const getLocationTemperature = async (latitude: string, longitude: string) => {
const params = {
latitude,
longitude,
daily: ["temperature_2m_max", "temperature_2m_min"],
forecast_days: 1
};
const meteoEndpoint = "https://api.open-meteo.com/v1/forecast";
// https://api.open-meteo.com/v1/forecast?latitude=48.0356&longitude=27.8129&hourly=temperature_2m&daily=temperature_2m_max,temperature_2m_min&forecast_days=1&format=json&timeformat=unixtime
const url = new URL(meteoEndpoint);
url.searchParams.set('latitude', latitude);
url.searchParams.set('longitude', longitude);
url.searchParams.set('daily', 'temperature_2m_max,temperature_2m_min');
url.searchParams.set('format', 'json');
url.searchParams.set('forecast_days', '1');
try {
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
console.log('DATA >>>>', data);
}
// const response = await fetchWeatherApi(url, params);
// Get the first location
// console.log('RESPONSES', responses)
// const response = responses[0];
// console.log('RESPONSE >>>>', response);
// Get daily data
// // const daily = response.daily();
// return {
// max: daily.temperature_2m_max[0],
// min: daily.temperature_2m_min[0],
// unit: daily.temperature_2m_max_units
// };
return {
ok: true
}
} catch (error) {
console.log(error);
return {
ok: false
}
}
}

View File

@ -7,12 +7,15 @@ export type CountryBasic = {
export type CityBasic = {
id: number;
city: string;
name: string;
country_id: number;
latitude: number;
longitude: number;
};
export type ComboOption = {
id: number;
name: string;
emoji?: string;
country_id?: number;
}

View File

@ -1,5 +1,7 @@
import { allCities, allCountries } from '$lib/db/queries';
import type { CityBasic, CountryBasic } from '$lib/types';
import { error, type Actions } from '@sveltejs/kit';
import { getLocationTemperature } from '$lib/server/meteoService';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
@ -18,3 +20,28 @@ export const load: PageServerLoad = () => {
};
}
};
export const actions: Actions = {
fetchTemperature: async ({ request }) => {
try {
const formData = await request.formData();
const latitude = formData.get('latitude') as string;
const longitude = formData.get('longitude') as string;
const response = await getLocationTemperature(latitude, longitude);
console.log('RESPONSE >>>>', response);
return {
ok: true,
data: response,
error: null
};
} catch (error) {
console.log(error);
return {
ok: false,
data: null,
error: 'Failed to fetch temperature'
}
}
}
}

View File

@ -1,14 +1,14 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ComboBox from '$lib/components/ComboBox.svelte';
import type { CityBasic, CountryBasic } from '$lib/types';
import Footer from '$lib/components/Footer.svelte';
import TwoCities from '$lib/components/TwoCities.svelte';
import type { CityBasic, CountryBasic, ComboOption } from '$lib/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let filteredCities: CityBasic[] = $state([]);
// TODO:
// - create a combo box
// - implement fuzzy-search
let filteredCities: CityBasic[] = $state([]);
const setCountryCities = (id: number | null) => {
if (id) {
@ -18,39 +18,51 @@
}
};
const onSelect = (country: CountryBasic) => {
const onCountrySelect = (country: CountryBasic | ComboOption) => {
setCountryCities(country.id);
};
</script>
<main class="container mx-auto my-3 max-w-4xl p-4">
<h1 class="my-5 text-center text-4xl font-light">HotColdCities</h1>
<h2 class="mx-auto max-w-xl text-center text-xl leading-tight md:text-2xl">
Compare the hottest 🌡️ and coldest ❄️ cities in any region to plan 📅, move 🚚, or explore 🌍
with ease! 🎉✨
</h2>
<svelte:head>
<title>HotColdCities</title>
</svelte:head>
<section>
<ComboBox options={data.countries} {onSelect} onClear={() => setCountryCities(null)} />
</section>
<main class="flex h-full min-h-screen flex-col bg-white">
<div class="container mx-auto my-3 max-w-4xl flex-grow p-4">
<h1 class="my-5 text-center text-4xl font-light">HotColdCities</h1>
<h2 class="mx-auto max-w-xl text-center text-xl leading-tight md:text-2xl">
Compare the hottest 🌡️ and coldest ❄️ cities in any region to plan 📅, move 🚚, or explore 🌍
with ease! 🎉✨
</h2>
<button class="btn btn-lg btn-soft btn-primary"> Hi </button>
<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> -->
<section>
<ComboBox
options={data.countries}
onSelect={onCountrySelect}
onClear={() => setCountryCities(null)}
/>
</section>
<!-- TODO: Show max min temperatures in the country and its coresponding cities -->
<!-- <TwoCities cities={data.cities} /> -->
<button class="btn btn-lg btn-soft btn-primary"> Hi </button>
<div class="grid grid-cols-2 gap-5">
<ul class="flex flex-col gap-1">
{#each filteredCities as city (city.id)}
<li class="flex gap-2">
{city.name}
<form action="?/fetchTemperature" method="POST" use:enhance>
<input type="text" class="hidden" name="latitude" value={city.latitude} />
<input type="text" class="hidden" name="longitude" value={city.longitude} />
<button type="submit" class="btn btn-soft btn-primary">Get</button>
</form>
</li>
{/each}
</ul>
<!-- <code>{JSON.stringify(filteredCities, null, 2)}</code> -->
</div>
</div>
<Footer />
</main>