style: calm down frontend visual system

This commit is contained in:
zv
2026-06-04 19:54:26 +02:00
parent 2aaa93e03f
commit 9395659f07
31 changed files with 255 additions and 188 deletions

View File

@@ -100,13 +100,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
return (
<div className="mt-3 space-y-2">
{showPrompt && (
<div className="glass-subtle rounded-2xl p-3.5">
<div className="glass-subtle rounded-card p-3.5">
<div className="flex items-start gap-3">
<div className="rounded-full bg-sky-500/10 p-2 text-sky-700 dark:text-sky-300"><MapPinned className="size-4" /></div>
<div className="rounded-control bg-accent/10 p-2 text-accent"><MapPinned className="size-4" /></div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
@@ -124,11 +124,11 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button>
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-slate-600 dark:text-slate-300">{message}</p>}
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">{message}</p>}
</div>
)}
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">OpenStreetMap <ExternalLink className="size-3" /></a>
<p className="text-[0.68rem] text-muted">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">OpenStreetMap <ExternalLink className="size-3" /></a>
</p>
</div>
);

View File

@@ -18,7 +18,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
return (
<section className="space-y-3">
<div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><MapPinned className="size-4" />{t("featured.label")}</p>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("featured.label")}</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
@@ -29,13 +29,13 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
type="button"
key={station.id}
onClick={() => selectStation(station.id)}
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-2xl p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10", active && "border-sky-400/60 bg-white/60 dark:bg-white/15")}
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active && "border-accent/60 bg-surface-raised/90")}
>
<span className="min-w-0">
<span className="block truncate text-xs font-medium text-slate-600 dark:text-slate-300">{station.name}</span>
<span className="block truncate text-xs font-medium text-muted">{station.name}</span>
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language)}</span>
</span>
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
</button>
);
})}

View File

@@ -26,14 +26,14 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
return (
<section className="relative z-30">
<div className="glass rounded-[1.75rem] p-3 sm:p-4">
<div className="glass rounded-panel p-3 sm:p-4">
<div className="flex items-center gap-2 px-1 pb-3">
<MapPin className="size-4 text-sky-700 dark:text-sky-300" />
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p>
<MapPin className="size-4 text-accent" />
<p className="section-kicker">{t("location.label")}</p>
</div>
<label className="relative block">
<span className="sr-only">{t("location.searchLabel")}</span>
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-sky-600" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />}
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />}
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -41,25 +41,25 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
placeholder={t("location.placeholder")}
autoComplete="off"
className="w-full rounded-2xl border border-white/40 bg-white/55 py-3.5 pl-10 pr-10 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10"
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
/>
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>}
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><X className="size-4" /></button>}
</label>
<CurrentLocationControl stations={locatedStations} />
{selectedLocation && (
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300">
<p className="mt-3 px-1 text-xs text-muted">
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
</p>
)}
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo / GeoNames</a>
<p className="mt-3 px-1 text-[0.68rem] text-muted">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent">Open-Meteo / GeoNames</a>
</p>
</div>
{showSuggestions && (
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.error")}</p> :
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.empty")}</p> :
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
suggestions.map(({ location, nearest }) => nearest && (
<button
type="button"
@@ -69,13 +69,13 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
setQuery("");
setIsFocused(false);
}}
className="flex w-full items-start justify-between gap-3 rounded-2xl px-3 py-3 text-left transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span>
<span className="block text-sm font-semibold">{location.name}</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
<span className="mt-0.5 block text-xs text-muted">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
</span>
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400">{t("location.nearest")}<br /><strong className="font-semibold text-slate-700 dark:text-slate-200">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
</button>
))}
</div>

View File

@@ -8,12 +8,12 @@ export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { ic
return (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
<Card className="h-full p-4 sm:p-5">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
<Icon className="size-4 text-sky-600 dark:text-sky-300" />
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-accent" />
{label}
</div>
<p className="mt-4 text-xl font-semibold tracking-tight">{value}</p>
{detail && <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{detail}</p>}
{detail && <p className="mt-1 text-xs text-muted">{detail}</p>}
</Card>
</motion.div>
);

View File

@@ -22,20 +22,20 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`;
return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}>
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-white/60 dark:hover:bg-slate-900/45">
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
<div className="flex items-start justify-between gap-2">
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500">
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<p className="truncate text-sm font-semibold">{station.name}</p>
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p>
</Link>
<div className="flex flex-col items-end gap-2">
<WeatherIcon mood={mood} className="size-9 text-sky-600 dark:text-sky-300" />
<WeatherIcon mood={mood} className="size-9 text-accent" />
<Button variant="ghost" className="size-8 p-0" aria-label={favorite ? t("favorites.removeStation", { name: station.name }) : t("favorites.addStation", { name: station.name })} onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
</Button>
</div>
</div>
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-400">
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span>
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span>
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>

View File

@@ -27,7 +27,7 @@ export function StationDetailPage({ id }: { id: string }) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link href="/" className="inline-flex items-center gap-2 rounded-full px-1 py-1 text-sm font-medium text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-300"><ArrowLeft className="size-4" />{t("station.all")}</Link>
<Link href="/" className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><ArrowLeft className="size-4" />{t("station.all")}</Link>
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{favorite ? t("favorites.remove") : t("favorites.add")}
@@ -35,20 +35,20 @@ export function StationDetailPage({ id }: { id: string }) {
</div>
<WeatherHero station={station} />
<section>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("station.label", { name: station.name })}</p>
<p className="section-kicker">{t("station.label", { name: station.name })}</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{t("station.parameters")}</h1>
<p className="mb-4 mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("station.parametersDescription")}</p>
<p className="mb-4 mt-1 text-sm leading-6 text-muted">{t("station.parametersDescription")}</p>
<WeatherDetailsGrid station={station} />
</section>
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<SnapshotChart station={station} />
<Card className="p-5">
<div className="flex items-center gap-2 text-sky-700 dark:text-sky-300"><ShieldCheck className="size-5" /><p className="text-xs font-semibold uppercase tracking-[0.18em]">{t("station.quality")}</p></div>
<div className="section-kicker flex items-center gap-2"><ShieldCheck className="size-5" /><p>{t("station.quality")}</p></div>
<h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("station.qualityDescription")}</p>
<p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
<dl className="mt-6 space-y-3 text-sm">
<div><dt className="text-slate-500 dark:text-slate-400">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
<div><dt className="text-slate-500 dark:text-slate-400">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
<div><dt className="text-muted">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
<div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
</dl>
</Card>
</div>

View File

@@ -33,12 +33,12 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
<motion.div
animate={reduceMotion ? undefined : { x: ["-4%", "5%", "-4%"], y: [0, 8, 0], scale: [1, 1.05, 1] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
className="absolute -left-24 -top-20 h-52 w-[78%] rounded-[50%] bg-slate-100/30 blur-3xl"
className="absolute -left-24 -top-20 h-52 w-[78%] rounded-full bg-slate-100/25 blur-2xl"
/>
<motion.div
animate={reduceMotion ? undefined : { x: ["8%", "-5%", "8%"], y: [0, -6, 0], scale: [1.04, 1, 1.04] }}
transition={{ duration: 22, repeat: Infinity, ease: "easeInOut" }}
className="absolute -right-24 top-0 h-60 w-[82%] rounded-[50%] bg-slate-300/24 blur-3xl"
className="absolute -right-24 top-0 h-60 w-[82%] rounded-full bg-slate-300/20 blur-2xl"
/>
<div className="absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-slate-100/25 via-slate-200/10 to-transparent" />
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-slate-950/15 to-transparent" />
@@ -48,7 +48,7 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
<motion.div
animate={reduceMotion ? undefined : { scale: [1, 1.08, 1], opacity: [0.4, 0.58, 0.4] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
className="absolute -right-16 -top-20 size-64 rounded-full bg-amber-200/45 blur-3xl"
className="absolute -right-12 -top-16 size-52 rounded-full bg-amber-200/25 blur-2xl"
/>
)}
{mood === "night" && stars.map((star, index) => (
@@ -88,7 +88,7 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
/>
)}
{mood === "cold" && (
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-cyan-100/20 to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-blue-100/20 to-transparent" />
)}
</div>
);

View File

@@ -50,11 +50,9 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: "easeOut" }}
className={`relative isolate overflow-hidden rounded-[2rem] bg-gradient-to-br ${moodGradient(mood)} px-5 py-6 text-white shadow-[0_24px_75px_rgba(15,23,42,0.24)] sm:px-8 sm:py-8 lg:px-10`}
className={`relative isolate overflow-hidden rounded-panel bg-gradient-to-br ${moodGradient(mood)} px-5 py-6 text-white shadow-card sm:px-8 sm:py-8 lg:px-10`}
>
<WeatherEffects station={displayedStation} mood={mood} precipitation10m={currentWeather?.precipitation10m} thunderstorm={currentWeather?.condition === "thunderstorm"} />
<div className="absolute -right-20 -top-20 size-72 rounded-full bg-white/15 blur-3xl" />
<div className="absolute -bottom-24 -left-16 size-72 rounded-full bg-cyan-200/15 blur-3xl" />
<div className="relative z-10">
<div>
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{displayedLocationName}</span>
@@ -74,7 +72,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
</div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
<div>
<div className="text-[5.8rem] font-medium leading-[0.85] tracking-[-0.11em] drop-shadow-[0_10px_24px_rgba(15,23,42,0.16)] sm:text-[8rem]">
<div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]">
{formatTemperature(displayedStation.temperature, language)}
</div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p>
@@ -84,7 +82,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
</div>
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => (
<div key={label} className="rounded-2xl border border-white/20 bg-white/10 p-3.5 backdrop-blur-xl">
<div key={label} className="rounded-card border border-white/20 bg-white/10 p-3.5 backdrop-blur-xl">
<div className="flex items-center gap-2 text-xs text-white/70"><Icon className="size-3.5" />{label}</div>
<p className="mt-2 text-base font-semibold">{value}</p>
</div>