mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
refactor(client): read browser preferences through one store hook
Every localStorage-backed preference (name, theme, sound, last region, daily record, hint seen) now goes through lib/storage.js, which never throws and notifies watchers in this tab and others, and components read it with useStoredValue over useSyncExternalStore instead of seeding state in an effect. That fixes the breakpoint pair of theme toggles disagreeing after a click, takes the bare localStorage calls out of the username module and the first-round hint (a blocked-storage browser threw inside the home page's landing effect), and lets the daily replay derive from the stored record rather than from six setState calls in the mount effect. Callback props reach the imperative Leaflet and PhotoSphere handlers through useEffectEvent. The leaderboard modal fetches from the action that changed the board, the search box resets on the prop's edge, the count-up derives its idle values, and the username form mounts with the dialog so it needs no re-seeding. The react-hooks rules that flagged twenty-one warnings are now errors, and nothing violates them.
This commit is contained in:
1 parent
6099f1135a
commit
30cfe63e5f
24 files changed
+448
-321
No files matched your search
+10
-7
@@ -30,13 +30,16 @@ const eslintConfig = [
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
// Next 16 turned these React-Compiler-era rules on as errors. This app's
|
||||
// hits are deliberate: setState-in-effect is the localStorage/hydration
|
||||
// sync pattern (theme, username, count-up), and the ref reads feed
|
||||
// imperative Leaflet/PhotoSphere containers that render nothing. Kept
|
||||
// visible as warnings; revisit if the React Compiler is ever adopted.
|
||||
"react-hooks/set-state-in-effect": "warn",
|
||||
"react-hooks/refs": "warn",
|
||||
// Next 16 turns these React-Compiler-era rules on as errors, and the
|
||||
// code now satisfies them: browser-stored preferences are read through
|
||||
// useSyncExternalStore (lib/use-stored-value.js) rather than seeded in
|
||||
// an effect, and callback props reach imperative Leaflet/PhotoSphere
|
||||
// handlers through useEffectEvent rather than refs written in render.
|
||||
// The one ref written during render (a data snapshot in
|
||||
// debug/coverage/CoverageMap.js) carries an inline disable with its
|
||||
// reason. Kept as errors so the patterns do not creep back.
|
||||
"react-hooks/set-state-in-effect": "error",
|
||||
"react-hooks/refs": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { AlertCircle, ArrowRight, Calendar, Check, Share2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { dailyDay, dailyNumber } from '../../lib/daily-calendar';
|
||||
import { getDailyProgress, currentStreak } from '../../lib/daily-progress';
|
||||
import { getDailyProgress, watchDailyProgress, currentStreak } from '../../lib/daily-progress';
|
||||
import { useStoredValue } from '../../lib/use-stored-value';
|
||||
import { buildDailyShareText, shareText } from '../../lib/share';
|
||||
import { formatDistance } from '../../lib/game';
|
||||
|
||||
const MAX_POINTS = 5;
|
||||
|
||||
// The day never changes under an open page in a way worth subscribing to; a
|
||||
// reload at midnight is the refresh.
|
||||
const subscribeNothing = () => () => {};
|
||||
|
||||
/**
|
||||
* The home page's door to today's daily challenge.
|
||||
*
|
||||
@@ -22,26 +27,19 @@ const MAX_POINTS = 5;
|
||||
* @param {Function} props.onPlayClick Same interception hook as the region rows.
|
||||
*/
|
||||
export default function DailyCard({ onPlayClick }) {
|
||||
const [state, setState] = useState(null);
|
||||
const [shareState, setShareState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const today = dailyDay();
|
||||
const progress = getDailyProgress();
|
||||
setState({
|
||||
today,
|
||||
number: dailyNumber(today),
|
||||
streak: currentStreak(progress, today),
|
||||
played: progress?.day === today ? progress : null,
|
||||
});
|
||||
}, []);
|
||||
// Today's day is browser-only knowledge: the page is prerendered, and a day
|
||||
// computed at build time would be frozen into the HTML and disagree with
|
||||
// the client on every later day. Read through the same store hook as the
|
||||
// record, with null as the server value, so nothing dated renders until
|
||||
// the browser says what day it is.
|
||||
const today = useStoredValue(dailyDay, subscribeNothing, null);
|
||||
const progress = useStoredValue(getDailyProgress, watchDailyProgress, null);
|
||||
|
||||
// Everything below comes from `state` alone. The page is prerendered, so a
|
||||
// fallback computed at render time would freeze the build day's number into
|
||||
// the HTML and mismatch the client's on every later day.
|
||||
const number = state?.number ?? null;
|
||||
const played = state?.played ?? null;
|
||||
const streak = state?.streak ?? 0;
|
||||
const number = today ? dailyNumber(today) : null;
|
||||
const played = today && progress?.day === today ? progress : null;
|
||||
const streak = today ? currentStreak(progress, today) : 0;
|
||||
const shareLabel = shareState === 'copied' ? 'Copied' : shareState === 'failed' ? 'Retry' : 'Share';
|
||||
const ShareIcon = shareState === 'copied' ? Check : shareState === 'failed' ? AlertCircle : Share2;
|
||||
const shareStatus =
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const HINT_STORAGE_KEY = 'vngeoguessr_hint_seen';
|
||||
import { getHintSeen, setHintSeen, watchHintSeen } from '../../lib/first-round-hint';
|
||||
import { useStoredValue } from '../../lib/use-stored-value';
|
||||
|
||||
/**
|
||||
* One-time how-to-play banner, rendered into the panorama pane's top row. All
|
||||
@@ -21,30 +21,18 @@ const HINT_STORAGE_KEY = 'vngeoguessr_hint_seen';
|
||||
* @param {boolean} props.hasGuess True once a guess pin exists this round.
|
||||
*/
|
||||
export default function FirstRoundHint({ hasGuess }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
// Treated as seen on the server, so the banner appears only once the
|
||||
// browser has said it has not been: a frame late beats a hydration mismatch.
|
||||
const seen = useStoredValue(getHintSeen, watchHintSeen, true);
|
||||
|
||||
// Read in an effect: localStorage does not exist on the server, and the
|
||||
// banner appearing a frame late beats a hydration mismatch.
|
||||
// Placing a pin proves the hint has been understood. Recorded, not just
|
||||
// render-hidden on `hasGuess`: the next round clears the guess, and an
|
||||
// already-understood banner must not come back.
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem(HINT_STORAGE_KEY)) setVisible(true);
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
localStorage.setItem(HINT_STORAGE_KEY, '1');
|
||||
};
|
||||
|
||||
// Placing a pin proves the hint has been understood. State must flip too,
|
||||
// not just render-hide on `hasGuess`: the next round clears the guess, and
|
||||
// an already-understood banner must not come back.
|
||||
useEffect(() => {
|
||||
if (hasGuess) {
|
||||
setVisible(false);
|
||||
localStorage.setItem(HINT_STORAGE_KEY, '1');
|
||||
}
|
||||
if (hasGuess) setHintSeen();
|
||||
}, [hasGuess]);
|
||||
|
||||
if (!visible || hasGuess) return null;
|
||||
if (seen || hasGuess) return null;
|
||||
|
||||
return (
|
||||
// role="status" rather than "note": the banner appears after hydration, so
|
||||
@@ -64,7 +52,7 @@ export default function FirstRoundHint({ hasGuess }) {
|
||||
panorama drag instead of dismissing the hint. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
onClick={setHintSeen}
|
||||
aria-label="Dismiss the how-to-play hint"
|
||||
className="pointer-events-auto flex size-11 shrink-0 items-center justify-center rounded-lg border border-border bg-card/95 text-muted-foreground shadow-lg backdrop-blur transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
>
|
||||
|
||||
@@ -12,7 +12,8 @@ import GuessMapPanel from './GuessMapPanel';
|
||||
import RoundResultDialog from './RoundResultDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { generateRandomUsername, getUsername, setUsername } from '../../lib/username';
|
||||
import { generateRandomUsername, getUsername, setUsername, watchUsername } from '../../lib/username';
|
||||
import { useStoredValue } from '../../lib/use-stored-value';
|
||||
import { setLastRegion } from '../../lib/last-region';
|
||||
import { playSound } from '../../lib/audio';
|
||||
// The revealed path comes from /api/guess (the RESOLVED district), not from
|
||||
@@ -20,7 +21,7 @@ import { playSound } from '../../lib/audio';
|
||||
// chose would make the reveal meaningless for a country round.
|
||||
import { getRegion, isRegion, regionName as regionNameOf } from '../../lib/regions';
|
||||
import { dailyDay } from '../../lib/daily-calendar';
|
||||
import { getDailyProgress, saveDailyResult, currentStreak } from '../../lib/daily-progress';
|
||||
import { getDailyProgress, watchDailyProgress, saveDailyResult, currentStreak } from '../../lib/daily-progress';
|
||||
|
||||
// Loaded on demand like the Leaflet map: the viewer drags three.js in with
|
||||
// it, the largest chunk in the app by far, and nothing on the game screen can
|
||||
@@ -97,7 +98,7 @@ async function fetchNewRound(locationCode, currentSessionId, daily) {
|
||||
export default function GameClient({ region, daily = false }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [imageData, setImageData] = useState(null);
|
||||
const [imageDataState, setImageData] = useState(null);
|
||||
// Bumped once per applied round and used as the viewer's key. The image URL
|
||||
// is not enough: a small district can serve the same panorama twice in a
|
||||
// row, and without a remount the viewer never fires 'ready' again, which is
|
||||
@@ -115,15 +116,16 @@ export default function GameClient({ region, daily = false }) {
|
||||
// left an empty screen whose only way out was the browser back button.
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [guessCoordinates, setGuessCoordinates] = useState(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [guessCoordinatesState, setGuessCoordinates] = useState(null);
|
||||
const [showResultState, setShowResult] = useState(false);
|
||||
// Everything a submitted round produced, in one object set in exactly one
|
||||
// place per outcome. null until a round has been submitted; failed:true
|
||||
// marks a round the server never recorded, so the result screen cannot
|
||||
// present a write failure as a confident 99999m miss.
|
||||
const [result, setResult] = useState(null);
|
||||
const [resultState, setResult] = useState(null);
|
||||
const [showDonate, setShowDonate] = useState(false);
|
||||
const [username, setUsernameState] = useState('');
|
||||
// The stored name, kept current by storage rather than copied into state.
|
||||
const username = useStoredValue(getUsername, watchUsername, '');
|
||||
// This visit's tally, client-side only: rounds submitted and points earned
|
||||
// since the page loaded. The result dialog shows leaderboard totals, but
|
||||
// those arrive per-board and per-region; this is the simple "how am I doing
|
||||
@@ -136,7 +138,22 @@ export default function GameClient({ region, daily = false }) {
|
||||
const [mapExpanded, setMapExpanded] = useState(false);
|
||||
// Daily mode only: which day and number this round is, and the streak it
|
||||
// counts toward. null until the round (or the stored result) is known.
|
||||
const [dailyInfo, setDailyInfo] = useState(null);
|
||||
const [dailyInfoState, setDailyInfo] = useState(null);
|
||||
|
||||
// Daily mode: today's finished round, if there is one, straight from the
|
||||
// browser's record. Everything the result screen needs is derived from it,
|
||||
// so reopening /daily replays the round without a fetch and without any
|
||||
// state being seeded in an effect -- and the record written at submit time
|
||||
// takes over from the live round's state seamlessly, being the same data.
|
||||
const progress = useStoredValue(getDailyProgress, watchDailyProgress, null);
|
||||
const replay = daily && progress && progress.day === dailyDay() ? progress : null;
|
||||
const imageData = replay ? { url: replay.imageUrl, isPano: replay.isPano ?? true } : imageDataState;
|
||||
const guessCoordinates = replay ? replay.guessCoordinates : guessCoordinatesState;
|
||||
const result = replay ? replay.result : resultState;
|
||||
const showResult = replay ? true : showResultState;
|
||||
const dailyInfo = replay
|
||||
? { day: replay.day, number: replay.number, streak: replay.streak }
|
||||
: dailyInfoState;
|
||||
|
||||
// What the player picked, resolved through the tree. The page validated the
|
||||
// code before rendering, so the isRegion guard is belt-and-braces rather
|
||||
@@ -210,28 +227,15 @@ export default function GameClient({ region, daily = false }) {
|
||||
}
|
||||
}, [applyRound, daily]);
|
||||
|
||||
// initialLoading starts true and nothing here touches it before the first
|
||||
// await, so this can run from the mount effect without a synchronous
|
||||
// setState.
|
||||
const loadLibrariesAndInitialize = useCallback(async (locationCode) => {
|
||||
if (initializingRef.current) return;
|
||||
initializingRef.current = true;
|
||||
setInitialLoading(true);
|
||||
|
||||
try {
|
||||
const code = locationCode.toUpperCase();
|
||||
|
||||
// Already played today: show that result again rather than dealing a
|
||||
// second attempt. The panorama, the pin and the outcome were all kept.
|
||||
const played = daily ? getDailyProgress() : null;
|
||||
if (played && played.day === dailyDay()) {
|
||||
setDailyInfo({ day: played.day, number: played.number, streak: played.streak });
|
||||
setImageData({ url: played.imageUrl, isPano: played.isPano ?? true });
|
||||
setRoundKey((key) => key + 1);
|
||||
setGuessCoordinates(played.guessCoordinates);
|
||||
setResult(played.result);
|
||||
setShowResult(true);
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
roundEpochRef.current += 1;
|
||||
const loaded = await loadRound(locationCode, null, roundEpochRef.current);
|
||||
// Only a region that actually served a round is worth offering as
|
||||
@@ -247,11 +251,10 @@ export default function GameClient({ region, daily = false }) {
|
||||
}, [loadRound, daily]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
const existingUsername = getUsername();
|
||||
setUsernameState(existingUsername || '');
|
||||
// Already played today: the stored round is on screen, nothing to fetch.
|
||||
if (initialized || replay) return;
|
||||
loadLibrariesAndInitialize(region);
|
||||
}, [region, loadLibrariesAndInitialize, initialized]);
|
||||
}, [region, loadLibrariesAndInitialize, initialized, replay]);
|
||||
|
||||
const submitGameResult = async (guessCoords) => {
|
||||
if (!guessCoords || !sessionId) return null;
|
||||
@@ -263,7 +266,6 @@ export default function GameClient({ region, daily = false }) {
|
||||
if (!playerName) {
|
||||
playerName = generateRandomUsername();
|
||||
setUsername(playerName);
|
||||
setUsernameState(playerName);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -368,12 +370,11 @@ export default function GameClient({ region, daily = false }) {
|
||||
};
|
||||
setResult(outcome);
|
||||
if (daily && dailyInfo) {
|
||||
// Today is done. Stored with everything the result screen needs,
|
||||
// so a revisit shows this rather than a fresh round.
|
||||
const saved = saveDailyResult(
|
||||
// Today is done. Stored with everything the result screen needs;
|
||||
// the derived `replay` above takes over from here.
|
||||
saveDailyResult(
|
||||
dailyInfo.day, dailyInfo.number, outcome, guessCoordinates, imageData.url, imageData.isPano
|
||||
);
|
||||
setDailyInfo({ ...dailyInfo, streak: saved.streak });
|
||||
}
|
||||
if (mountedRef.current) playSound(resultSound(submitted.score ?? 0));
|
||||
} else {
|
||||
@@ -491,7 +492,7 @@ export default function GameClient({ region, daily = false }) {
|
||||
|
||||
// Only the very first load owns the screen. Everything after it keeps the
|
||||
// game chrome mounted -- tearing it down destroys the panorama viewer.
|
||||
if (initialLoading) {
|
||||
if (initialLoading && !replay) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center vn-surface">
|
||||
<div className="text-center space-y-4 animate-fade-in-up" role="status" aria-live="polite">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
@@ -23,14 +23,16 @@ export default function LeaderboardModal({ currentUsername }) {
|
||||
const [leaderboardError, setLeaderboardError] = useState(null);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetchLeaderboard = async (regionCode, type) => {
|
||||
const fetchLeaderboard = async (regionCode, type, fresh) => {
|
||||
const key = `${regionCode}-${type}`;
|
||||
|
||||
// Bump first: a cache hit still supersedes whatever is in flight, and it
|
||||
// has to clear the spinner that request raised or a cached board renders
|
||||
// behind skeletons until an unrelated fetch settles.
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
if (leaderboards[key]) {
|
||||
// `fresh` is the open click, which has just cleared the cache in a state
|
||||
// update this closure cannot see yet.
|
||||
if (!fresh && leaderboards[key]) {
|
||||
setLoadingLeaderboard(false);
|
||||
return;
|
||||
}
|
||||
@@ -60,17 +62,25 @@ export default function LeaderboardModal({ currentUsername }) {
|
||||
|
||||
// Only the board on screen is fetched. Fetching every one was viable at five
|
||||
// cities; at 67 regions it would be 134 requests on a single click.
|
||||
useEffect(() => {
|
||||
if (open && region) fetchLeaderboard(region, activeTypeTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, region, activeTypeTab]);
|
||||
|
||||
// Fetched from the action that puts a board on screen -- opening, changing
|
||||
// region, changing type -- rather than from an effect watching those three.
|
||||
const handleLeaderboardClick = () => {
|
||||
// Cleared on open so a player who just scored does not see their old total.
|
||||
// Kept within a session so switching level or type back is free.
|
||||
setLeaderboards({});
|
||||
setLeaderboardError(null);
|
||||
setOpen(true);
|
||||
fetchLeaderboard(region, activeTypeTab, true);
|
||||
};
|
||||
|
||||
const handleRegionChange = (code) => {
|
||||
setRegion(code);
|
||||
fetchLeaderboard(code, activeTypeTab, false);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type) => {
|
||||
setActiveTypeTab(type);
|
||||
fetchLeaderboard(region, type, false);
|
||||
};
|
||||
|
||||
const rows = leaderboards[`${region}-${activeTypeTab}`] ?? [];
|
||||
@@ -102,7 +112,7 @@ export default function LeaderboardModal({ currentUsername }) {
|
||||
level={level}
|
||||
onLevelChange={setLevel}
|
||||
region={region}
|
||||
onRegionChange={setRegion}
|
||||
onRegionChange={handleRegionChange}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
@@ -113,7 +123,7 @@ export default function LeaderboardModal({ currentUsername }) {
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setActiveTypeTab(type)}
|
||||
onClick={() => handleTypeChange(type)}
|
||||
aria-pressed={activeTypeTab === type}
|
||||
className={`h-11 px-3 text-left text-sm font-semibold capitalize outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:-ring-offset-1 ${
|
||||
index > 0 ? 'border-t border-border' : ''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useEffectEvent, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
// Bundled from the leaflet package rather than fetched from a CDN: a blocked
|
||||
@@ -28,12 +28,11 @@ export default function LeafletMap({
|
||||
const mapRef = useRef(null);
|
||||
const leafletMapRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
const onMapClickRef = useRef(onMapClick);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
// Update refs when callbacks change
|
||||
onMapClickRef.current = onMapClick;
|
||||
onReadyRef.current = onReady;
|
||||
// Effect events: always call the latest callback props without making them
|
||||
// effect dependencies, so a parent re-rendering with a new function identity
|
||||
// does not tear the map down.
|
||||
const emitMapClick = useEffectEvent((coords) => onMapClick?.(coords));
|
||||
const emitReady = useEffectEvent((map) => onReady?.(map));
|
||||
|
||||
useEffect(() => {
|
||||
const initMap = async () => {
|
||||
@@ -71,30 +70,21 @@ export default function LeafletMap({
|
||||
|
||||
// Handle click events
|
||||
map.on('click', (e) => {
|
||||
if (onMapClickRef.current) {
|
||||
// Clear existing markers
|
||||
markersRef.current.forEach(marker => {
|
||||
map.removeLayer(marker);
|
||||
});
|
||||
markersRef.current = [];
|
||||
// Clear existing markers
|
||||
markersRef.current.forEach(marker => {
|
||||
map.removeLayer(marker);
|
||||
});
|
||||
markersRef.current = [];
|
||||
|
||||
// Add new marker
|
||||
const marker = L.marker([e.latlng.lat, e.latlng.lng]).addTo(map);
|
||||
markersRef.current.push(marker);
|
||||
// Add new marker
|
||||
const marker = L.marker([e.latlng.lat, e.latlng.lng]).addTo(map);
|
||||
markersRef.current.push(marker);
|
||||
|
||||
// Call callback
|
||||
onMapClickRef.current({
|
||||
lat: e.latlng.lat,
|
||||
lng: e.latlng.lng
|
||||
});
|
||||
}
|
||||
emitMapClick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
});
|
||||
|
||||
leafletMapRef.current = map;
|
||||
|
||||
if (onReadyRef.current) {
|
||||
onReadyRef.current(map);
|
||||
}
|
||||
emitReady(map);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing Leaflet map:', error);
|
||||
@@ -115,9 +105,7 @@ export default function LeafletMap({
|
||||
markersRef.current = [];
|
||||
// The handle given out by onReady is now a destroyed map; a parent
|
||||
// still holding it would crash on the first pan/zoom call.
|
||||
if (onReadyRef.current) {
|
||||
onReadyRef.current(null);
|
||||
}
|
||||
emitReady(null);
|
||||
}
|
||||
};
|
||||
}, [bbox, center, zoom, zoomPosition]); // Include props used in initialization
|
||||
|
||||
@@ -36,20 +36,21 @@ export default function MapSearchBox({ map, rootCode, expanded }) {
|
||||
const regions = useMemo(() => searchRegions(query, rootCode), [query, rootCode]);
|
||||
|
||||
// A round reset collapses the mobile minimap; a stale query from the last
|
||||
// round should not survive into the next one.
|
||||
useEffect(() => {
|
||||
// round should not survive into the next one. Adjusted during render on the
|
||||
// prop's edge rather than in an effect, so the reset lands in the same
|
||||
// commit as the collapse instead of one frame later.
|
||||
const [wasExpanded, setWasExpanded] = useState(expanded);
|
||||
if (expanded !== wasExpanded) {
|
||||
setWasExpanded(expanded);
|
||||
if (!expanded) {
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [expanded]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
// A fresh query invalidates whatever the previous one produced -- results
|
||||
// and the unavailable row alike must never describe a stale request.
|
||||
setPlaces([]);
|
||||
if (trimmed.length < 3 || trimmed === lastSelectedRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -131,6 +132,10 @@ export default function MapSearchBox({ map, rootCode, expanded }) {
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
// A fresh query invalidates whatever the previous one produced --
|
||||
// results and the unavailable row alike must never describe a
|
||||
// stale request.
|
||||
setPlaces([]);
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, memo } from 'react';
|
||||
import { useEffect, useEffectEvent, useRef, memo } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Viewer } from '@photo-sphere-viewer/core';
|
||||
import '@photo-sphere-viewer/core/index.css';
|
||||
|
||||
function PanoramaViewer({ imageUrl, onReady, onError, topBarSlot }) {
|
||||
const containerRef = useRef(null);
|
||||
const onReadyRef = useRef(onReady);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
// Kept in refs so a changing callback identity cannot tear down the viewer.
|
||||
onReadyRef.current = onReady;
|
||||
onErrorRef.current = onError;
|
||||
// Effect events, so a changing callback identity cannot tear down the
|
||||
// viewer: the effect below depends on the image alone and still reaches the
|
||||
// latest callbacks when it fires them.
|
||||
const emitReady = useEffectEvent(() => onReady?.());
|
||||
const emitError = useEffectEvent((error) => onError?.(error));
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -62,19 +61,19 @@ function PanoramaViewer({ imageUrl, onReady, onError, topBarSlot }) {
|
||||
});
|
||||
|
||||
viewer.addEventListener('ready', () => {
|
||||
if (!disposed) onReadyRef.current?.();
|
||||
if (!disposed) emitReady();
|
||||
});
|
||||
|
||||
viewer.addEventListener('panorama-error', (event) => {
|
||||
console.error('Panorama failed to render, falling back to a flat image:', event);
|
||||
showFallbackImage();
|
||||
onReadyRef.current?.();
|
||||
emitReady();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Could not create the panorama viewer:', error);
|
||||
showFallbackImage();
|
||||
onReadyRef.current?.();
|
||||
onErrorRef.current?.(error);
|
||||
emitReady();
|
||||
emitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { getLastRegion } from '../../lib/last-region';
|
||||
import { getLastRegion, watchLastRegion } from '../../lib/last-region';
|
||||
import { useStoredValue } from '../../lib/use-stored-value';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -120,18 +121,13 @@ function UnavailableRow({ code }) {
|
||||
* so the common case stays one click while 61 districts stay reachable.
|
||||
*/
|
||||
export default function RegionPicker({ onPlayClick }) {
|
||||
// Read in an effect, not during render: localStorage does not exist on the
|
||||
// server and a hydration mismatch is worse than the row appearing a frame
|
||||
// late. Only one row keeps the accent style -- two competing primary
|
||||
// actions is worse than none -- so this renders un-emphasised.
|
||||
const [lastRegion, setLastRegionState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const code = getLastRegion();
|
||||
if (code && code !== COUNTRY_CODE && isRegion(code) && isPlayable(code)) {
|
||||
setLastRegionState(code);
|
||||
}
|
||||
}, []);
|
||||
// The server sees null and renders no row; the client reads the stored code
|
||||
// and adds it, so there is no hydration mismatch and no effect. Only one row
|
||||
// keeps the accent style -- two competing primary actions is worse than
|
||||
// none -- so this renders un-emphasised.
|
||||
const stored = useStoredValue(getLastRegion, watchLastRegion, null);
|
||||
const lastRegion =
|
||||
stored && stored !== COUNTRY_CODE && isRegion(stored) && isPlayable(stored) ? stored : null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Music, Volume2, VolumeX } from 'lucide-react';
|
||||
import {
|
||||
getStoredMusicEnabled,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
watchMusicPreference,
|
||||
watchSfxPreference,
|
||||
} from '../../lib/audio';
|
||||
import { useStoredValue } from '../../lib/use-stored-value';
|
||||
|
||||
/**
|
||||
* Music and sound-effect switches, styled as a sibling of ThemeToggle so the
|
||||
@@ -29,23 +29,11 @@ import {
|
||||
* @returns {JSX.Element} The control.
|
||||
*/
|
||||
export default function SoundToggle({ className = '', compact = false }) {
|
||||
// Seeded with the same defaults the server renders, so the first client
|
||||
// render matches the HTML and the effect below only ever corrects a player
|
||||
// who has actually chosen otherwise. No `mounted` gate: with a default of
|
||||
// on, gating would paint every load as muted and then flip.
|
||||
const [musicOn, setMusicOn] = useState(true);
|
||||
const [sfxOn, setSfxOn] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setMusicOn(getStoredMusicEnabled());
|
||||
setSfxOn(getStoredSfxEnabled());
|
||||
const unwatchMusic = watchMusicPreference(setMusicOn);
|
||||
const unwatchSfx = watchSfxPreference(setSfxOn);
|
||||
return () => {
|
||||
unwatchMusic();
|
||||
unwatchSfx();
|
||||
};
|
||||
}, []);
|
||||
// Read from storage and re-read on every change, with `true` -- the
|
||||
// default -- as the server value, so the first paint matches the HTML and
|
||||
// only a player who has actually chosen otherwise sees a correction.
|
||||
const musicOn = useStoredValue(getStoredMusicEnabled, watchMusicPreference, true);
|
||||
const sfxOn = useStoredValue(getStoredSfxEnabled, watchSfxPreference, true);
|
||||
|
||||
// A click here is a real user gesture, which is the one thing the audio
|
||||
// context needs -- so unmuting is audible immediately rather than on the
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import {
|
||||
THEMES,
|
||||
DEFAULT_THEME,
|
||||
getStoredTheme,
|
||||
setStoredTheme,
|
||||
watchStoredTheme,
|
||||
applyTheme,
|
||||
watchSystemTheme,
|
||||
} from '../../lib/theme';
|
||||
import { useStoredValue } from '../../lib/use-stored-value';
|
||||
|
||||
// Lucide, matching the rest of the chrome's icon language (Trophy, Wrench,
|
||||
// ArrowLeft ...); emoji ignored currentColor and read noisier than the rest.
|
||||
@@ -27,35 +29,29 @@ const THEME_ICONS = {
|
||||
* @param {boolean} [props.compact] True for the single cycling button.
|
||||
*/
|
||||
export default function ThemeToggle({ className = '', compact = false }) {
|
||||
const [theme, setTheme] = useState(DEFAULT_THEME);
|
||||
// The stored choice only exists on the client, so the first paint has to
|
||||
// match the server's default and correct itself after mount.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// The stored choice, read from storage and re-read whenever it changes --
|
||||
// including a change made by the other copy of this control (the header
|
||||
// mounts a breakpoint pair). null until the browser value is known, so the
|
||||
// server marks nothing selected rather than guessing.
|
||||
const theme = useStoredValue(getStoredTheme, watchStoredTheme, null);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(getStoredTheme());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (theme === null) return undefined;
|
||||
applyTheme(theme);
|
||||
if (theme !== 'system') return;
|
||||
if (theme !== 'system') return undefined;
|
||||
// Only while following the system does an OS change mean anything.
|
||||
return watchSystemTheme(() => applyTheme('system'));
|
||||
}, [theme, mounted]);
|
||||
}, [theme]);
|
||||
|
||||
const handleSelect = (choice) => {
|
||||
setTheme(choice);
|
||||
setStoredTheme(choice);
|
||||
};
|
||||
// Writing storage is the whole update: every mounted toggle re-reads.
|
||||
const handleSelect = (choice) => setStoredTheme(choice);
|
||||
|
||||
const groupClass = `inline-flex h-11 items-center rounded-lg border border-border bg-card ${className}`;
|
||||
|
||||
if (compact) {
|
||||
// One cell that steps light -> dark -> system. Shows the CURRENT theme's
|
||||
// icon, so the button reads as a status as well as a control.
|
||||
const index = THEMES.findIndex((option) => option.value === theme);
|
||||
const index = THEMES.findIndex((option) => option.value === (theme ?? DEFAULT_THEME));
|
||||
const current = THEMES[index === -1 ? 0 : index];
|
||||
const next = THEMES[(index + 1) % THEMES.length];
|
||||
const Icon = THEME_ICONS[current.value];
|
||||
@@ -84,7 +80,7 @@ export default function ThemeToggle({ className = '', compact = false }) {
|
||||
className={groupClass}
|
||||
>
|
||||
{THEMES.map((option) => {
|
||||
const selected = mounted && theme === option.value;
|
||||
const selected = theme === option.value;
|
||||
const Icon = THEME_ICONS[option.value];
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,36 +18,8 @@ import { validateUsername } from '../../lib/username';
|
||||
// action skips into a generated name; when editing an existing name it is a
|
||||
// plain Cancel, because "skip" would read as discarding the current name.
|
||||
export default function UsernameModal({ isOpen, onSubmit, onSkip, onClose, initialValue }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const hasExistingName = Boolean(initialValue);
|
||||
|
||||
// Re-seed the field each time the dialog opens: it stays mounted between
|
||||
// opens, and an edit session must start from the saved name, not the
|
||||
// leftovers of the previous visit.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setUsername(initialValue || '');
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, initialValue]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// The same rule /api/guess applies, so a name accepted here is never
|
||||
// rejected at the first submit.
|
||||
const checked = validateUsername(username);
|
||||
if (!checked.ok) {
|
||||
setError(checked.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(checked.value);
|
||||
setError('');
|
||||
setUsername('');
|
||||
};
|
||||
|
||||
const handleSecondary = () => {
|
||||
if (!hasExistingName && onSkip) onSkip();
|
||||
else onClose();
|
||||
@@ -66,6 +38,41 @@ export default function UsernameModal({ isOpen, onSubmit, onSkip, onClose, initi
|
||||
: 'Enter a username for the leaderboard'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* The form lives inside the content, which Radix unmounts when the
|
||||
dialog closes, so every open starts a fresh form seeded from the
|
||||
saved name -- no effect re-seeding a field that outlived its
|
||||
previous edit session. */}
|
||||
<UsernameForm
|
||||
initialValue={initialValue}
|
||||
hasExistingName={hasExistingName}
|
||||
onSubmit={onSubmit}
|
||||
onSecondary={handleSecondary}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function UsernameForm({ initialValue, hasExistingName, onSubmit, onSecondary }) {
|
||||
const [username, setUsername] = useState(initialValue || '');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// The same rule /api/guess applies, so a name accepted here is never
|
||||
// rejected at the first submit.
|
||||
const checked = validateUsername(username);
|
||||
if (!checked.ok) {
|
||||
setError(checked.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(checked.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -99,7 +106,7 @@ export default function UsernameModal({ isOpen, onSubmit, onSkip, onClose, initi
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleSecondary}
|
||||
onClick={onSecondary}
|
||||
className="flex-1"
|
||||
>
|
||||
{hasExistingName ? 'Cancel' : 'Skip — random name'}
|
||||
@@ -116,7 +123,6 @@ export default function UsernameModal({ isOpen, onSubmit, onSkip, onClose, initi
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Displayed on the leaderboard
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useEffectEvent, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { getTileConfig } from '../../../lib/map-tiles';
|
||||
@@ -31,13 +31,15 @@ export default function CoverageMap({
|
||||
const boundaryLayerRef = useRef(null);
|
||||
const dotsLayerRef = useRef(null);
|
||||
const highlightRef = useRef(null);
|
||||
// The click handler is bound once, when the map is created, and needs the
|
||||
// CURRENT points -- not the empty array of the first render. Data, not a
|
||||
// callback, so an effect event does not fit; the ref is written during
|
||||
// render on purpose and read only inside Leaflet's handler.
|
||||
const panosRef = useRef(panos);
|
||||
const onBoundsChangeRef = useRef(onBoundsChange);
|
||||
const onSelectPanoRef = useRef(onSelectPano);
|
||||
|
||||
// eslint-disable-next-line react-hooks/refs -- data snapshot for a once-bound imperative handler
|
||||
panosRef.current = panos;
|
||||
onBoundsChangeRef.current = onBoundsChange;
|
||||
onSelectPanoRef.current = onSelectPano;
|
||||
const emitBoundsChange = useEffectEvent((bounds) => onBoundsChange?.(bounds));
|
||||
const emitSelectPano = useEffectEvent((point) => onSelectPano?.(point));
|
||||
|
||||
// Create the map once. Data arrives through the effects below.
|
||||
useEffect(() => {
|
||||
@@ -62,14 +64,14 @@ export default function CoverageMap({
|
||||
clearTimeout(moveTimer);
|
||||
moveTimer = setTimeout(() => {
|
||||
const b = map.getBounds();
|
||||
onBoundsChangeRef.current?.([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||||
emitBoundsChange([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||||
}, MOVE_DEBOUNCE_MS);
|
||||
};
|
||||
map.on('moveend', report);
|
||||
|
||||
const pick = (event) => {
|
||||
const points = panosRef.current;
|
||||
if (!points?.length || !onSelectPanoRef.current) return;
|
||||
if (!points?.length) return;
|
||||
|
||||
// Compare in screen space so the tolerance means the same thing at every
|
||||
// zoom level.
|
||||
@@ -84,7 +86,7 @@ export default function CoverageMap({
|
||||
best = point;
|
||||
}
|
||||
}
|
||||
if (best && bestDistance <= CLICK_TOLERANCE_PX) onSelectPanoRef.current(best);
|
||||
if (best && bestDistance <= CLICK_TOLERANCE_PX) emitSelectPano(best);
|
||||
};
|
||||
map.on('click', pick);
|
||||
|
||||
|
||||
@@ -101,16 +101,23 @@ export default function CoveragePage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
regionRef.current = region;
|
||||
// Switching region is an action: everything derived from the previous
|
||||
// region goes, then the new one loads. The first load happens once on mount.
|
||||
const selectRegion = useCallback((code) => {
|
||||
setRegion(code);
|
||||
regionRef.current = code;
|
||||
setBoundary(null);
|
||||
setPanos(null);
|
||||
setCounts(null);
|
||||
setGeneratedAt(null);
|
||||
setSelected(null);
|
||||
setPano(null);
|
||||
load(region, null);
|
||||
}, [region, load]);
|
||||
load(code, null);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
load(regionRef.current, null);
|
||||
}, [load]);
|
||||
|
||||
const handleSelectPano = useCallback(async (point) => {
|
||||
const requestId = ++panoRequestRef.current;
|
||||
@@ -179,7 +186,7 @@ export default function CoveragePage() {
|
||||
level={level}
|
||||
onLevelChange={setLevel}
|
||||
region={region}
|
||||
onRegionChange={setRegion}
|
||||
onRegionChange={selectRegion}
|
||||
levels={['province', 'district']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+19
-18
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Beer, Wrench } from "lucide-react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import ThemeToggle from './components/ThemeToggle';
|
||||
@@ -13,7 +13,8 @@ import DonateQRModal from './components/DonateQRModal';
|
||||
import LeaderboardModal from './components/LeaderboardModal';
|
||||
import RegionPicker from './components/RegionPicker';
|
||||
import DailyCard from './components/DailyCard';
|
||||
import { generateRandomUsername, getUsername, setUsername } from '../lib/username';
|
||||
import { generateRandomUsername, getUsername, setUsername, watchUsername } from '../lib/username';
|
||||
import { useStoredValue } from '../lib/use-stored-value';
|
||||
import { playSound } from '../lib/audio';
|
||||
import { SCORE_BANDS, formatDistance } from '../lib/game';
|
||||
|
||||
@@ -40,25 +41,25 @@ const SCORING_ROWS = [
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [showDonateModal, setShowDonateModal] = useState(false);
|
||||
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
||||
const [username, setUsernameState] = useState('');
|
||||
// The stored name: null while unknown (server render and hydration), '' when
|
||||
// the browser has none, otherwise the name. Written through setUsername and
|
||||
// re-read here, so this page, the header chip and the game agree.
|
||||
const storedName = useStoredValue(getUsername, watchUsername, null);
|
||||
const username = storedName ?? '';
|
||||
// Opened by the header chip to change an existing name.
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
// With no stored name the prompt opens right on landing: every path out of
|
||||
// it (save, skip, or dismiss) leaves a name behind, so the suggestion to
|
||||
// pick one comes before Play instead of interrupting it, and saving is what
|
||||
// closes it.
|
||||
const showUsernameModal = editingName || storedName === '';
|
||||
// The Play destination held while the name prompt is up, so navigation
|
||||
// resumes after the name is settled.
|
||||
const [pendingHref, setPendingHref] = useState(null);
|
||||
|
||||
// With no stored name the prompt opens right on landing: every path out of
|
||||
// it (save, skip, or dismiss) leaves a name behind, so the suggestion to
|
||||
// pick one comes before Play instead of interrupting it.
|
||||
useEffect(() => {
|
||||
const stored = getUsername() || '';
|
||||
setUsernameState(stored);
|
||||
if (!stored) setShowUsernameModal(true);
|
||||
}, []);
|
||||
|
||||
const saveUsername = (newUsername) => {
|
||||
setUsername(newUsername);
|
||||
setUsernameState(newUsername);
|
||||
setShowUsernameModal(false);
|
||||
setEditingName(false);
|
||||
};
|
||||
|
||||
const handleUsernameSubmit = (newUsername) => {
|
||||
@@ -83,7 +84,7 @@ export default function Home() {
|
||||
handleUsernameSkip();
|
||||
return;
|
||||
}
|
||||
setShowUsernameModal(false);
|
||||
setEditingName(false);
|
||||
setPendingHref(null);
|
||||
};
|
||||
|
||||
@@ -93,7 +94,7 @@ export default function Home() {
|
||||
playSound('click');
|
||||
if (getUsername()) return false;
|
||||
setPendingHref(href);
|
||||
setShowUsernameModal(true);
|
||||
setEditingName(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -115,7 +116,7 @@ export default function Home() {
|
||||
rather than overflowing the header row. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUsernameModal(true)}
|
||||
onClick={() => setEditingName(true)}
|
||||
className="min-h-11 max-w-44 truncate rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
title={username ? 'Change your name' : 'Set your leaderboard name'}
|
||||
>
|
||||
|
||||
+20
-11
@@ -1,4 +1,5 @@
|
||||
import { previousDay } from './daily-calendar.js';
|
||||
import { readItem, writeItem, watchItem } from './storage.js';
|
||||
|
||||
// The player's daily-challenge record in localStorage, mirroring username.js:
|
||||
// one module owns one browser-storage concern.
|
||||
@@ -21,15 +22,28 @@ export const DAILY_STORAGE_KEY = 'vngeoguessr_daily';
|
||||
* @property {boolean} isPano Whether it renders in the 360 viewer or flat.
|
||||
*/
|
||||
|
||||
// The last raw string parsed and what it parsed to. useSyncExternalStore
|
||||
// compares snapshots by identity, so the same stored text must yield the same
|
||||
// object, not a fresh parse every read.
|
||||
let cachedRaw = null;
|
||||
let cachedProgress = null;
|
||||
|
||||
/** @returns {DailyProgress|null} */
|
||||
export function getDailyProgress() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const raw = readItem(DAILY_STORAGE_KEY);
|
||||
if (raw === cachedRaw) return cachedProgress;
|
||||
cachedRaw = raw;
|
||||
try {
|
||||
const raw = localStorage.getItem(DAILY_STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
cachedProgress = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
cachedProgress = null;
|
||||
}
|
||||
return cachedProgress;
|
||||
}
|
||||
|
||||
/** Be told when the record changes. */
|
||||
export function watchDailyProgress(onChange) {
|
||||
return watchItem(DAILY_STORAGE_KEY, onChange);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,13 +82,8 @@ export function saveDailyResult(day, number, result, guessCoordinates, imageUrl,
|
||||
imageUrl,
|
||||
isPano: isPano !== false,
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(DAILY_STORAGE_KEY, JSON.stringify(progress));
|
||||
} catch {
|
||||
// Best-effort: losing it means the player may play today twice.
|
||||
}
|
||||
}
|
||||
// Best effort: if storage refuses, the player may get to play today twice.
|
||||
writeItem(DAILY_STORAGE_KEY, JSON.stringify(progress));
|
||||
return progress;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Whether the one-time how-to-play hint has been seen, in localStorage,
|
||||
// mirroring username.js: one module owns one browser-storage concern.
|
||||
|
||||
import { readItem, writeItem, watchItem } from './storage.js';
|
||||
|
||||
export const HINT_STORAGE_KEY = 'vngeoguessr_hint_seen';
|
||||
|
||||
export function getHintSeen() {
|
||||
return readItem(HINT_STORAGE_KEY) === '1';
|
||||
}
|
||||
|
||||
export function setHintSeen() {
|
||||
writeItem(HINT_STORAGE_KEY, '1');
|
||||
}
|
||||
|
||||
export function watchHintSeen(onChange) {
|
||||
return watchItem(HINT_STORAGE_KEY, onChange);
|
||||
}
|
||||
+8
-14
@@ -1,24 +1,18 @@
|
||||
// Last-played region code in localStorage, mirroring username.js: one module
|
||||
// owns one browser-storage concern, so nothing server-side ever touches window.
|
||||
|
||||
import { readItem, writeItem, watchItem } from './storage.js';
|
||||
|
||||
export const LAST_REGION_STORAGE_KEY = 'vngeoguessr_last_region';
|
||||
|
||||
export function getLastRegion() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return localStorage.getItem(LAST_REGION_STORAGE_KEY);
|
||||
} catch {
|
||||
// Storage can throw (blocked site data, some private windows). No stored
|
||||
// region is a fine answer.
|
||||
return null;
|
||||
}
|
||||
return readItem(LAST_REGION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function setLastRegion(code) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(LAST_REGION_STORAGE_KEY, code);
|
||||
} catch {
|
||||
// Best-effort convenience; losing it costs one extra click.
|
||||
}
|
||||
writeItem(LAST_REGION_STORAGE_KEY, code);
|
||||
}
|
||||
|
||||
export function watchLastRegion(onChange) {
|
||||
return watchItem(LAST_REGION_STORAGE_KEY, onChange);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// The one place localStorage is touched.
|
||||
//
|
||||
// Every per-browser preference (name, theme, sound, last region, daily
|
||||
// record, hint seen) has its own module that owns its key, its validation and
|
||||
// its serialisation. Those modules come here for the three things they all
|
||||
// need and none should re-implement: a read that never throws, a write that
|
||||
// never throws, and a way to be told when the value changes.
|
||||
//
|
||||
// Reads and writes are guarded because storage is not always there: private
|
||||
// windows, blocked site data and thumbnail renderers all throw on access, and
|
||||
// a preference that cannot be remembered must never take the page down.
|
||||
//
|
||||
// Change notification covers both tabs and this one. The browser's `storage`
|
||||
// event fires only in OTHER tabs, so a same-tab write also dispatches on a
|
||||
// local target; two copies of one control on the same page (a breakpoint
|
||||
// pair, one hidden) stay in step that way.
|
||||
|
||||
const local = typeof EventTarget === 'undefined' ? null : new EventTarget();
|
||||
|
||||
/**
|
||||
* Read a raw string, or null when absent or unreadable.
|
||||
* @param {string} key
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function readItem(key) {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a raw string (or remove the key with null), then notify watchers.
|
||||
* @param {string} key
|
||||
* @param {string|null} value
|
||||
* @returns {void}
|
||||
*/
|
||||
export function writeItem(key, value) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Best effort: the caller's in-memory state still applies for this visit.
|
||||
}
|
||||
local?.dispatchEvent(new CustomEvent('change', { detail: key }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Be told when a key changes, in this tab or another.
|
||||
* @param {string} key
|
||||
* @param {Function} onChange Called with no arguments; re-read the value.
|
||||
* @returns {Function} Unsubscribe.
|
||||
*/
|
||||
export function watchItem(key, onChange) {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
const onLocal = (event) => {
|
||||
if (event.detail === key) onChange();
|
||||
};
|
||||
const onStorage = (event) => {
|
||||
if (event.key === key || event.key === null) onChange();
|
||||
};
|
||||
local?.addEventListener('change', onLocal);
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
local?.removeEventListener('change', onLocal);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}
|
||||
+15
-15
@@ -5,6 +5,8 @@
|
||||
// utilities and the token palette switch together. layout.js runs an inlined
|
||||
// copy of resolveDark before paint; keep the two in step.
|
||||
|
||||
import { readItem, writeItem, watchItem } from './storage.js';
|
||||
|
||||
export const THEME_STORAGE_KEY = 'vngeoguessr_theme';
|
||||
export const DEFAULT_THEME = 'system';
|
||||
|
||||
@@ -23,15 +25,8 @@ const DARK_QUERY = '(prefers-color-scheme: dark)';
|
||||
* @returns {string} 'light', 'dark' or 'system'.
|
||||
*/
|
||||
export function getStoredTheme() {
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME;
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return THEMES.some((theme) => theme.value === stored) ? stored : DEFAULT_THEME;
|
||||
} catch {
|
||||
// Private browsing and blocked site data both throw here; following the
|
||||
// system is a fine answer when the choice cannot be read.
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
const stored = readItem(THEME_STORAGE_KEY);
|
||||
return THEMES.some((theme) => theme.value === stored) ? stored : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,12 +35,17 @@ export function getStoredTheme() {
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setStoredTheme(choice) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, choice);
|
||||
} catch {
|
||||
// Not being able to remember the choice must not break changing it.
|
||||
}
|
||||
writeItem(THEME_STORAGE_KEY, choice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Be told when the stored choice changes. Every mounted toggle subscribes, so
|
||||
* a breakpoint pair of them never disagrees after a click.
|
||||
* @param {Function} onChange
|
||||
* @returns {Function} Unsubscribe.
|
||||
*/
|
||||
export function watchStoredTheme(onChange) {
|
||||
return watchItem(THEME_STORAGE_KEY, onChange);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+16
-22
@@ -13,34 +13,21 @@ const FRAME_MS = 1000 / 60;
|
||||
* the score feel earned. Written by hand rather than pulled from an animation
|
||||
* library: this is the only animated number in the app.
|
||||
*
|
||||
* Only the animation frames live in state. Inactive, zero and reduced-motion
|
||||
* are derived on the way out, so nothing has to be reset in an effect.
|
||||
*
|
||||
* @param {number} value Final value to land on.
|
||||
* @param {boolean} active False holds the counter at zero, so a reveal can wait
|
||||
* its turn in the sequence.
|
||||
* @returns {number} The value to render this frame.
|
||||
*/
|
||||
export function useCountUp(value, active) {
|
||||
const [shown, setShown] = useState(0);
|
||||
// The frames of the current animation, keyed by what they animate towards
|
||||
// so a stale run for a previous value is never shown against a new one.
|
||||
const [frame, setFrame] = useState({ target: null, shown: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setShown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing to count towards, and no reason to spin a timer for it.
|
||||
if (!value) {
|
||||
setShown(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// prefers-reduced-motion means show the answer, not a slower animation.
|
||||
const reduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced) {
|
||||
setShown(value);
|
||||
return;
|
||||
}
|
||||
if (!active || !value || prefersReducedMotion()) return undefined;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
@@ -48,12 +35,19 @@ export function useCountUp(value, active) {
|
||||
const progress = Math.min(1, elapsed / DURATION_MS);
|
||||
// Ease out cubic: fast at first, settling onto the final number.
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setShown(Math.round(value * eased));
|
||||
setFrame({ target: value, shown: Math.round(value * eased) });
|
||||
if (progress >= 1) clearInterval(timer);
|
||||
}, FRAME_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [value, active]);
|
||||
|
||||
return shown;
|
||||
if (!active) return 0;
|
||||
// prefers-reduced-motion means show the answer, not a slower animation.
|
||||
if (!value || prefersReducedMotion()) return value;
|
||||
return frame.target === value ? frame.shown : 0;
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Render a browser-stored value and re-render when it changes.
|
||||
*
|
||||
* Built on useSyncExternalStore, which is what a preference in localStorage
|
||||
* is: state owned outside React. The server, and the client during hydration,
|
||||
* see `serverValue`; the client then reads the real value and re-renders once.
|
||||
* That replaces the read-in-an-effect-then-setState pattern, which painted a
|
||||
* default first and corrected it a frame later, and left two copies of one
|
||||
* control (a breakpoint pair) free to disagree.
|
||||
*
|
||||
* `read` must return the same value for the same stored contents -- a string,
|
||||
* a number, or a cached object -- or the store will loop.
|
||||
* @template T
|
||||
* @param {() => T} read The module's getter.
|
||||
* @param {(onChange: Function) => Function} watch The module's watcher.
|
||||
* @param {T} serverValue What to render before the browser value is known.
|
||||
* @returns {T}
|
||||
*/
|
||||
export function useStoredValue(read, watch, serverValue) {
|
||||
return useSyncExternalStore(watch, read, () => serverValue);
|
||||
}
|
||||
+10
-4
@@ -2,16 +2,22 @@
|
||||
// browser-storage concern, so server code importing lib/game.js for scoring
|
||||
// never touches window.
|
||||
|
||||
import { readItem, writeItem, watchItem } from './storage.js';
|
||||
|
||||
export const USERNAME_STORAGE_KEY = 'vngeoguessr_username';
|
||||
|
||||
/** The stored name, or '' when there is none. Never throws. */
|
||||
export function getUsername() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(USERNAME_STORAGE_KEY);
|
||||
return readItem(USERNAME_STORAGE_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setUsername(username) {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(USERNAME_STORAGE_KEY, username);
|
||||
writeItem(USERNAME_STORAGE_KEY, username);
|
||||
}
|
||||
|
||||
/** Be told when the name changes, here or in another tab. */
|
||||
export function watchUsername(onChange) {
|
||||
return watchItem(USERNAME_STORAGE_KEY, onChange);
|
||||
}
|
||||
|
||||
export const USERNAME_MIN_LENGTH = 2;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readItem, writeItem, watchItem } from '../src/lib/storage.js';
|
||||
import { getUsername, setUsername } from '../src/lib/username.js';
|
||||
import { getStoredTheme, DEFAULT_THEME } from '../src/lib/theme.js';
|
||||
import { getDailyProgress } from '../src/lib/daily-progress.js';
|
||||
|
||||
// Vitest runs these in Node, where there is no window: exactly the situation
|
||||
// server rendering is in, and the one every storage read has to survive.
|
||||
describe('browser storage without a browser', () => {
|
||||
it('reads as absent and writes as a no-op instead of throwing', () => {
|
||||
expect(readItem('anything')).toBeNull();
|
||||
expect(() => writeItem('anything', 'x')).not.toThrow();
|
||||
expect(typeof watchItem('anything', () => {})).toBe('function');
|
||||
});
|
||||
|
||||
it('gives every module its server default', () => {
|
||||
expect(getUsername()).toBe('');
|
||||
expect(() => setUsername('mai')).not.toThrow();
|
||||
expect(getStoredTheme()).toBe(DEFAULT_THEME);
|
||||
expect(getDailyProgress()).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user