Fix dashboard auth and map marker cache updates
This commit is contained in:
@@ -1,16 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { LogIn, UserRound } from "lucide-react";
|
import { LogIn, UserRound } from "lucide-react";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import {
|
|
||||||
getSupabaseBrowserClient,
|
|
||||||
hasSupabasePublicEnv,
|
|
||||||
} from "@/lib/supabase/client";
|
|
||||||
|
|
||||||
function parseExpiryInfo(raw?: string | null) {
|
function parseExpiryInfo(raw?: string | null) {
|
||||||
const text = String(raw || "").trim();
|
const text = String(raw || "").trim();
|
||||||
@@ -30,41 +25,12 @@ export function HeaderBar() {
|
|||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale, setLocale, t } = useI18n();
|
const { locale, setLocale, t } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const isAuthenticated = store.proAccess.authenticated;
|
||||||
const supabaseReady = hasSupabasePublicEnv();
|
|
||||||
const docsHref = "/docs/intro";
|
const docsHref = "/docs/intro";
|
||||||
const docsActive = pathname?.startsWith("/docs");
|
const docsActive = pathname?.startsWith("/docs");
|
||||||
const trialPromoLabel =
|
const trialPromoLabel =
|
||||||
locale === "en-US" ? "New users get 3-day Pro trial" : "新用户可免费体验 3 天 Pro";
|
locale === "en-US" ? "New users get 3-day Pro trial" : "新用户可免费体验 3 天 Pro";
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
if (!supabaseReady) {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = getSupabaseBrowserClient();
|
|
||||||
|
|
||||||
void supabase.auth.getSession().then(({ data }) => {
|
|
||||||
if (!mounted) return;
|
|
||||||
setIsAuthenticated(Boolean(data.session?.user?.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { subscription },
|
|
||||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
|
||||||
if (!mounted) return;
|
|
||||||
setIsAuthenticated(Boolean(session?.user?.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, [supabaseReady]);
|
|
||||||
|
|
||||||
const accountHref = isAuthenticated
|
const accountHref = isAuthenticated
|
||||||
? "/account"
|
? "/account"
|
||||||
: "/auth/login?next=%2Faccount";
|
: "/auth/login?next=%2Faccount";
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect } from "react";
|
||||||
import styles from "./Dashboard.module.css";
|
import styles from "./Dashboard.module.css";
|
||||||
import {
|
import {
|
||||||
DashboardStoreProvider,
|
DashboardStoreProvider,
|
||||||
@@ -47,11 +46,6 @@ function DashboardScreen() {
|
|||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void import("@/components/dashboard/HistoryModal");
|
|
||||||
void import("@/components/dashboard/FutureForecastModal");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
|
|||||||
@@ -133,24 +133,19 @@ export function DashboardStoreProvider({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const initialCache = dashboardClient.readCityDetailCacheBundle();
|
const initialCacheRef = useRef<ReturnType<
|
||||||
|
typeof dashboardClient.readCityDetailCacheBundle
|
||||||
|
> | null>(null);
|
||||||
const [cities, setCities] = useState<CityListItem[]>([]);
|
const [cities, setCities] = useState<CityListItem[]>([]);
|
||||||
const [cityDetailsByName, setCityDetailsByName] = useState<
|
const [cityDetailsByName, setCityDetailsByName] = useState<
|
||||||
Record<string, CityDetail>
|
Record<string, CityDetail>
|
||||||
>(() => initialCache.details);
|
>({});
|
||||||
const [citySummariesByName, setCitySummariesByName] = useState<
|
const [citySummariesByName, setCitySummariesByName] = useState<
|
||||||
Record<string, CitySummary>
|
Record<string, CitySummary>
|
||||||
>(() =>
|
>({});
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
|
||||||
cityName,
|
|
||||||
toCitySummary(detail),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
||||||
Record<string, { cachedAt: number; revision: string }>
|
Record<string, { cachedAt: number; revision: string }>
|
||||||
>(() => initialCache.meta);
|
>({});
|
||||||
const [marketScanByCityName, setMarketScanByCityName] = useState<
|
const [marketScanByCityName, setMarketScanByCityName] = useState<
|
||||||
Record<string, MarketScan>
|
Record<string, MarketScan>
|
||||||
>({});
|
>({});
|
||||||
@@ -173,15 +168,9 @@ export function DashboardStoreProvider({
|
|||||||
|
|
||||||
const mapStopMotionRef = useRef<() => void>(() => {});
|
const mapStopMotionRef = useRef<() => void>(() => {});
|
||||||
const hydratedSelectionRef = useRef(false);
|
const hydratedSelectionRef = useRef(false);
|
||||||
|
const hydratedProCacheRef = useRef(false);
|
||||||
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
|
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
|
||||||
const citySummariesRef = useRef<Record<string, CitySummary>>(
|
const citySummariesRef = useRef<Record<string, CitySummary>>({});
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
|
||||||
cityName,
|
|
||||||
toCitySummary(detail),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const selectedDetail =
|
const selectedDetail =
|
||||||
selectedCity && proAccess.subscriptionActive
|
selectedCity && proAccess.subscriptionActive
|
||||||
? cityDetailsByName[selectedCity] || null
|
? cityDetailsByName[selectedCity] || null
|
||||||
@@ -200,11 +189,22 @@ export function DashboardStoreProvider({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (proAccess.loading) return;
|
||||||
|
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
|
||||||
|
dashboardClient.clearCityDetailCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
dashboardClient.writeCityDetailCacheBundle(
|
dashboardClient.writeCityDetailCacheBundle(
|
||||||
cityDetailsByName,
|
cityDetailsByName,
|
||||||
cityDetailMetaByName,
|
cityDetailMetaByName,
|
||||||
);
|
);
|
||||||
}, [cityDetailMetaByName, cityDetailsByName]);
|
}, [
|
||||||
|
cityDetailMetaByName,
|
||||||
|
cityDetailsByName,
|
||||||
|
proAccess.authenticated,
|
||||||
|
proAccess.loading,
|
||||||
|
proAccess.subscriptionActive,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
citySummariesRef.current = citySummariesByName;
|
citySummariesRef.current = citySummariesByName;
|
||||||
@@ -214,6 +214,34 @@ export function DashboardStoreProvider({
|
|||||||
proAccessRef.current = proAccess;
|
proAccessRef.current = proAccess;
|
||||||
}, [proAccess]);
|
}, [proAccess]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (proAccess.loading) return;
|
||||||
|
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
|
||||||
|
hydratedProCacheRef.current = false;
|
||||||
|
initialCacheRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hydratedProCacheRef.current) return;
|
||||||
|
|
||||||
|
hydratedProCacheRef.current = true;
|
||||||
|
const cached =
|
||||||
|
initialCacheRef.current || dashboardClient.readCityDetailCacheBundle();
|
||||||
|
initialCacheRef.current = cached;
|
||||||
|
if (!Object.keys(cached.details).length) return;
|
||||||
|
|
||||||
|
setCityDetailsByName(cached.details);
|
||||||
|
setCityDetailMetaByName(cached.meta);
|
||||||
|
setCitySummariesByName((current) => ({
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(cached.details).map(([cityName, detail]) => [
|
||||||
|
cityName,
|
||||||
|
toCitySummary(detail),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
...current,
|
||||||
|
}));
|
||||||
|
}, [proAccess.authenticated, proAccess.loading, proAccess.subscriptionActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (proAccess.loading) return;
|
if (proAccess.loading) return;
|
||||||
if (proAccess.authenticated && proAccess.subscriptionActive) return;
|
if (proAccess.authenticated && proAccess.subscriptionActive) return;
|
||||||
@@ -636,31 +664,39 @@ export function DashboardStoreProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshAll = async () => {
|
const refreshAll = async () => {
|
||||||
const previousSelectedDetail = selectedCity
|
|
||||||
? cityDetailsByName[selectedCity]
|
|
||||||
: undefined;
|
|
||||||
dashboardClient.clearCityDetailCache();
|
dashboardClient.clearCityDetailCache();
|
||||||
setCityDetailsByName({});
|
setCityDetailsByName({});
|
||||||
setCityDetailMetaByName({});
|
setCityDetailMetaByName({});
|
||||||
if (selectedCity) {
|
if (selectedCity) {
|
||||||
|
const access = proAccessRef.current;
|
||||||
setLoadingState((current) => ({ ...current, refresh: true }));
|
setLoadingState((current) => ({ ...current, refresh: true }));
|
||||||
try {
|
try {
|
||||||
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
|
if (access.authenticated && access.subscriptionActive) {
|
||||||
force: true,
|
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
|
||||||
});
|
force: true,
|
||||||
const detail = latestDetail;
|
});
|
||||||
setCityDetailsByName({ [selectedCity]: detail });
|
const detail = latestDetail;
|
||||||
setCitySummariesByName((current) => ({
|
setCityDetailsByName({ [selectedCity]: detail });
|
||||||
...current,
|
setCitySummariesByName((current) => ({
|
||||||
[selectedCity]: toCitySummary(detail),
|
...current,
|
||||||
}));
|
[selectedCity]: toCitySummary(detail),
|
||||||
setCityDetailMetaByName({
|
}));
|
||||||
[selectedCity]: {
|
setCityDetailMetaByName({
|
||||||
cachedAt: Date.now(),
|
[selectedCity]: {
|
||||||
revision: getCityRevision(detail),
|
cachedAt: Date.now(),
|
||||||
},
|
revision: getCityRevision(detail),
|
||||||
});
|
},
|
||||||
setSelectedForecastDate(detail.local_date);
|
});
|
||||||
|
setSelectedForecastDate(detail.local_date);
|
||||||
|
} else {
|
||||||
|
const summary = await dashboardClient.getCitySummary(selectedCity, {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
setCitySummariesByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedCity]: summary,
|
||||||
|
}));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingState((current) => ({ ...current, refresh: false }));
|
setLoadingState((current) => ({ ...current, refresh: false }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,20 @@ function createMarkerIcon(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMarkerSignature(
|
||||||
|
city: CityListItem,
|
||||||
|
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
city.display_name,
|
||||||
|
city.risk_level,
|
||||||
|
city.temp_unit,
|
||||||
|
city.lat,
|
||||||
|
city.lon,
|
||||||
|
snapshot?.current?.temp ?? "",
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
|
function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
|
||||||
const sanitizeWindText = (value?: string | null) => {
|
const sanitizeWindText = (value?: string | null) => {
|
||||||
const text = String(value || "").trim();
|
const text = String(value || "").trim();
|
||||||
@@ -295,7 +309,7 @@ export function useLeafletMap({
|
|||||||
}, [cities]);
|
}, [cities]);
|
||||||
|
|
||||||
const lastCityDataRef = useRef<
|
const lastCityDataRef = useRef<
|
||||||
Record<string, { temp?: number | null; risk?: string }>
|
Record<string, string>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
// Handle marker synchronization
|
// Handle marker synchronization
|
||||||
@@ -309,29 +323,32 @@ export function useLeafletMap({
|
|||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
const currentMarkers = markersRef.current;
|
const currentMarkers = markersRef.current;
|
||||||
const nextMarkers: typeof currentMarkers = {};
|
const cityNames = new Set(cities.map((city) => city.name));
|
||||||
const nextLastData: typeof lastCityDataRef.current = {};
|
|
||||||
|
Object.entries(currentMarkers).forEach(([name, entry]) => {
|
||||||
|
if (cityNames.has(name)) return;
|
||||||
|
map.removeLayer(entry.marker);
|
||||||
|
delete currentMarkers[name];
|
||||||
|
delete lastCityDataRef.current[name];
|
||||||
|
});
|
||||||
|
|
||||||
cities.forEach((city) => {
|
cities.forEach((city) => {
|
||||||
const detail = cityDetailsByName[city.name];
|
const detail = cityDetailsByName[city.name];
|
||||||
const summary = citySummariesByName[city.name];
|
const summary = citySummariesByName[city.name];
|
||||||
const snapshot = detail || summary;
|
const snapshot = detail || summary;
|
||||||
const existing = currentMarkers[city.name];
|
const existing = currentMarkers[city.name];
|
||||||
|
const signature = getMarkerSignature(city, snapshot);
|
||||||
const currentTemp = snapshot?.current?.temp;
|
const previousSignature = lastCityDataRef.current[city.name];
|
||||||
const currentRisk = city.risk_level;
|
|
||||||
const lastData = lastCityDataRef.current[city.name];
|
|
||||||
const dataChanged =
|
|
||||||
!lastData ||
|
|
||||||
lastData.temp !== currentTemp ||
|
|
||||||
lastData.risk !== currentRisk;
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (dataChanged) {
|
if (existing.city.lat !== city.lat || existing.city.lon !== city.lon) {
|
||||||
|
existing.marker.setLatLng([city.lat, city.lon]);
|
||||||
|
}
|
||||||
|
if (previousSignature !== signature) {
|
||||||
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
||||||
}
|
}
|
||||||
nextMarkers[city.name] = { city, marker: existing.marker };
|
currentMarkers[city.name] = { city, marker: existing.marker };
|
||||||
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
lastCityDataRef.current[city.name] = signature;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,19 +374,9 @@ export function useLeafletMap({
|
|||||||
onSelectCityRef.current(city.name);
|
onSelectCityRef.current(city.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
nextMarkers[city.name] = { city, marker };
|
currentMarkers[city.name] = { city, marker };
|
||||||
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
lastCityDataRef.current[city.name] = signature;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup removed markers
|
|
||||||
Object.entries(currentMarkers).forEach(([name, entry]) => {
|
|
||||||
if (!nextMarkers[name]) {
|
|
||||||
map.removeLayer(entry.marker);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
markersRef.current = nextMarkers;
|
|
||||||
lastCityDataRef.current = nextLastData;
|
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user