211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import clsx from "clsx";
|
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
|
import { useI18n } from "@/hooks/useI18n";
|
|
import { CityListItem } from "@/lib/dashboard-types";
|
|
|
|
type RiskGroupKey = "high" | "medium" | "low" | "other";
|
|
|
|
const GROUP_STATE_STORAGE_KEY = "polyWeather_sidebar_groups_v1";
|
|
const DEFAULT_EXPANDED_GROUPS: Record<RiskGroupKey, boolean> = {
|
|
high: true,
|
|
medium: true,
|
|
low: false,
|
|
other: false,
|
|
};
|
|
|
|
function toRiskGroup(level?: string): RiskGroupKey {
|
|
if (level === "high" || level === "medium" || level === "low") return level;
|
|
return "other";
|
|
}
|
|
|
|
function normalizeExpandedGroups(
|
|
value: unknown,
|
|
): Record<RiskGroupKey, boolean> {
|
|
if (!value || typeof value !== "object") {
|
|
return DEFAULT_EXPANDED_GROUPS;
|
|
}
|
|
const candidate = value as Partial<Record<RiskGroupKey, unknown>>;
|
|
return {
|
|
high:
|
|
typeof candidate.high === "boolean"
|
|
? candidate.high
|
|
: DEFAULT_EXPANDED_GROUPS.high,
|
|
medium:
|
|
typeof candidate.medium === "boolean"
|
|
? candidate.medium
|
|
: DEFAULT_EXPANDED_GROUPS.medium,
|
|
low:
|
|
typeof candidate.low === "boolean"
|
|
? candidate.low
|
|
: DEFAULT_EXPANDED_GROUPS.low,
|
|
other:
|
|
typeof candidate.other === "boolean"
|
|
? candidate.other
|
|
: DEFAULT_EXPANDED_GROUPS.other,
|
|
};
|
|
}
|
|
|
|
export function CitySidebar() {
|
|
const store = useDashboardStore();
|
|
const { t } = useI18n();
|
|
const selectedCity = store.selectedCity;
|
|
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
|
|
const [expandedGroups, setExpandedGroups] = useState<
|
|
Record<RiskGroupKey, boolean>
|
|
>(DEFAULT_EXPANDED_GROUPS);
|
|
|
|
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
|
|
const aSelected = a.name === selectedCity;
|
|
const bSelected = b.name === selectedCity;
|
|
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
|
const aGroup = toRiskGroup(a.risk_level);
|
|
const bGroup = toRiskGroup(b.risk_level);
|
|
return (
|
|
(riskOrder[aGroup] ?? 3) -
|
|
(riskOrder[bGroup] ?? 3) ||
|
|
a.display_name.localeCompare(b.display_name)
|
|
);
|
|
}), [store.cities, selectedCity]);
|
|
|
|
const groupedCities = useMemo(() => {
|
|
const groups: Record<RiskGroupKey, CityListItem[]> = {
|
|
high: [],
|
|
medium: [],
|
|
low: [],
|
|
other: [],
|
|
};
|
|
sortedCities.forEach((city) => {
|
|
groups[toRiskGroup(city.risk_level)].push(city);
|
|
});
|
|
return groups;
|
|
}, [sortedCities]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedCity) return;
|
|
const selected = store.cities.find((city) => city.name === selectedCity);
|
|
if (!selected) return;
|
|
const groupKey = toRiskGroup(selected.risk_level);
|
|
setExpandedGroups((current) =>
|
|
current[groupKey] ? current : { ...current, [groupKey]: true },
|
|
);
|
|
}, [selectedCity, store.cities]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const raw = window.localStorage.getItem(GROUP_STATE_STORAGE_KEY);
|
|
if (!raw) return;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
setExpandedGroups(normalizeExpandedGroups(parsed));
|
|
} catch {}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(
|
|
GROUP_STATE_STORAGE_KEY,
|
|
JSON.stringify(expandedGroups),
|
|
);
|
|
} catch {}
|
|
}, [expandedGroups]);
|
|
|
|
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
|
|
{ key: "high", label: t("sidebar.group.high") },
|
|
{ key: "medium", label: t("sidebar.group.medium") },
|
|
{ key: "low", label: t("sidebar.group.low") },
|
|
{ key: "other", label: t("sidebar.group.other") },
|
|
];
|
|
|
|
return (
|
|
<nav className="city-list">
|
|
<div className="city-list-header">
|
|
<span>{t("sidebar.title")}</span>
|
|
<span className="city-count">{store.cities.length}</span>
|
|
</div>
|
|
|
|
<div className="city-list-items">
|
|
{groupMeta.map((group) => {
|
|
const citiesInGroup = groupedCities[group.key];
|
|
if (!citiesInGroup.length) return null;
|
|
const expanded = expandedGroups[group.key];
|
|
|
|
return (
|
|
<section
|
|
key={group.key}
|
|
className={clsx("city-group", !expanded && "collapsed")}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="city-group-header"
|
|
aria-expanded={expanded}
|
|
onClick={() =>
|
|
setExpandedGroups((current) => ({
|
|
...current,
|
|
[group.key]: !current[group.key],
|
|
}))
|
|
}
|
|
>
|
|
<span className="city-group-title">{group.label}</span>
|
|
<span className="city-group-meta">
|
|
<span className="city-group-count">{citiesInGroup.length}</span>
|
|
<span className={clsx("city-group-arrow", expanded && "expanded")}>
|
|
▾
|
|
</span>
|
|
</span>
|
|
</button>
|
|
|
|
<div className="city-group-items">
|
|
{citiesInGroup.map((city) => {
|
|
const detail = store.cityDetailsByName[city.name];
|
|
const summary = store.citySummariesByName[city.name];
|
|
const snapshot = detail || summary;
|
|
const isActive = store.selectedCity === city.name;
|
|
|
|
return (
|
|
<button
|
|
key={city.name}
|
|
type="button"
|
|
className={clsx("city-item", isActive && "active")}
|
|
onClick={() => void store.selectCity(city.name)}
|
|
>
|
|
<div className="city-item-main">
|
|
<span className={clsx("risk-dot", city.risk_level)} />
|
|
<span className="city-name-text">{city.display_name}</span>
|
|
<span
|
|
className={clsx(
|
|
"city-temp",
|
|
snapshot?.current?.temp != null && "loaded",
|
|
)}
|
|
>
|
|
{snapshot?.current?.temp != null
|
|
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
|
|
: t("common.na")}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="city-item-info">
|
|
<span className="city-local-time">
|
|
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
|
|
</span>
|
|
<span className="city-max-info">
|
|
{detail?.current?.max_temp_time
|
|
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
|
|
: ""}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|