diff --git a/src/app/components/DailyCard.js b/src/app/components/DailyCard.js
index c055a55..f13fa63 100644
--- a/src/app/components/DailyCard.js
+++ b/src/app/components/DailyCard.js
@@ -1,17 +1,16 @@
"use client";
-import { useState } from 'react';
import Link from 'next/link';
-import { AlertCircle, ArrowRight, Calendar, Check, Share2 } from 'lucide-react';
+import { ArrowRight, Calendar } 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, watchDailyProgress, currentStreak } from '../../lib/daily-progress';
import { useStoredValue } from '../../lib/use-stored-value';
-import { buildDailyShareText, shareText } from '../../lib/share';
+import { buildDailyShareText, scoreLine } from '../../lib/share';
import { formatDistance } from '../../lib/game';
-
-const MAX_POINTS = 5;
+import ShareButton from './ShareButton';
+import PlayLink from './PlayLink';
// The day never changes under an open page in a way worth subscribing to; a
// reload at midnight is the refresh.
@@ -27,8 +26,6 @@ const subscribeNothing = () => () => {};
* @param {Function} props.onPlayClick Same interception hook as the region rows.
*/
export default function DailyCard({ onPlayClick }) {
- const [shareState, setShareState] = useState(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
@@ -40,30 +37,14 @@ export default function DailyCard({ onPlayClick }) {
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 =
- shareState === 'copied' ? 'Copied to the clipboard.'
- : shareState === 'shared' ? 'Shared.'
- : shareState === 'failed' ? 'Sharing failed. Try again.'
- : '';
-
- const handleShare = async () => {
- const outcome = await shareText(
- buildDailyShareText(
- played.number,
- played.result.score,
- formatDistance(played.result.distance),
- played.streak,
- `${window.location.origin}/daily`
- )
+ const getShareText = () =>
+ buildDailyShareText(
+ played.number,
+ played.result.score,
+ formatDistance(played.result.distance),
+ played.streak,
+ `${window.location.origin}/daily`
);
- setShareState(outcome);
- };
-
- const squares = played
- ? '🟩'.repeat(played.result.score) + '⬜'.repeat(MAX_POINTS - played.result.score)
- : '';
return (
@@ -84,7 +65,7 @@ export default function DailyCard({ onPlayClick }) {
{played
- ? `Played today: ${squares} ${played.result.score}/${MAX_POINTS} · ${formatDistance(played.result.distance)} away`
+ ? `Played today: ${scoreLine(played.result.score)} · ${formatDistance(played.result.distance)} away`
: 'One street view, one guess, the same for everyone. New at midnight, Vietnam time.'}
@@ -92,27 +73,20 @@ export default function DailyCard({ onPlayClick }) {
{played ? (
-
-
- {shareLabel}
-
+
See result
-
{shareStatus}
) : (
- {
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
- if (onPlayClick && onPlayClick('/daily')) e.preventDefault();
- }}
+ onPlayClick={onPlayClick}
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-brand-foreground transition-colors hover:bg-brand/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
>
Play today's challenge
-
+
)}
diff --git a/src/app/components/GameClient.js b/src/app/components/GameClient.js
index 09207b2..fc51142 100644
--- a/src/app/components/GameClient.js
+++ b/src/app/components/GameClient.js
@@ -48,19 +48,11 @@ function resultSound(score) {
* Ask the server for a new round. Throws on failure; touches no state, so the
* result-screen prefetch can call it without disturbing the round on screen.
* @param {string} locationCode Region to play.
- * @param {string|null} currentSessionId Session id to reuse, or null for a new one.
* @param {boolean} daily True for today's daily challenge instead of a region draw.
* @returns {Promise} The /api/new-game or /api/daily payload.
*/
-async function fetchNewRound(locationCode, currentSessionId, daily) {
- let url = '/api/daily';
- if (!daily) {
- // Encoded: an unencoded value carrying its own '&sessionId=' would let a
- // shared link choose the session key a victim's round is stored under.
- const params = new URLSearchParams({ region: locationCode });
- if (currentSessionId) params.set('sessionId', currentSessionId);
- url = `/api/new-game?${params.toString()}`;
- }
+async function fetchNewRound(locationCode, daily) {
+ const url = daily ? '/api/daily' : `/api/new-game?${new URLSearchParams({ region: locationCode })}`;
// A ceiling on the wait: the server gives itself eight seconds to draw a
// round, so a request still open after fifteen is not coming back, and the
@@ -112,7 +104,6 @@ export default function GameClient({ region, daily = false }) {
// A failed round fetch, shown in place with a retry. Never an alert(): that
// left an empty screen whose only way out was the browser back button.
const [loadError, setLoadError] = useState(null);
- const [initialized, setInitialized] = useState(false);
const [guessCoordinatesState, setGuessCoordinates] = useState(null);
const [showResultState, setShowResult] = useState(false);
// Everything a submitted round produced, in one object set in exactly one
@@ -163,7 +154,10 @@ export default function GameClient({ region, daily = false }) {
// in state painted one frame of Ho Chi Minh on every other region.
const mapCenter = pickedRegion?.center ?? [10.8231, 106.6297];
- const initializingRef = useRef(false);
+ // Set once the first round fetch has been started. A ref, not state:
+ // StrictMode replays the mount effect before any state update lands, and a
+ // second first-load would mint a second session.
+ const startedRef = useRef(false);
// The next round, fetched while the result dialog is open so Next Round can
// swap it in without a wait. Holds a promise; consumed exactly once.
const prefetchRef = useRef(null);
@@ -177,9 +171,14 @@ export default function GameClient({ region, daily = false }) {
// Back stays live while a guess is in flight, so a submit can resolve after
// the player has already left for the menu. State updates after that are
// harmless no-ops; a victory jingle over the home screen is not.
+ // Set in the effect as well as cleared in its cleanup: StrictMode unmounts
+ // and remounts once, and a flag only ever cleared stays false for good.
const mountedRef = useRef(true);
- useEffect(() => () => { mountedRef.current = false; }, []);
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => { mountedRef.current = false; };
+ }, []);
const applyRound = useCallback((data) => {
if (data.day) {
@@ -206,9 +205,11 @@ export default function GameClient({ region, daily = false }) {
setLoadError(null);
}, []);
- const loadRound = useCallback(async (locationCode, currentSessionId, epoch) => {
+ // Never throws. On failure it shows the error panel and ends roundLoading
+ // itself, so every caller is just "start a load under this epoch".
+ const loadRound = useCallback(async (locationCode, epoch) => {
try {
- const data = await fetchNewRound(locationCode, currentSessionId, daily);
+ const data = await fetchNewRound(locationCode, daily);
// Superseded while in flight: the newer action owns the screen and the
// roundLoading flag, so touch nothing and report nothing to clear.
if (epoch !== roundEpochRef.current) return true;
@@ -220,6 +221,7 @@ export default function GameClient({ region, daily = false }) {
setImageData(null);
setSessionId(null);
setLoadError(error.message || 'No images found');
+ setRoundLoading(false);
return false;
}
}, [applyRound, daily]);
@@ -227,31 +229,27 @@ export default function GameClient({ region, daily = false }) {
// 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;
-
- try {
- const code = locationCode.toUpperCase();
- roundEpochRef.current += 1;
- const loaded = await loadRound(locationCode, null, roundEpochRef.current);
- // Only a region that actually served a round is worth offering as
- // "Continue in ..." on the home page.
- if (loaded && !daily && isRegion(code)) setLastRegion(code);
- setInitialized(true);
- } catch (error) {
- console.error('Failed to initialize:', error);
- } finally {
- initializingRef.current = false;
- setInitialLoading(false);
- }
+ const loadFirstRound = useCallback(async (locationCode) => {
+ roundEpochRef.current += 1;
+ const loaded = await loadRound(locationCode, roundEpochRef.current);
+ // Only a region that actually served a round is worth offering as
+ // "Continue in ..." on the home page.
+ if (loaded && !daily && isRegion(locationCode)) setLastRegion(locationCode);
+ setInitialLoading(false);
}, [loadRound, daily]);
useEffect(() => {
+ if (startedRef.current) return;
// Already played today: the stored round is on screen, nothing to fetch.
- if (initialized || replay) return;
- loadLibrariesAndInitialize(region);
- }, [region, loadLibrariesAndInitialize, initialized, replay]);
+ // Read from storage, not from `replay`: on a hydrated load this first
+ // commit still sees the server snapshot (null), and its effects run
+ // before the re-render that would show the stored record.
+ if (daily && getDailyProgress()?.day === dailyDay()) return;
+ startedRef.current = true;
+ loadFirstRound(region);
+ // `replay` is a dependency so a record cleared from another tab starts a
+ // round instead of leaving the loading screen up.
+ }, [region, daily, loadFirstRound, replay]);
const submitGameResult = async (guessCoords) => {
if (!guessCoords || !sessionId) return null;
@@ -323,8 +321,8 @@ export default function GameClient({ region, daily = false }) {
// Start fetching the next round while the player reads the result, and warm
// the browser cache with its image. The wasted lookup when they leave from
// the dialog is one API call and one self-expiring session.
- const startPrefetch = (locationCode, currentSessionId) => {
- const promise = fetchNewRound(locationCode, currentSessionId).then((data) => {
+ const startPrefetch = (locationCode) => {
+ const promise = fetchNewRound(locationCode, false).then((data) => {
if (typeof window !== 'undefined' && data.imageData?.url) {
const image = new window.Image();
image.src = data.imageData.url;
@@ -342,7 +340,6 @@ export default function GameClient({ region, daily = false }) {
// After the guard: a blocked submit stays silent.
playSound('submit');
setSubmitting(true);
- const currentSession = sessionId;
try {
const submitted = await submitGameResult(guessCoordinates);
@@ -391,8 +388,9 @@ export default function GameClient({ region, daily = false }) {
setSubmitting(false);
setShowResult(true);
- // One round a day: there is no next one to fetch.
- if (!daily) startPrefetch(region, currentSession);
+ // One round a day: there is no next one to fetch. Nor is there once the
+ // player has left for the menu.
+ if (!daily && mountedRef.current) startPrefetch(region);
};
// Everything a round accumulates. Both Next Round and Skip come through here,
@@ -418,7 +416,6 @@ export default function GameClient({ region, daily = false }) {
const epoch = roundEpochRef.current;
setShowResult(false);
resetRoundState();
- const currentSession = sessionId;
setSessionId(null);
const prefetched = prefetchRef.current;
prefetchRef.current = null;
@@ -439,8 +436,15 @@ export default function GameClient({ region, daily = false }) {
}
}
- const loaded = await loadRound(region, currentSession, epoch);
- if (!loaded) setRoundLoading(false);
+ await loadRound(region, epoch);
+ };
+
+ // Start a fresh round load that supersedes whatever is in flight.
+ const reloadRound = () => {
+ setLoadError(null);
+ setRoundLoading(true);
+ roundEpochRef.current += 1;
+ return loadRound(region, roundEpochRef.current);
};
const handleSkipGuess = async () => {
@@ -461,26 +465,14 @@ export default function GameClient({ region, daily = false }) {
resetRoundState();
prefetchRef.current = null;
- roundEpochRef.current += 1;
setSessionId(null);
- // Skip is also the way out of the error panel; leaving the error up would
- // suppress the spinner and read as a hang while the new round loads.
- setLoadError(null);
- setRoundLoading(true);
- // A fresh id, never the one just skipped: the DEL above is still in
- // flight, and reusing the id let it land after the new round's write and
- // delete a live session.
- const loaded = await loadRound(region, null, roundEpochRef.current);
- if (!loaded) setRoundLoading(false);
+ // Skip is also the way out of the error panel (reloadRound clears it).
+ // The server mints a fresh session id every round, so the DEL above can
+ // land after the new round's write without touching it.
+ await reloadRound();
};
- const handleRetryLoad = async () => {
- setLoadError(null);
- roundEpochRef.current += 1;
- setRoundLoading(true);
- const loaded = await loadRound(region, null, roundEpochRef.current);
- if (!loaded) setRoundLoading(false);
- };
+ const handleRetryLoad = reloadRound;
const handleGoBack = () => {
playSound('click');
@@ -579,7 +571,8 @@ export default function GameClient({ region, daily = false }) {
regionCode={pickedRegion?.code ?? 'VN'}
expanded={mapExpanded}
onExpandedChange={setMapExpanded}
- hasGuess={Boolean(guessCoordinates)}
+ guess={guessCoordinates}
+ viewKey={roundKey}
onMapClick={handleMapClick}
/>
diff --git a/src/app/components/GuessMapPanel.js b/src/app/components/GuessMapPanel.js
index 6393928..1bc6ce7 100644
--- a/src/app/components/GuessMapPanel.js
+++ b/src/app/components/GuessMapPanel.js
@@ -21,9 +21,11 @@ export default function GuessMapPanel({
regionCode,
expanded,
onExpandedChange,
- hasGuess,
+ guess,
+ viewKey,
onMapClick,
}) {
+ const hasGuess = Boolean(guess);
const mapRef = useRef(null);
// Mirrored into state so the search box renders once the map exists.
const [mapInstance, setMapInstance] = useState(null);
@@ -113,6 +115,8 @@ export default function GuessMapPanel({
zoom={10}
// The search box owns the top-left corner on every breakpoint.
zoomPosition="bottomleft"
+ marker={guess}
+ viewKey={viewKey}
onMapClick={onMapClick}
onReady={handleMapReady}
className="w-full h-full"
diff --git a/src/app/components/LeafletMap.js b/src/app/components/LeafletMap.js
index c292df1..14dfe62 100644
--- a/src/app/components/LeafletMap.js
+++ b/src/app/components/LeafletMap.js
@@ -14,10 +14,33 @@ import { getTileConfig } from '../../lib/map-tiles';
// under Turbopack dev; Leaflet needs the string either way.
const imageUrl = (image) => (typeof image === 'string' ? image : image.src);
+/**
+ * Frame the map on a bbox when there is one, else on a centre and zoom.
+ * @param {L.Map} map
+ * @param {number[]|null} bbox [west, south, east, north].
+ * @param {number[]} center [lat, lng].
+ * @param {number} zoom
+ * @returns {void}
+ */
+function frameView(map, bbox, center, zoom) {
+ if (bbox) {
+ map.fitBounds(L.latLngBounds([bbox[1], bbox[0]], [bbox[3], bbox[2]]), { padding: [20, 20] });
+ } else {
+ map.setView(center, zoom);
+ }
+}
+
+// A click map with at most one pin. The pin is controlled: `marker` says where
+// it is, and a click only reports coordinates, so a parent clearing its guess
+// between rounds clears the pin too. Changing `viewKey` re-frames the map on
+// the region, which is how a new round starts from the whole region again
+// rather than wherever the last guess was zoomed to.
export default function LeafletMap({
center,
zoom = 10,
bbox = null,
+ marker = null,
+ viewKey = 0,
onMapClick,
onReady,
// Consumers that overlay UI on the top-left corner (the guess map's search
@@ -27,109 +50,77 @@ export default function LeafletMap({
}) {
const mapRef = useRef(null);
const leafletMapRef = useRef(null);
- const markersRef = useRef([]);
+ const markerRef = useRef(null);
// 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));
+ const placeMarker = useEffectEvent(() => {
+ const map = leafletMapRef.current;
+ markerRef.current?.remove();
+ markerRef.current = map && marker ? L.marker(marker).addTo(map) : null;
+ });
+ const reframe = useEffectEvent(() => {
+ if (leafletMapRef.current) frameView(leafletMapRef.current, bbox, center, zoom);
+ });
useEffect(() => {
- const initMap = async () => {
- if (!mapRef.current || leafletMapRef.current) return;
+ if (!mapRef.current || leafletMapRef.current) return undefined;
- try {
+ try {
+ // Fix default marker icons issue
+ delete L.Icon.Default.prototype._getIconUrl;
+ L.Icon.Default.mergeOptions({
+ iconRetinaUrl: imageUrl(markerIcon2x),
+ iconUrl: imageUrl(markerIcon),
+ shadowUrl: imageUrl(markerShadow),
+ });
- // Fix default marker icons issue
- delete L.Icon.Default.prototype._getIconUrl;
- L.Icon.Default.mergeOptions({
- iconRetinaUrl: imageUrl(markerIcon2x),
- iconUrl: imageUrl(markerIcon),
- shadowUrl: imageUrl(markerShadow),
- });
+ const map = L.map(mapRef.current, { zoomControl: false });
+ L.control.zoom({ position: zoomPosition }).addTo(map);
+ frameView(map, bbox, center, zoom);
- // Create map
- const map = L.map(mapRef.current, { zoomControl: false });
- L.control.zoom({ position: zoomPosition }).addTo(map);
+ const tiles = getTileConfig();
+ L.tileLayer(tiles.url, tiles.options).addTo(map);
- // Set initial view - use bbox if provided, otherwise center/zoom
- if (bbox) {
- // bbox format: [west, south, east, north]
- const bounds = L.latLngBounds(
- L.latLng(bbox[1], bbox[0]), // southwest corner
- L.latLng(bbox[3], bbox[2]) // northeast corner
- );
- map.fitBounds(bounds, { padding: [20, 20] });
- } else {
- map.setView(center, zoom);
- }
+ map.on('click', (e) => emitMapClick({ lat: e.latlng.lat, lng: e.latlng.lng }));
- // Add tile layer
- const tiles = getTileConfig();
- L.tileLayer(tiles.url, tiles.options).addTo(map);
-
- // Handle click events
- map.on('click', (e) => {
- // 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);
-
- emitMapClick({ lat: e.latlng.lat, lng: e.latlng.lng });
- });
-
- leafletMapRef.current = map;
- emitReady(map);
-
- } catch (error) {
- console.error('Error initializing Leaflet map:', error);
- }
- };
-
- initMap();
-
- // Cleanup function
- return () => {
- if (leafletMapRef.current) {
- try {
- leafletMapRef.current.remove();
- } catch (error) {
- console.warn('Error removing map:', error);
- }
- leafletMapRef.current = null;
- 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.
- emitReady(null);
- }
- };
- }, [bbox, center, zoom, zoomPosition]); // Include props used in initialization
-
- // Update view when props change
- useEffect(() => {
- if (leafletMapRef.current) {
- if (bbox) {
- // bbox format: [west, south, east, north]
- const bounds = L.latLngBounds(
- L.latLng(bbox[1], bbox[0]), // southwest corner
- L.latLng(bbox[3], bbox[2]) // northeast corner
- );
- leafletMapRef.current.fitBounds(bounds, { padding: [20, 20] });
- } else if (center) {
- leafletMapRef.current.setView(center, zoom);
- }
+ leafletMapRef.current = map;
+ // A rebuilt map starts empty; put back the pin the parent still holds.
+ placeMarker();
+ emitReady(map);
+ } catch (error) {
+ console.error('Error initializing Leaflet map:', error);
}
- }, [center, zoom, bbox]);
+
+ return () => {
+ if (!leafletMapRef.current) return;
+ try {
+ leafletMapRef.current.remove();
+ } catch (error) {
+ console.warn('Error removing map:', error);
+ }
+ leafletMapRef.current = null;
+ markerRef.current = null;
+ // The handle given out by onReady is now a destroyed map; a parent
+ // still holding it would crash on the first pan/zoom call.
+ emitReady(null);
+ };
+ }, [bbox, center, zoom, zoomPosition]);
+
+ useEffect(() => {
+ placeMarker();
+ }, [marker]);
+
+ useEffect(() => {
+ reframe();
+ }, [viewKey]);
return (
-
);
-}
\ No newline at end of file
+}
diff --git a/src/app/components/PlayLink.js b/src/app/components/PlayLink.js
new file mode 100644
index 0000000..b16b34d
--- /dev/null
+++ b/src/app/components/PlayLink.js
@@ -0,0 +1,26 @@
+"use client";
+
+import Link from 'next/link';
+
+/**
+ * A link into a game that lets the home page intercept a plain click (to ask
+ * for a name first). Modified clicks (new tab, new window) keep native
+ * behaviour: the deep-linked game page can name the player itself, so
+ * hijacking the gesture would cost more than the prompt is worth.
+ * @param {Object} props
+ * @param {string} props.href Game URL.
+ * @param {(href: string) => boolean} [props.onPlayClick] Returning true cancels
+ * the navigation (the caller resumes it itself).
+ */
+export default function PlayLink({ href, onPlayClick, ...linkProps }) {
+ return (
+ {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
+ if (onPlayClick?.(href)) e.preventDefault();
+ }}
+ />
+ );
+}
diff --git a/src/app/components/RegionPicker.js b/src/app/components/RegionPicker.js
index aaa144a..c3375ed 100644
--- a/src/app/components/RegionPicker.js
+++ b/src/app/components/RegionPicker.js
@@ -1,6 +1,6 @@
"use client";
-import Link from 'next/link';
+import PlayLink from './PlayLink';
import { ArrowRight } from 'lucide-react';
import { getLastRegion, watchLastRegion } from '../../lib/last-region';
@@ -56,15 +56,9 @@ function PlayRow({ code, label, emphasis, onPlayClick }) {
const href = `/game/${regionSlug(code)}`;
return (
- {
- // Modified clicks (new tab, new window) keep native behavior: the
- // deep-linked game page can name the player itself, so hijacking the
- // gesture would cost more than the prompt is worth.
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
- if (onPlayClick && onPlayClick(href)) e.preventDefault();
- }}
+ onPlayClick={onPlayClick}
className={`group flex min-h-14 items-center justify-between gap-3 rounded-xl border p-4 shadow-xs transition-all duration-150 hover:border-brand/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
emphasis ? 'city-card-accent border-border bg-card' : 'border-border/60 bg-card/60'
}`}
@@ -94,7 +88,7 @@ function PlayRow({ code, label, emphasis, onPlayClick }) {
-
+
);
}
diff --git a/src/app/components/RoundResultDialog.js b/src/app/components/RoundResultDialog.js
index 86a7b2d..0e40231 100644
--- a/src/app/components/RoundResultDialog.js
+++ b/src/app/components/RoundResultDialog.js
@@ -1,14 +1,14 @@
"use client";
-import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
-import { AlertCircle, Check, ChevronDown, ExternalLink, Share2 } from 'lucide-react';
+import { ChevronDown, ExternalLink } from 'lucide-react';
import { formatDistance, SCORE_BANDS } from '../../lib/game';
import { useCountUp } from '../../lib/use-count-up';
import { regionSlug } from '../../lib/regions';
-import { buildShareText, buildDailyShareText, shareText } from '../../lib/share';
+import { buildShareText, buildDailyShareText } from '../../lib/share';
+import ShareButton from './ShareButton';
import ResultMap, { MARKER_COLORS } from './ResultMap';
// Why a round was not recorded, in the player's terms. The server names the
@@ -45,15 +45,6 @@ function hitCopy(hit, resolvedPath) {
return 'Wrong province';
}
-// What the share button did, for the screen reader; the visible label carries
-// the same word so nothing depends on colour or an icon alone.
-const SHARE_STATUS = {
- shared: 'Shared.',
- copied: 'Copied to the clipboard.',
- failed: 'Sharing failed. Try again.',
- cancelled: '',
-};
-
// Background and text move together: the semantic tokens flip to lighter
// fills with dark text in dark mode, so a hardcoded text-white cannot ride
// along on the wrapper.
@@ -112,18 +103,11 @@ export default function RoundResultDialog({
// rather than arriving already finished.
const shownScore = useCountUp(score, open);
- // The share outcome ('shared' | 'copied' | 'failed'), remembered against
- // the result it belongs to, so a new round's dialog opens with a fresh
- // button without an effect resetting state on open.
- const [shareOutcome, setShareOutcome] = useState({ result: null, state: null });
- const shareState = shareOutcome.result === result ? shareOutcome.state : null;
-
- const handleShare = async () => {
+ const getShareText = () => {
const distance = formatDistance(result.distance);
- const text = daily
+ return daily
? buildDailyShareText(daily.number, score, distance, daily.streak, `${window.location.origin}/daily`)
: buildShareText(regionName, score, distance, `${window.location.origin}/game/${regionSlug(regionCode)}`);
- setShareOutcome({ result, state: await shareText(text) });
};
const failureCopy = FAILURE_COPY[result?.reason] ?? FAILURE_COPY.default;
@@ -132,8 +116,6 @@ export default function RoundResultDialog({
// line earns its place under 3 points, where the ladder says only "beyond".
const hitLine =
result && !result.failed && score < 3 && result.hit ? hitCopy(result.hit, result.resolvedPath) : null;
- const shareLabel = shareState === 'copied' ? 'Copied' : shareState === 'failed' ? 'Retry' : 'Share';
- const ShareIcon = shareState === 'copied' ? Check : shareState === 'failed' ? AlertCircle : Share2;
const hasScoreLevels = (result?.scoreLevels?.length ?? 0) > 0;
const hasDistanceLevels = result?.distanceLevels?.some((entry) => entry.rank) ?? false;
@@ -379,16 +361,15 @@ export default function RoundResultDialog({
{daily ? 'Done' : 'Next Round'}
{result && !result.failed && (
-
-
- {shareLabel}
-
+ labelClassName="sr-only sm:not-sr-only"
+ />
)}
{!daily && (
@@ -396,10 +377,6 @@ export default function RoundResultDialog({
)}
- {/* Announced once per outcome; visually the button label already says it. */}
-
- {SHARE_STATUS[shareState] ?? ''}
-
);
diff --git a/src/app/components/ShareButton.js b/src/app/components/ShareButton.js
new file mode 100644
index 0000000..a8bec48
--- /dev/null
+++ b/src/app/components/ShareButton.js
@@ -0,0 +1,43 @@
+"use client";
+
+import { useState } from 'react';
+import { AlertCircle, Check, Share2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { shareText } from '../../lib/share';
+
+// What the share did, for the screen reader; the visible label carries the
+// same word so nothing depends on colour or an icon alone.
+const STATUS = {
+ shared: 'Shared.',
+ copied: 'Copied to the clipboard.',
+ failed: 'Sharing failed. Try again.',
+};
+
+/**
+ * A share button that remembers what the last press did. Unmount it (or give
+ * it a new key) to start fresh for a new result.
+ * @param {Object} props
+ * @param {() => string} props.getText Builds the text at press time.
+ * @param {string} props.title Tooltip.
+ * @param {string} [props.labelClassName] Classes for the visible word.
+ */
+export default function ShareButton({ getText, title, labelClassName, ...buttonProps }) {
+ const [outcome, setOutcome] = useState(null);
+ const label = outcome === 'copied' ? 'Copied' : outcome === 'failed' ? 'Retry' : 'Share';
+ const Icon = outcome === 'copied' ? Check : outcome === 'failed' ? AlertCircle : Share2;
+
+ return (
+ <>
+ setOutcome(await shareText(getText()))}
+ >
+
+ {label}
+
+ {STATUS[outcome] ?? ''}
+ >
+ );
+}
diff --git a/src/app/components/ThemeSync.js b/src/app/components/ThemeSync.js
new file mode 100644
index 0000000..af17748
--- /dev/null
+++ b/src/app/components/ThemeSync.js
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect } from 'react';
+import { getStoredTheme, watchStoredTheme, applyTheme, watchSystemTheme } from '../../lib/theme';
+import { useStoredValue } from '../../lib/use-stored-value';
+
+/**
+ * Keeps the document's theme in step with the stored choice and, while that
+ * choice is 'system', with the OS. Mounted once in the root layout, so the
+ * theme follows on every page -- not only where a toggle happens to be -- and
+ * an OS flip is applied once rather than once per mounted toggle. The toggles
+ * only write the choice. The first paint is the inline script in layout.js.
+ * @returns {null}
+ */
+export default function ThemeSync() {
+ const theme = useStoredValue(getStoredTheme, watchStoredTheme, null);
+
+ useEffect(() => {
+ if (theme === null) return undefined;
+ applyTheme(theme);
+ if (theme !== 'system') return undefined;
+ return watchSystemTheme(() => applyTheme('system'));
+ }, [theme]);
+
+ return null;
+}
diff --git a/src/app/components/ThemeToggle.js b/src/app/components/ThemeToggle.js
index f2a5e10..6846123 100644
--- a/src/app/components/ThemeToggle.js
+++ b/src/app/components/ThemeToggle.js
@@ -1,6 +1,5 @@
"use client";
-import { useEffect } from 'react';
import { Monitor, Moon, Sun } from 'lucide-react';
import {
THEMES,
@@ -8,8 +7,6 @@ import {
getStoredTheme,
setStoredTheme,
watchStoredTheme,
- applyTheme,
- watchSystemTheme,
} from '../../lib/theme';
import { useStoredValue } from '../../lib/use-stored-value';
@@ -35,15 +32,8 @@ export default function ThemeToggle({ className = '', compact = false }) {
// server marks nothing selected rather than guessing.
const theme = useStoredValue(getStoredTheme, watchStoredTheme, null);
- useEffect(() => {
- if (theme === null) return undefined;
- applyTheme(theme);
- if (theme !== 'system') return undefined;
- // Only while following the system does an OS change mean anything.
- return watchSystemTheme(() => applyTheme('system'));
- }, [theme]);
-
- // Writing storage is the whole update: every mounted toggle re-reads.
+ // Writing storage is the whole update: every mounted toggle re-reads, and
+ // ThemeSync in the root layout applies it to the document.
const handleSelect = (choice) => setStoredTheme(choice);
const groupClass = `inline-flex h-11 items-center rounded-lg border border-border bg-card ${className}`;
diff --git a/src/app/components/UsernameModal.js b/src/app/components/UsernameModal.js
index 2647524..b980051 100644
--- a/src/app/components/UsernameModal.js
+++ b/src/app/components/UsernameModal.js
@@ -73,56 +73,55 @@ function UsernameForm({ initialValue, hasExistingName, onSubmit, onSecondary })
return (
<>
+
-
-
- {hasExistingName ? 'Cancel' : 'Skip — random name'}
-
-
- Save name
-
-
-
-
-
- Displayed on the leaderboard
-
+
+ Displayed on the leaderboard
+
>
);
}
diff --git a/src/app/debug/coverage/page.js b/src/app/debug/coverage/page.js
index f4a1c0b..0292ed0 100644
--- a/src/app/debug/coverage/page.js
+++ b/src/app/debug/coverage/page.js
@@ -51,8 +51,8 @@ export default function CoveragePage() {
// than they come back, and an older one landing last would redraw stale dots.
const requestIdRef = useRef(0);
// Which region a response is allowed to draw for. The boundary is applied on
- // this rather than on request order -- see load(). Kept current by the effect
- // that switches region, which is the only thing that changes it.
+ // this rather than on request order -- see load(). Kept current by
+ // selectRegion, which is the only thing that changes it.
const regionRef = useRef(region);
const load = useCallback(async (regionCode, viewport) => {
@@ -111,7 +111,11 @@ export default function CoveragePage() {
setCounts(null);
setGeneratedAt(null);
setSelected(null);
+ // Orphan any pano lookup still in flight for the old region.
+ panoRequestRef.current += 1;
setPano(null);
+ setPanoError(null);
+ setPanoLoading(false);
load(code, null);
}, [load]);
diff --git a/src/app/layout.js b/src/app/layout.js
index 3348199..2a854f6 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -1,6 +1,7 @@
import { THEME_STORAGE_KEY } from '../lib/theme';
import AppBackground from './components/AppBackground';
import MusicPlayer from './components/MusicPlayer';
+import ThemeSync from './components/ThemeSync';
import InlineScript from './components/InlineScript';
import DebugFooter from './components/DebugFooter';
import { Analytics } from '@vercel/analytics/next';
@@ -86,10 +87,12 @@ export default function RootLayout({ children }) {
paints over the body background and under every page. */}
- {/* Renders nothing, and its position is load-bearing: inside
- but outside the flex column below, so the loop survives every
- client navigation between the menu and a round, and a null-
- rendering component can never become a row in that column. */}
+ {/* Both render nothing, and their position is load-bearing: inside
+ but outside the flex column below, so the music loop and
+ the theme watcher survive every client navigation between the
+ menu and a round, and a null-rendering component can never become
+ a row in that column. */}
+
{/* Sticky-footer column: pages fill the viewport via flex-1 and the
diff --git a/src/app/page.js b/src/app/page.js
index 558d9fe..b170e5e 100644
--- a/src/app/page.js
+++ b/src/app/page.js
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Beer, Wrench } from "lucide-react";
-import React, { useState } from "react";
+import { useState } from "react";
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import ThemeToggle from './components/ThemeToggle';
diff --git a/src/lib/share.js b/src/lib/share.js
index 1445038..bf88db0 100644
--- a/src/lib/share.js
+++ b/src/lib/share.js
@@ -7,6 +7,16 @@
const MAX_POINTS = 5;
+/**
+ * A score as squares and a fraction, clamped to the ladder: '🟩🟩🟩⬜⬜ 3/5'.
+ * @param {number} score Points earned, 0-5.
+ * @returns {string}
+ */
+export function scoreLine(score) {
+ const points = Math.min(Math.max(Math.trunc(score) || 0, 0), MAX_POINTS);
+ return `${'🟩'.repeat(points)}${'⬜'.repeat(MAX_POINTS - points)} ${points}/${MAX_POINTS}`;
+}
+
/**
* Build the share text for one round.
* @param {string} regionName Region the player picked, e.g. 'Ha Noi'.
@@ -16,9 +26,7 @@ const MAX_POINTS = 5;
* @returns {string}
*/
export function buildShareText(regionName, score, distanceLabel, url) {
- const points = Math.min(Math.max(Math.trunc(score) || 0, 0), MAX_POINTS);
- const squares = '🟩'.repeat(points) + '⬜'.repeat(MAX_POINTS - points);
- return `VNGeoGuessr · ${regionName}\n${squares} ${points}/${MAX_POINTS} · ${distanceLabel} away\n${url}`;
+ return `VNGeoGuessr · ${regionName}\n${scoreLine(score)} · ${distanceLabel} away\n${url}`;
}
/**
@@ -31,10 +39,8 @@ export function buildShareText(regionName, score, distanceLabel, url) {
* @returns {string}
*/
export function buildDailyShareText(number, score, distanceLabel, streak, url) {
- const points = Math.min(Math.max(Math.trunc(score) || 0, 0), MAX_POINTS);
- const squares = '🟩'.repeat(points) + '⬜'.repeat(MAX_POINTS - points);
const streakLine = streak > 1 ? ` · 🔥 ${streak} days` : '';
- return `VNGeoGuessr Daily #${number}\n${squares} ${points}/${MAX_POINTS} · ${distanceLabel} away${streakLine}\n${url}`;
+ return `VNGeoGuessr Daily #${number}\n${scoreLine(score)} · ${distanceLabel} away${streakLine}\n${url}`;
}
/**
diff --git a/src/lib/storage.js b/src/lib/storage.js
index b8c8bd0..56b75cc 100644
--- a/src/lib/storage.js
+++ b/src/lib/storage.js
@@ -9,6 +9,10 @@
// 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.
+// A write the browser refuses is kept in memory instead, so it still holds
+// for the rest of the visit: every value is read back through this module,
+// and a name or theme that silently reverts leaves the page unusable (the
+// name prompt would reopen forever).
//
// 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
@@ -16,6 +20,8 @@
// pair, one hidden) stay in step that way.
const local = typeof EventTarget === 'undefined' ? null : new EventTarget();
+// key -> value (or null for removed) for writes storage refused.
+const refused = new Map();
/**
* Read a raw string, or null when absent or unreadable.
@@ -24,6 +30,7 @@ const local = typeof EventTarget === 'undefined' ? null : new EventTarget();
*/
export function readItem(key) {
if (typeof window === 'undefined') return null;
+ if (refused.has(key)) return refused.get(key);
try {
return localStorage.getItem(key);
} catch {
@@ -42,8 +49,9 @@ export function writeItem(key, value) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
+ refused.delete(key);
} catch {
- // Best effort: the caller's in-memory state still applies for this visit.
+ refused.set(key, value);
}
local?.dispatchEvent(new CustomEvent('change', { detail: key }));
}
diff --git a/tests/storage.test.js b/tests/storage.test.js
index f0a443c..dd63e92 100644
--- a/tests/storage.test.js
+++ b/tests/storage.test.js
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } 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';
@@ -20,3 +20,24 @@ describe('browser storage without a browser', () => {
expect(getDailyProgress()).toBeNull();
});
});
+
+// A private window or blocked site data: every access throws.
+describe('browser storage that refuses access', () => {
+ it('keeps a refused write for the rest of the visit', async () => {
+ vi.resetModules();
+ const blocked = () => { throw new Error('blocked'); };
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('localStorage', { getItem: blocked, setItem: blocked, removeItem: blocked });
+ try {
+ const storage = await import('../src/lib/storage.js');
+ expect(storage.readItem('k')).toBeNull();
+ storage.writeItem('k', 'v');
+ expect(storage.readItem('k')).toBe('v');
+ storage.writeItem('k', null);
+ expect(storage.readItem('k')).toBeNull();
+ } finally {
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ }
+ });
+});