style: calm down frontend visual system
This commit is contained in:
@@ -4,14 +4,42 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
--background: 210 32% 95%;
|
||||||
|
--foreground: 214 35% 16%;
|
||||||
|
--surface: 210 35% 99%;
|
||||||
|
--surface-muted: 210 26% 92%;
|
||||||
|
--surface-raised: 0 0% 100%;
|
||||||
|
--border: 214 20% 82%;
|
||||||
|
--muted: 215 14% 43%;
|
||||||
|
--accent: 207 48% 34%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--warning: 38 58% 42%;
|
||||||
|
--chart-temperature: 207 48% 36%;
|
||||||
|
--chart-feels-like: 216 24% 48%;
|
||||||
|
--chart-rainfall: 202 38% 45%;
|
||||||
|
--chart-probability: 214 28% 38%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
--background: 214 37% 9%;
|
||||||
|
--foreground: 210 31% 94%;
|
||||||
|
--surface: 214 33% 13%;
|
||||||
|
--surface-muted: 215 27% 17%;
|
||||||
|
--surface-raised: 214 29% 16%;
|
||||||
|
--border: 214 19% 27%;
|
||||||
|
--muted: 214 15% 70%;
|
||||||
|
--accent: 204 44% 66%;
|
||||||
|
--accent-foreground: 214 37% 9%;
|
||||||
|
--warning: 39 64% 63%;
|
||||||
|
--chart-temperature: 204 44% 66%;
|
||||||
|
--chart-feels-like: 216 22% 73%;
|
||||||
|
--chart-rainfall: 202 42% 68%;
|
||||||
|
--chart-probability: 214 26% 76%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: hsl(var(--border) / 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -21,14 +49,14 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #eef5fb;
|
background: hsl(var(--background));
|
||||||
color: #102238;
|
color: hsl(var(--foreground));
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark body {
|
.dark body {
|
||||||
background: #07111f;
|
background: hsl(var(--background));
|
||||||
color: #edf7ff;
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -40,11 +68,19 @@ select {
|
|||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.glass {
|
.glass {
|
||||||
@apply border border-white/35 bg-white/45 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30;
|
@apply border border-border/70 bg-surface/80 shadow-card backdrop-blur-xl dark:bg-surface/75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-subtle {
|
.glass-subtle {
|
||||||
@apply border border-white/25 bg-white/25 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/20;
|
@apply border border-border/60 bg-surface-muted/65 backdrop-blur-xl dark:bg-surface-muted/55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-control {
|
||||||
|
@apply border border-border/70 bg-surface/80 shadow-soft backdrop-blur-xl dark:bg-surface-muted/70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker {
|
||||||
|
@apply text-xs font-semibold uppercase tracking-[0.16em] text-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-balance {
|
.text-balance {
|
||||||
@@ -58,7 +94,7 @@ select {
|
|||||||
|
|
||||||
@supports (-moz-appearance: none) {
|
@supports (-moz-appearance: none) {
|
||||||
.weather-scrollbar {
|
.weather-scrollbar {
|
||||||
scrollbar-color: rgba(8, 145, 178, 0.72) rgba(14, 116, 144, 0.1);
|
scrollbar-color: hsl(var(--accent) / 0.62) hsl(var(--border) / 0.32);
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,28 +106,27 @@ select {
|
|||||||
|
|
||||||
.weather-scrollbar::-webkit-scrollbar-track {
|
.weather-scrollbar::-webkit-scrollbar-track {
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: rgba(14, 116, 144, 0.1);
|
background: hsl(var(--border) / 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-scrollbar::-webkit-scrollbar-thumb {
|
.weather-scrollbar::-webkit-scrollbar-thumb {
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid hsl(var(--surface) / 0.78);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: linear-gradient(90deg, rgba(8, 145, 178, 0.78), rgba(14, 116, 144, 0.88));
|
background: hsl(var(--accent) / 0.68);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
box-shadow: 0 1px 5px rgba(8, 47, 73, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-scrollbar::-webkit-scrollbar-thumb:hover {
|
.weather-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(90deg, rgba(6, 182, 212, 0.9), rgba(3, 105, 161, 0.95));
|
background: hsl(var(--accent) / 0.82);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .weather-scrollbar::-webkit-scrollbar-track {
|
.dark .weather-scrollbar::-webkit-scrollbar-track {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: hsl(var(--border) / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .weather-scrollbar::-webkit-scrollbar-thumb {
|
.dark .weather-scrollbar::-webkit-scrollbar-thumb {
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: hsl(var(--surface) / 0.72);
|
||||||
background: linear-gradient(90deg, rgba(34, 211, 238, 0.72), rgba(56, 189, 248, 0.82));
|
background: hsl(var(--accent) / 0.7);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
viewportFit: "cover",
|
viewportFit: "cover",
|
||||||
themeColor: [
|
themeColor: [
|
||||||
{ media: "(prefers-color-scheme: light)", color: "#e8f4fb" },
|
{ media: "(prefers-color-scheme: light)", color: "#eef3f7" },
|
||||||
{ media: "(prefers-color-scheme: dark)", color: "#07111f" },
|
{ media: "(prefers-color-scheme: dark)", color: "#0e1722" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
export default function OfflinePage() {
|
export default function OfflinePage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<section className="glass mx-auto mt-12 max-w-lg rounded-[2rem] p-8 text-center">
|
<section className="glass mx-auto mt-12 max-w-lg rounded-panel p-8 text-center">
|
||||||
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-sky-500/10 text-sky-700 dark:text-sky-300"><WifiOff className="size-6" /></div>
|
<div className="mx-auto flex size-14 items-center justify-center rounded-control bg-accent/10 text-accent"><WifiOff className="size-6" /></div>
|
||||||
<h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</h1>
|
<h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</h1>
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("offline.description")}</p>
|
<p className="mt-2 text-sm leading-6 text-muted">{t("offline.description")}</p>
|
||||||
<Link href="/" className="mt-6 inline-flex rounded-full bg-slate-950 px-4 py-2.5 text-sm font-medium text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:bg-white dark:text-slate-950">{t("offline.back")}</Link>
|
<Link href="/" className="mt-6 inline-flex rounded-control bg-foreground px-4 py-2.5 text-sm font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">{t("offline.back")}</Link>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecas
|
|||||||
import type { HourlyForecast } from "@/types/forecast";
|
import type { HourlyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
|
const CHART_COLORS = {
|
||||||
|
grid: "hsl(var(--border) / 0.65)",
|
||||||
|
tooltipBorder: "hsl(var(--border) / 0.75)",
|
||||||
|
tooltipBackground: "hsl(var(--surface-raised) / 0.96)",
|
||||||
|
tooltipText: "hsl(var(--foreground))",
|
||||||
|
temperature: "hsl(var(--chart-temperature))",
|
||||||
|
feelsLike: "hsl(var(--chart-feels-like))",
|
||||||
|
rainfall: "hsl(var(--chart-rainfall))",
|
||||||
|
probability: "hsl(var(--chart-probability))",
|
||||||
|
};
|
||||||
|
|
||||||
function formatHour(value: string) {
|
function formatHour(value: string) {
|
||||||
return value.slice(11, 16);
|
return value.slice(11, 16);
|
||||||
@@ -30,16 +40,16 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
<div className="mt-4 h-56 min-w-0">
|
<div className="mt-4 h-56 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||||
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
||||||
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
|
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
||||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" />
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }}
|
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||||
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]}
|
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||||
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke="#0284c7" strokeWidth={3} dot={false} connectNulls />
|
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
|
||||||
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke="#818cf8" strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
|
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,12 +61,12 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
<div className="mt-4 h-56 min-w-0">
|
<div className="mt-4 h-56 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||||
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
|
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
|
||||||
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
|
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
||||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||||
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
|
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
|
||||||
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
|
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }}
|
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||||
formatter={(value, name) => [
|
formatter={(value, name) => [
|
||||||
name === t("forecast.precipitation")
|
name === t("forecast.precipitation")
|
||||||
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
||||||
@@ -65,8 +75,8 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||||
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill="#38bdf8" radius={[5, 5, 0, 0]} />
|
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill={CHART_COLORS.rainfall} radius={[5, 5, 0, 0]} />
|
||||||
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke="#6366f1" strokeWidth={2} dot={false} connectNulls />
|
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke={CHART_COLORS.probability} strokeWidth={2} dot={false} connectNulls />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,26 +6,31 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
|
const SNAPSHOT_COLORS = [
|
||||||
|
"hsl(var(--chart-temperature))",
|
||||||
|
"hsl(var(--chart-feels-like))",
|
||||||
|
"hsl(var(--chart-rainfall))",
|
||||||
|
];
|
||||||
|
|
||||||
export function SnapshotChart({ station }: { station: SynopStation }) {
|
export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const rows = [
|
const rows = [
|
||||||
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: "#38bdf8" },
|
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
|
||||||
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: "#818cf8" },
|
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: SNAPSHOT_COLORS[1] },
|
||||||
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: "#22d3ee" },
|
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
|
||||||
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("snapshot.label")}</p>
|
<p className="section-kicker">{t("snapshot.label")}</p>
|
||||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
|
||||||
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p>
|
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
|
||||||
<div className="mt-5 h-52 min-w-0">
|
<div className="mt-5 h-52 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||||
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
||||||
<XAxis type="number" hide domain={[0, 100]} />
|
<XAxis type="number" hide domain={[0, 100]} />
|
||||||
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
||||||
<Tooltip cursor={{ fill: "rgba(148,163,184,0.08)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
|
<Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
|
||||||
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
|
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
|
||||||
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
|
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ function getMaximumWind(hours: HourlyForecast[]) {
|
|||||||
|
|
||||||
function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
|
||||||
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
|
<Icon className="size-4 text-accent" />
|
||||||
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</p>
|
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{label}</p>
|
||||||
<p className="mt-1 text-base font-semibold">{value}</p>
|
<p className="mt-1 text-base font-semibold">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -84,7 +84,7 @@ export function DayForecastModal({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{day ? (
|
{day ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/55 p-0 backdrop-blur-md sm:p-4 lg:p-8"
|
className="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/55 p-0 backdrop-blur-sm sm:p-4 lg:p-8"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -94,7 +94,7 @@ export function DayForecastModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="day-forecast-title"
|
aria-labelledby="day-forecast-title"
|
||||||
className="weather-scrollbar h-full w-full overflow-y-auto bg-gradient-to-b from-sky-100/95 via-slate-100/95 to-white/95 shadow-2xl dark:from-slate-900/95 dark:via-slate-950/95 dark:to-slate-950/95 sm:max-w-6xl sm:rounded-[2rem] sm:border sm:border-white/30 dark:sm:border-white/10"
|
className="weather-scrollbar h-full w-full overflow-y-auto bg-background shadow-card sm:max-w-6xl sm:rounded-panel sm:border sm:border-border/70"
|
||||||
initial={{ opacity: 0, y: 28, scale: 0.985 }}
|
initial={{ opacity: 0, y: 28, scale: 0.985 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.99 }}
|
exit={{ opacity: 0, y: 20, scale: 0.99 }}
|
||||||
@@ -104,32 +104,32 @@ export function DayForecastModal({
|
|||||||
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
|
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">
|
<p className="section-kicker flex items-center gap-2">
|
||||||
<CloudSun className="size-4" />
|
<CloudSun className="size-4" />
|
||||||
{t("forecast.dayDetails")}
|
{t("forecast.dayDetails")}
|
||||||
</p>
|
</p>
|
||||||
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
|
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
|
||||||
<p className="mt-1 capitalize text-slate-600 dark:text-slate-300">{formattedDate}</p>
|
<p className="mt-1 capitalize text-muted">{formattedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
ref={closeButtonRef}
|
ref={closeButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("forecast.closeDetails")}
|
aria-label={t("forecast.closeDetails")}
|
||||||
className="rounded-full border border-white/35 bg-white/35 p-3 text-slate-700 transition hover:bg-white/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-100 dark:hover:bg-white/20"
|
className="surface-control rounded-control p-3 text-foreground transition hover:bg-surface-raised/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden bg-gradient-to-br from-sky-500/20 via-white/25 to-indigo-400/15 p-5 dark:from-sky-700/20 dark:via-white/5 dark:to-indigo-500/15 sm:p-6">
|
<Card className="overflow-hidden bg-surface-raised/70 p-5 sm:p-6">
|
||||||
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)}</p>
|
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
|
||||||
<div className="mt-2 flex items-end gap-4">
|
<div className="mt-2 flex items-end gap-4">
|
||||||
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-sky-700 dark:text-sky-300" />
|
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
|
||||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p>
|
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p>
|
||||||
<p className="mb-2 text-2xl text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</p>
|
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
||||||
@@ -151,15 +151,15 @@ export function DayForecastModal({
|
|||||||
<li
|
<li
|
||||||
key={hour.time}
|
key={hour.time}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[5.2rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5",
|
"w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center",
|
||||||
isPast && "opacity-45",
|
isPast && "opacity-45",
|
||||||
)}
|
)}
|
||||||
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
|
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||||
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
|
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
|
||||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
||||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300">
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
||||||
<Droplets className="size-3" />
|
<Droplets className="size-3" />
|
||||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ function getTotal(values: Array<number | null>) {
|
|||||||
|
|
||||||
function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
|
||||||
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
|
<Icon className="size-4 text-accent" />
|
||||||
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">{label}</p>
|
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-muted">{label}</p>
|
||||||
<p className="mt-1 text-sm font-semibold">{value}</p>
|
<p className="mt-1 text-sm font-semibold">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -69,8 +69,8 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`;
|
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-auto hidden border-t border-white/30 pt-4 dark:border-white/10 lg:block">
|
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
|
||||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{t("forecast.nextHoursOverview")}</p>
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p>
|
||||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||||
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
|
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
|
||||||
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} />
|
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} />
|
||||||
@@ -89,23 +89,23 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
|
|||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
|
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
|
||||||
className="border-t border-white/30 first:border-t-0 dark:border-white/10"
|
className="border-t border-border/65 first:border-t-0"
|
||||||
>
|
>
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
whileTap={{ scale: 0.99 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
aria-label={t("forecast.openDayDetails", { day: label })}
|
aria-label={t("forecast.openDayDetails", { day: label })}
|
||||||
className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-xl px-1 py-3 text-left transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:hover:bg-white/5 sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]"
|
className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-card px-1 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]"
|
||||||
onClick={() => onSelect(day)}
|
onClick={() => onSelect(day)}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold capitalize">{label}</p>
|
<p className="text-sm font-semibold capitalize">{label}</p>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
|
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
|
||||||
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||||
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
||||||
<ChevronRight className="hidden size-4 text-slate-400 sm:block" />
|
<ChevronRight className="hidden size-4 text-muted sm:block" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.li>
|
</motion.li>
|
||||||
);
|
);
|
||||||
@@ -123,9 +123,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><CloudSun className="size-4" />{t("forecast.label")}</p>
|
<p className="section-kicker flex items-center gap-2"><CloudSun className="size-4" />{t("forecast.label")}</p>
|
||||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
|
||||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("forecast.description", { location: locationName })}</p>
|
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">{t("forecast.description", { location: locationName })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
|
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
|
||||||
@@ -144,7 +144,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
|
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
|
||||||
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
|
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-sky-600 dark:text-sky-300" />{t("forecast.hourly")}</h3>
|
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-accent" />{t("forecast.hourly")}</h3>
|
||||||
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5">
|
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5">
|
||||||
<ul className="flex min-w-max gap-2">
|
<ul className="flex min-w-max gap-2">
|
||||||
{upcomingHours.map((hour, index) => (
|
{upcomingHours.map((hour, index) => (
|
||||||
@@ -153,24 +153,24 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
|
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
|
||||||
className="w-[4.6rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5 lg:w-[5.5rem] lg:py-4"
|
className="w-[4.6rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center lg:w-[5.5rem] lg:py-4"
|
||||||
title={getForecastCondition(hour.weatherCode, language)}
|
title={getForecastCondition(hour.weatherCode, language)}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||||
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
|
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
|
||||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
||||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
||||||
<div className="mt-3 hidden space-y-1.5 border-t border-white/35 pt-3 text-[0.66rem] text-slate-600 dark:border-white/10 dark:text-slate-300 lg:block">
|
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
|
||||||
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
||||||
<ThermometerSun className="size-3 text-sky-600 dark:text-sky-300" />
|
<ThermometerSun className="size-3 text-accent" />
|
||||||
{formatForecastTemperature(hour.feelsLike, language)}
|
{formatForecastTemperature(hour.feelsLike, language)}
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
|
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
|
||||||
<Wind className="size-3 text-sky-600 dark:text-sky-300" />
|
<Wind className="size-3 text-accent" />
|
||||||
{formatForecastWind(hour.windSpeed, language)}
|
{formatForecastWind(hour.windSpeed, language)}
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
|
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
|
||||||
<CloudRain className="size-3 text-sky-600 dark:text-sky-300" />
|
<CloudRain className="size-3 text-accent" />
|
||||||
{formatForecastRainfall(hour.precipitation, language)}
|
{formatForecastRainfall(hour.precipitation, language)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +181,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
<HourlyForecastSummary hours={upcomingHours} />
|
<HourlyForecastSummary hours={upcomingHours} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 sm:p-5">
|
<Card className="p-4 sm:p-5">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-sky-600 dark:text-sky-300" />{t("forecast.daily")}</h3>
|
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-accent" />{t("forecast.daily")}</h3>
|
||||||
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
|
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
|
|||||||
const hasImgw = sources.includes("imgw-alaro");
|
const hasImgw = sources.includes("imgw-alaro");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className="text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400">
|
<p className="text-[0.68rem] leading-5 text-muted">
|
||||||
{t("forecast.source")}{" "}
|
{t("forecast.source")}{" "}
|
||||||
{hasImgw && (
|
{hasImgw && (
|
||||||
<>
|
<>
|
||||||
<a href="https://meteo.imgw.pl/pogoda/" 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">
|
<a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||||
IMGW ALARO <ExternalLink className="size-3" />
|
IMGW ALARO <ExternalLink className="size-3" />
|
||||||
</a>
|
</a>
|
||||||
{", "}
|
{", "}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<a href="https://open-meteo.com/" 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">
|
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
|
||||||
Open-Meteo <ExternalLink className="size-3" />
|
Open-Meteo <ExternalLink className="size-3" />
|
||||||
</a>
|
</a>
|
||||||
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}
|
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}
|
||||||
|
|||||||
@@ -27,16 +27,16 @@ export function HydroPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">{t("hydro.section")}</p>
|
<p className="section-kicker">{t("hydro.section")}</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("hydro.title")}</h1>
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("hydro.title")}</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("hydro.description")}</p>
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("hydro.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="glass relative block rounded-[1.5rem] p-3">
|
<label className="glass relative block rounded-panel p-3">
|
||||||
<span className="sr-only">{t("hydro.searchLabel")}</span>
|
<span className="sr-only">{t("hydro.searchLabel")}</span>
|
||||||
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-slate-500" />
|
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" />
|
||||||
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-2xl border border-white/40 bg-white/45 py-3 pl-10 pr-4 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/5" />
|
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" />
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
|
<p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
|
||||||
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
|
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
|||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
|
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
|
||||||
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-white/60 dark:hover:bg-slate-900/45">
|
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold tracking-tight">{station.name}</h2>
|
<h2 className="font-semibold tracking-tight">{station.name}</h2>
|
||||||
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p>
|
<p className="mt-1 flex items-center gap-1 text-xs text-muted"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||||
@@ -23,7 +23,7 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
|||||||
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} />
|
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} />
|
||||||
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
|
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-[0.7rem] text-slate-500 dark:text-slate-400">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
|
<p className="mt-4 text-[0.7rem] text-muted">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
);
|
);
|
||||||
@@ -31,8 +31,8 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
|||||||
|
|
||||||
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl bg-white/35 p-2.5 dark:bg-white/5">
|
<div className="rounded-card bg-surface-muted/60 p-2.5">
|
||||||
<p className="flex items-center gap-1 text-[0.65rem] text-slate-500 dark:text-slate-400"><Icon className="size-3" />{label}</p>
|
<p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p>
|
||||||
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
|
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.28),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(129,140,248,0.18),transparent_31%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(14,116,144,0.22),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(49,46,129,0.22),transparent_31%)]">
|
<div className="min-h-screen overflow-x-hidden bg-background">
|
||||||
<header className="sticky top-0 z-40 border-b border-white/25 bg-white/30 backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30">
|
<header className="sticky top-0 z-40 border-b border-border/70 bg-surface/80 backdrop-blur-xl dark:bg-background/80">
|
||||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||||
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-white">
|
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||||
wtr<span className="text-sky-600 dark:text-sky-300">.</span>
|
wtr<span className="text-accent">.</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
|
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} href={item.href} className={cn("rounded-full px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-white/60 text-slate-950 shadow-sm dark:bg-white/15 dark:text-white" : "text-slate-600 hover:bg-white/35 dark:text-slate-300 dark:hover:bg-white/10")}>
|
<Link key={item.href} href={item.href} className={cn("rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70")}>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -40,12 +40,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-7xl px-4 pb-28 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
|
<main className="mx-auto max-w-7xl px-4 pb-28 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
|
||||||
<nav aria-label={t("nav.mobile")} className="fixed inset-x-3 bottom-3 z-50 flex justify-around rounded-[1.4rem] border border-white/40 bg-white/65 p-1.5 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65 md:hidden">
|
<nav aria-label={t("nav.mobile")} className="fixed inset-x-3 bottom-3 z-50 flex justify-around rounded-panel border border-border/70 bg-surface/90 p-1.5 shadow-card backdrop-blur-xl dark:bg-surface/90 md:hidden">
|
||||||
{NAV_ITEMS.map((item, index) => {
|
{NAV_ITEMS.map((item, index) => {
|
||||||
const Icon = icons[index];
|
const Icon = icons[index];
|
||||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-2xl px-3 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-slate-950 text-white dark:bg-white dark:text-slate-950" : "text-slate-600 dark:text-slate-300")}>
|
<Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-card px-3 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { Card } from "@/components/ui/card";
|
|||||||
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) {
|
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) {
|
||||||
return (
|
return (
|
||||||
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="mb-4 rounded-full bg-emerald-500/10 p-3 text-emerald-600 dark:text-emerald-300"><Icon className="size-6" /></div>
|
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent"><Icon className="size-6" /></div>
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
<p className="mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description}</p>
|
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export function ErrorState({ title, description, onRetry }: { title?: string; de
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="mb-4 rounded-full bg-amber-500/15 p-3 text-amber-700 dark:text-amber-300"><TriangleAlert className="size-6" /></div>
|
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning"><TriangleAlert className="size-6" /></div>
|
||||||
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
|
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
|
||||||
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description ?? t("error.description")}</p>
|
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-muted">{description ?? t("error.description")}</p>
|
||||||
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
|
|
||||||
export function LoadingSkeleton({ className = "" }: { className?: string }) {
|
export function LoadingSkeleton({ className = "" }: { className?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return <div className={cn("animate-pulse rounded-[1.75rem] bg-white/40 dark:bg-white/10", className)} aria-label={t("common.loading")} />;
|
return <div className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)} aria-label={t("common.loading")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageLoadingSkeleton() {
|
export function PageLoadingSkeleton() {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 rounded-full text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center gap-2 rounded-control text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-slate-950 px-4 py-2.5 text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200",
|
default: "bg-foreground px-4 py-2.5 text-background hover:opacity-90",
|
||||||
glass: "border border-white/30 bg-white/30 px-4 py-2.5 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
|
glass: "surface-control px-4 py-2.5 text-foreground hover:bg-surface-raised/90",
|
||||||
ghost: "px-3 py-2 text-slate-700 hover:bg-white/50 dark:text-slate-200 dark:hover:bg-white/10",
|
ghost: "px-3 py-2 text-muted hover:bg-surface-muted/70 hover:text-foreground",
|
||||||
icon: "size-10 border border-white/30 bg-white/30 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
|
icon: "surface-control size-10 text-foreground hover:bg-surface-raised/90",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "default" },
|
defaultVariants: { variant: "default" },
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import type { HTMLAttributes } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("glass rounded-[1.75rem]", className)} {...props} />;
|
return <div className={cn("glass rounded-panel", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export function LanguageToggle() {
|
|||||||
return (
|
return (
|
||||||
<label className="relative flex items-center">
|
<label className="relative flex items-center">
|
||||||
<span className="sr-only">{t("language.label")}</span>
|
<span className="sr-only">{t("language.label")}</span>
|
||||||
<Languages className="pointer-events-none absolute left-3 size-4 text-slate-700 dark:text-slate-200" />
|
<Languages className="pointer-events-none absolute left-3 size-4 text-muted" />
|
||||||
<select
|
<select
|
||||||
aria-label={t("language.label")}
|
aria-label={t("language.label")}
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(event) => setLanguage(event.target.value as Language)}
|
onChange={(event) => setLanguage(event.target.value as Language)}
|
||||||
className="h-10 appearance-none rounded-full border border-white/30 bg-white/30 py-2 pl-9 pr-3 text-xs font-semibold uppercase text-slate-800 backdrop-blur-xl transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20"
|
className="surface-control h-10 appearance-none rounded-control py-2 pl-9 pr-3 text-xs font-semibold uppercase text-foreground transition hover:bg-surface-raised/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<option value="pl">{t("language.polish")}</option>
|
<option value="pl">{t("language.polish")}</option>
|
||||||
<option value="en">{t("language.english")}</option>
|
<option value="en">{t("language.english")}</option>
|
||||||
|
|||||||
@@ -73,28 +73,28 @@ export function DashboardWarnings() {
|
|||||||
return (
|
return (
|
||||||
<motion.section
|
<motion.section
|
||||||
aria-label={t("warnings.dashboard.title")}
|
aria-label={t("warnings.dashboard.title")}
|
||||||
className="rounded-[1.75rem] border border-amber-200/60 bg-amber-50/55 p-4 shadow-[0_18px_50px_-34px_rgba(146,64,14,0.45)] backdrop-blur-xl dark:border-amber-300/15 dark:bg-amber-950/15 sm:p-5"
|
className="rounded-panel border border-warning/25 bg-warning/10 p-4 shadow-soft backdrop-blur-xl sm:p-5"
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="rounded-full border border-amber-300/60 bg-amber-100/70 p-2 text-amber-700 dark:border-amber-300/20 dark:bg-amber-300/10 dark:text-amber-200">
|
<div className="rounded-control border border-warning/30 bg-warning/10 p-2 text-warning">
|
||||||
<AlertTriangle className="size-4" aria-hidden="true" />
|
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-amber-800/70 dark:text-amber-100/65">
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
|
||||||
IMGW · {formatProvinceName(province, language)}
|
IMGW · {formatProvinceName(province, language)}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-1 text-base font-semibold text-slate-900 dark:text-white">
|
<h2 className="mt-1 text-base font-semibold text-foreground">
|
||||||
{t("warnings.dashboard.title")}
|
{t("warnings.dashboard.title")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/warnings"
|
href="/warnings"
|
||||||
className="inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-1 text-xs font-semibold text-amber-800 transition hover:bg-amber-100/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 dark:text-amber-100 dark:hover:bg-amber-300/10"
|
className="inline-flex w-fit items-center gap-1.5 rounded-control px-2 py-1 text-xs font-semibold text-warning transition hover:bg-warning/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-warning"
|
||||||
>
|
>
|
||||||
{t("warnings.dashboard.viewAll")}
|
{t("warnings.dashboard.viewAll")}
|
||||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||||
@@ -111,17 +111,17 @@ export function DashboardWarnings() {
|
|||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={warning.id}
|
key={warning.id}
|
||||||
className="rounded-2xl border border-amber-200/60 bg-white/45 px-3.5 py-3 dark:border-amber-200/10 dark:bg-slate-950/15"
|
className="rounded-card border border-warning/20 bg-surface/60 px-3.5 py-3"
|
||||||
>
|
>
|
||||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-amber-800/70 dark:text-amber-100/65">
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
|
||||||
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
|
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
|
||||||
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}
|
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
|
<p className="mt-1 text-sm font-semibold text-foreground">
|
||||||
{warning.title || t("warnings.genericMeteo")}
|
{warning.title || t("warnings.genericMeteo")}
|
||||||
</p>
|
</p>
|
||||||
{validityLabel && (
|
{validityLabel && (
|
||||||
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-slate-600 dark:text-slate-300">
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted">
|
||||||
<CalendarClock className="size-3.5" aria-hidden="true" />
|
<CalendarClock className="size-3.5" aria-hidden="true" />
|
||||||
{validityLabel}
|
{validityLabel}
|
||||||
</p>
|
</p>
|
||||||
@@ -132,7 +132,7 @@ export function DashboardWarnings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hiddenWarningsCount > 0 && (
|
{hiddenWarningsCount > 0 && (
|
||||||
<p className="mt-3 text-xs font-medium text-amber-800/75 dark:text-amber-100/70">
|
<p className="mt-3 text-xs font-medium text-warning">
|
||||||
{t("warnings.dashboard.more", { count: hiddenWarningsCount })}
|
{t("warnings.dashboard.more", { count: hiddenWarningsCount })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
|
|||||||
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
|
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
|
||||||
<Card className="h-full overflow-hidden p-5">
|
<Card className="h-full overflow-hidden p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="rounded-2xl bg-amber-500/15 p-2.5 text-amber-700 dark:text-amber-300"><Icon className="size-5" /></div>
|
<div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div>
|
||||||
<span className={cn("rounded-full border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-orange-300/40 bg-orange-400/15 text-orange-800 dark:text-orange-200" : "border-amber-300/40 bg-amber-400/15 text-amber-800 dark:text-amber-200")}>{levelLabel}</span>
|
<span className={cn("rounded-control border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-warning/30 bg-warning/10 text-warning" : "border-warning/25 bg-warning/10 text-warning")}>{levelLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-slate-500 dark:text-slate-400">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
|
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
|
||||||
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
|
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
|
||||||
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-slate-600 dark:text-slate-300">{warning.description}</p>}
|
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>}
|
||||||
<div className="mt-5 space-y-2 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-5 space-y-2 text-xs text-muted">
|
||||||
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} — {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
|
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} — {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
|
||||||
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
|
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
|
||||||
</div>
|
</div>
|
||||||
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-slate-600 dark:text-slate-300">{t("warnings.probability", { value: warning.probability })}</p>}
|
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-muted">{t("warnings.probability", { value: warning.probability })}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export function WarningsPageContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("warnings.section")}</p>
|
<p className="section-kicker">{t("warnings.section")}</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("warnings.title")}</h1>
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("warnings.title")}</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.description")}</p>
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("warnings.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<WarningsPanel />
|
<WarningsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export function WarningsPanel() {
|
|||||||
<div className="space-y-9">
|
<div className="space-y-9">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<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("warnings.myProvince")}</p>
|
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p>
|
||||||
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{provinceLabel}</h2>
|
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{provinceLabel}</h2>
|
||||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.myProvinceDescription", { province: provinceLabel })}</p>
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: provinceLabel })}</p>
|
||||||
</div>
|
</div>
|
||||||
{localWarnings.length
|
{localWarnings.length
|
||||||
? <WarningGrid warnings={localWarnings} />
|
? <WarningGrid warnings={localWarnings} />
|
||||||
@@ -52,8 +52,8 @@ export function WarningsPanel() {
|
|||||||
{otherWarnings.length > 0 && (
|
{otherWarnings.length > 0 && (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400"><Map className="size-4" />{t("warnings.otherRegions")}</p>
|
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted"><Map className="size-4" />{t("warnings.otherRegions")}</p>
|
||||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.otherRegionsDescription")}</p>
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />
|
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
|||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{showPrompt && (
|
{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="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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
|
<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>
|
<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-amber-700 dark:text-amber-300"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</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">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
|
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
|
||||||
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
{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 ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||||
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
|
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
<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-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">OpenStreetMap <ExternalLink className="size-3" /></a>
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<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>
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
|
<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"
|
type="button"
|
||||||
key={station.id}
|
key={station.id}
|
||||||
onClick={() => selectStation(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="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 className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language)}</span>
|
||||||
</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative z-30">
|
<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">
|
<div className="flex items-center gap-2 px-1 pb-3">
|
||||||
<MapPin className="size-4 text-sky-700 dark:text-sky-300" />
|
<MapPin className="size-4 text-accent" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p>
|
<p className="section-kicker">{t("location.label")}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative block">
|
<label className="relative block">
|
||||||
<span className="sr-only">{t("location.searchLabel")}</span>
|
<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
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
@@ -41,25 +41,25 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
||||||
placeholder={t("location.placeholder")}
|
placeholder={t("location.placeholder")}
|
||||||
autoComplete="off"
|
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>
|
</label>
|
||||||
<CurrentLocationControl stations={locatedStations} />
|
<CurrentLocationControl stations={locatedStations} />
|
||||||
{selectedLocation && (
|
{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 })}
|
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400">
|
<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-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo / GeoNames</a>
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{showSuggestions && (
|
{showSuggestions && (
|
||||||
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass">
|
<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-slate-600 dark:text-slate-300">{t("location.preparing")}</p> :
|
{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-slate-600 dark:text-slate-300">{t("location.error")}</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-slate-600 dark:text-slate-300">{t("location.empty")}</p> :
|
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
|
||||||
suggestions.map(({ location, nearest }) => nearest && (
|
suggestions.map(({ location, nearest }) => nearest && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,13 +69,13 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
setIsFocused(false);
|
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>
|
||||||
<span className="block text-sm font-semibold">{location.name}</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>
|
||||||
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { ic
|
|||||||
return (
|
return (
|
||||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
|
<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">
|
<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">
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
|
||||||
<Icon className="size-4 text-sky-600 dark:text-sky-300" />
|
<Icon className="size-4 text-accent" />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-xl font-semibold tracking-tight">{value}</p>
|
<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>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,20 +22,20 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
|
|||||||
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`;
|
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`;
|
||||||
return (
|
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 }}>
|
<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">
|
<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="truncate text-sm font-semibold">{station.name}</p>
|
||||||
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p>
|
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<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)}>
|
<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")} />
|
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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"><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"><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>
|
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function StationDetailPage({ id }: { id: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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)}>
|
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
|
||||||
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
|
||||||
{favorite ? t("favorites.remove") : t("favorites.add")}
|
{favorite ? t("favorites.remove") : t("favorites.add")}
|
||||||
@@ -35,20 +35,20 @@ export function StationDetailPage({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<WeatherHero station={station} />
|
<WeatherHero station={station} />
|
||||||
<section>
|
<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>
|
<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} />
|
<WeatherDetailsGrid station={station} />
|
||||||
</section>
|
</section>
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
|
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
<SnapshotChart station={station} />
|
<SnapshotChart station={station} />
|
||||||
<Card className="p-5">
|
<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>
|
<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">
|
<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-muted">{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.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
|
|||||||
<motion.div
|
<motion.div
|
||||||
animate={reduceMotion ? undefined : { x: ["-4%", "5%", "-4%"], y: [0, 8, 0], scale: [1, 1.05, 1] }}
|
animate={reduceMotion ? undefined : { x: ["-4%", "5%", "-4%"], y: [0, 8, 0], scale: [1, 1.05, 1] }}
|
||||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
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
|
<motion.div
|
||||||
animate={reduceMotion ? undefined : { x: ["8%", "-5%", "8%"], y: [0, -6, 0], scale: [1.04, 1, 1.04] }}
|
animate={reduceMotion ? undefined : { x: ["8%", "-5%", "8%"], y: [0, -6, 0], scale: [1.04, 1, 1.04] }}
|
||||||
transition={{ duration: 22, repeat: Infinity, ease: "easeInOut" }}
|
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 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" />
|
<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
|
<motion.div
|
||||||
animate={reduceMotion ? undefined : { scale: [1, 1.08, 1], opacity: [0.4, 0.58, 0.4] }}
|
animate={reduceMotion ? undefined : { scale: [1, 1.08, 1], opacity: [0.4, 0.58, 0.4] }}
|
||||||
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
|
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) => (
|
{mood === "night" && stars.map((star, index) => (
|
||||||
@@ -88,7 +88,7 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mood === "cold" && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,11 +50,9 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
|||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.55, ease: "easeOut" }}
|
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"} />
|
<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 className="relative z-10">
|
||||||
<div>
|
<div>
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{displayedLocationName}</span>
|
<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>
|
||||||
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
||||||
<div>
|
<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)}
|
{formatTemperature(displayedStation.temperature, language)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p>
|
<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>
|
||||||
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
|
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
|
||||||
{metrics.map(({ icon: Icon, label, value }) => (
|
{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>
|
<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>
|
<p className="mt-2 text-base font-semibold">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,11 +186,11 @@ export function getWeatherDescription(station: SynopStation, language: Language
|
|||||||
|
|
||||||
export function moodGradient(mood: WeatherMood) {
|
export function moodGradient(mood: WeatherMood) {
|
||||||
return {
|
return {
|
||||||
warm: "from-sky-400 via-blue-500 to-indigo-700",
|
warm: "from-blue-500 via-blue-700 to-slate-900",
|
||||||
cloudy: "from-slate-600 via-slate-700 to-slate-900",
|
cloudy: "from-slate-600 via-slate-700 to-slate-900",
|
||||||
wind: "from-cyan-600 via-slate-600 to-blue-950",
|
wind: "from-slate-500 via-blue-700 to-slate-900",
|
||||||
cold: "from-cyan-300 via-blue-500 to-indigo-900",
|
cold: "from-blue-300 via-slate-500 to-slate-800",
|
||||||
night: "from-slate-800 via-indigo-950 to-slate-950",
|
night: "from-slate-800 via-slate-900 to-slate-950",
|
||||||
mild: "from-sky-500 via-cyan-700 to-blue-900",
|
mild: "from-blue-500 via-slate-700 to-slate-900",
|
||||||
}[mood];
|
}[mood];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,30 @@ export default {
|
|||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||||
|
surface: "hsl(var(--surface) / <alpha-value>)",
|
||||||
|
"surface-muted": "hsl(var(--surface-muted) / <alpha-value>)",
|
||||||
|
"surface-raised": "hsl(var(--surface-raised) / <alpha-value>)",
|
||||||
|
border: "hsl(var(--border) / <alpha-value>)",
|
||||||
|
muted: "hsl(var(--muted) / <alpha-value>)",
|
||||||
|
accent: "hsl(var(--accent) / <alpha-value>)",
|
||||||
|
"accent-foreground": "hsl(var(--accent-foreground) / <alpha-value>)",
|
||||||
|
warning: "hsl(var(--warning) / <alpha-value>)",
|
||||||
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
||||||
},
|
},
|
||||||
|
borderRadius: {
|
||||||
|
panel: "1.5rem",
|
||||||
|
card: "1.25rem",
|
||||||
|
control: "9999px",
|
||||||
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
glass: "0 18px 55px rgba(15, 23, 42, 0.13)",
|
card: "0 16px 42px hsl(215 32% 18% / 0.08)",
|
||||||
|
glass: "0 16px 42px hsl(215 32% 18% / 0.08)",
|
||||||
|
soft: "0 10px 28px hsl(215 32% 18% / 0.06)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user