feat(ui): semantic colour tokens, OS dark mode, and accessibility pass

Every surface hardcoded its own grey and red, so the dark palette already
defined in globals.css could never take effect and muted text failed
contrast. Route colour through tokens instead: --brand and --surface join
the shadcn set, the Vietnamese flag red stays the brand, and a
prefers-color-scheme block supplies the dark values.

The dark: variant now matches both the media query and a .dark ancestor.
It previously matched only the class, so dark: utilities compiled to rules
that could never apply under OS-driven theming.

Contrast, measured against WCAG AA rather than eyeballed:
text-gray-400 on the page background was 2.41:1, the blue and purple rank
labels 2.34:1 and 2.46:1, white on amber-500 2.15:1. All twelve token
pairs now sit between 4.7:1 and 19.8:1 in both themes.

Also: emoji and text glyphs used as icons give way to lucide SVGs with
accessible names, score and rank stop relying on colour alone, loading and
result regions announce via role=status, interactive targets reach 44px,
100vh becomes dvh so mobile browser chrome stops clipping the game, the
leaderboard tab count follows the city list, and numeric columns use
tabular figures.
This commit is contained in:
tiennm99 committed 2026-08-29 15:16:19 +07:00
1 parent 9645a355c6
commit 577cb8f599
12 files changed
+348 -141

No files matched your search

@@ -0,0 +1,110 @@
# Research Report: Free Street-Level Imagery Alternatives to Mapillary (Vietnam)
Conducted 2026-08-29. All coverage numbers below are live API probes run that day, not vendor claims.
## Executive Summary
**No better free alternative exists.** Stay on Mapillary. Every candidate that is free and API-accessible fails the one requirement this game cannot compromise on: equirectangular 360° panoramas in Vietnamese cities. KartaView and Panoramax both have open, fast, keyless APIs and real Vietnam data — but effectively zero usable panoramas. Google Street View has the coverage and is blocked by billing, as stated.
**The actual win is not a provider switch.** It is dropping Mapillary's Graph *search* API for its *vector tile* API. Current cost is up to 20 bbox searches at ~1-5s each; the tile path is one CDN request plus one entity lookup. That attacks the same latency the dart-throw rework already halved, and goes further, without changing imagery source, licensing, or coverage.
Caveat up front: the tile path is documented but **not empirically verified** here — `MAPILLARY_ACCESS_TOKEN` is not available in this environment (env pull blocked). Everything else in this report is measured.
## Methodology
- 4 web searches, 1 doc fetch, ~12 direct API probes against KartaView, Panoramax, and image CDNs.
- Coverage measured by querying each API at the centre of TPHCM, HN, DL, DN and counting panorama-flagged records.
- Panorama claims verified by downloading actual JPEGs and reading dimensions from the SOF header — vendor `field_of_view` metadata was **not** trusted, and that scepticism proved necessary.
## Candidate Evaluation
| Provider | API free? | Key needed? | VN coverage | Usable 360°? | Verdict |
|---|---|---|---|---|---|
| **Mapillary** (current) | yes | yes | good | **yes** | **keep** |
| KartaView | yes | **no** | thin | **no** | reject |
| Panoramax | yes | **no** | thin | **no** | reject |
| Google Street View | free tier, billing required | yes | best | yes | blocked (billing) |
| Bing Streetside | — | — | — | — | platform retiring |
| Yandex / Apple / Naver / Kakao | no public API | — | none | — | reject |
### KartaView — closest call, still fails
Genuinely attractive on paper: `POST https://api.kartaview.org/1.0/list/nearby-photos/` needs **no API key**, and answered in **0.05-0.30s** (vs Mapillary's 1-5s). Grab-backed, so Southeast Asia is a focus, and it markets "3 Cities in Southeast Asia, in full 360º Imagery".
Measured reality, 300m radius at each city centre:
| City | photos | `projection: SPHERE` |
|---|---|---|
| TPHCM | 893 | 13 |
| HN | 49 | 0 |
| DL | 1 | 0 |
| DN | 0 | 0 |
Worse, the 13 HCMC SPHERE records are mislabelled. All 6 sampled carry `field_of_view: 360` and `projection: SPHERE`, but the served JPEG is **2560×1440 (16:9)** — a flat frame, not a 2:1 equirectangular pano. PhotoSphere Viewer would render it as a distorted band. Outside HCMC there is no meaningful density at all.
Image CDN, for the record: `name` is prefixed `storage13/`; canonical URL is `https://kartaview.org/<name>`, 301-ing to `https://storage13.openstreetcam.org/files/...`. Variants `proc` (2.4 MB), `lth` (341 KB), `th`.
### Panoramax — open and well-built, wrong geography
STAC API, no key, fast: `GET https://api.panoramax.xyz/api/search?bbox=minLng,minLat,maxLng,maxLat`. Federated, IGN + OSM-France backed, CC-BY-SA.
Vietnam data exists but is entirely narrow-FOV phone photography:
| Scope | images sampled | `field_of_view` values | 360° |
|---|---|---|---|
| TPHCM | 120 | absent | 0 |
| HN | 200 | 72° | 0 |
| Vietnam-wide | 200 | 18°-108° | 0 |
526 Vietnam images sampled, zero panoramas. Coverage is France-centric by design; the Taiwan and Wales instances show the federation model working, but nobody is running a Vietnam instance.
### Google Street View
Best Vietnam coverage by far, and the Street View Static API does have a free monthly tier. But it still requires an enabled Google Cloud **billing account**, which is the stated blocker. Not routable around without a foreign card or reseller — out of scope for this report.
## Recommendation: switch Mapillary access pattern, not provider
Current `fetchMapillaryImages` searches a random 1km window and re-rolls on a miss. Each miss is a full Graph *search* round-trip (~1-5s). The concurrent-round rework cut p50 from 17.8s to ~4s, but the ceiling is still bounded by search latency.
Mapillary serves the same coverage as vector tiles from a CDN. The `image` layer at **zoom 14** carries `id` and `is_pano` per point — exactly the two fields the dart-throw is hunting for.
```
current: up to 20 × graph search (~1-5s each) → p50 ~4s
proposed: 1 × tile fetch (CDN) + 1 × entity lookup → est. ~300-800ms
```
Sketch:
1. Compute the z14 tile range covering the city bbox (a z14 tile is ~2.4km; TPHCM ≈ 36 tiles).
2. Pick a random tile, fetch
`https://tiles.mapillary.com/maps/vtp/mly1_public/2/14/{x}/{y}?access_token=…`
3. Decode MVT, take the `image` layer, filter `is_pano === true`.
4. Random pick → gives `id` **and** point geometry (so no coordinate lookup needed).
5. One entity call for the image URL:
`https://graph.mapillary.com/{id}?fields=thumb_original_url`
Why this beats the dart-throw: a z14 urban tile holds hundreds of image points, so "does this window contain a pano" stops being a coin flip. Misses become rare instead of typical.
**Costs and caveats:**
- Two new deps to decode MVT: `@mapbox/vector-tile` + `pbf`. Both small, pure JS, no native build.
- **Tile API rate limit is 50,000/day — not per minute.** Graph is 60k/min. This is far tighter and is the main risk. Mitigation: cache decoded tiles in the existing Upstash (they change slowly), which also drops most game starts to a single entity lookup.
- Empty/ocean tiles still need a re-roll, so keep a bounded retry loop.
## Also worth doing regardless
Da Lat returned `success: false` during yesterday's verification, and Ha Noi's p90 is still ~17s. Both are hit-rate problems, not latency problems — HN uses the smallest window (`mapillaryDelta: 0.003`). Widening deltas is a cheaper experiment than any of the above and is independent of it.
## Unresolved questions
1. **Tile path unverified.** No `MAPILLARY_ACCESS_TOKEN` in this environment, so steps 2-5 above are documentation-based. Needs one probe against a real token before committing to it.
2. **50k/day tile ceiling** — is that per app or per token? Docs say "limited to 50,000 per day"; unclear whether caching alone keeps a popular deployment under it.
3. **Pano density per z14 tile in VN cities** unmeasured — determines whether the retry loop can shrink to 2-3 attempts or stays near current size.
4. Whether Google Cloud billing is genuinely unreachable, or reachable via a foreign card — would change the recommendation outright, since Street View coverage dwarfs Mapillary's in Vietnam.
## Sources
- [KartaView](https://kartaview.org/) · [KartaView GitHub](https://github.com/kartaview) · [SEA 360° imagery](https://kartaview.org/landing/open-imagery)
- [Panoramax](https://www.panoramax.xyz/en/instances) · [Panoramax API](https://api.panoramax.xyz/) · [Wikipedia](https://en.wikipedia.org/wiki/Panoramax)
- [Mapillary API documentation](https://www.mapillary.com/developer/api-documentation) · [API v4 getting started](https://blog.mapillary.com/update/2021/06/23/getting-started-with-the-new-mapillary-api-v4.html)
- [Google Street View alternatives 2026](https://topalter.com/best-google-street-view-alternatives/free)
+8 -4
View File
@@ -1,5 +1,7 @@
"use client";
import Image from 'next/image';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
@@ -13,19 +15,21 @@ export default function DonateQRModal({ isOpen, onClose }) {
<div className="text-center space-y-4 flex flex-col items-center">
<div className="w-full max-w-[320px] mx-auto">
<img
<Image
src="/qr.png"
alt="Donate QR Code"
alt="QR code for bank transfer donation"
width={320}
height={320}
className="w-full h-auto object-contain rounded-xl shadow-lg"
/>
</div>
<p className="text-sm text-gray-500">Scan to support the project</p>
<p className="text-sm text-muted-foreground">Scan to support the project</p>
<Button
onClick={onClose}
variant="outline"
className="px-6"
className="min-h-11 px-6"
>
Close
</Button>
+72 -53
View File
@@ -3,12 +3,13 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { ArrowLeft, Coffee } from 'lucide-react';
import PanoramaViewer from './PanoramaViewer';
import DonateQRModal from './DonateQRModal';
const LeafletMap = dynamic(() => import('./LeafletMap'), {
ssr: false,
loading: () => <div className="w-full h-full min-h-[400px] bg-gray-100 flex items-center justify-center text-gray-400">Loading map...</div>
loading: () => <div role="status" aria-live="polite" className="w-full h-full min-h-[400px] bg-muted flex items-center justify-center text-muted-foreground">Loading map...</div>
});
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -336,42 +337,53 @@ export default function GameClient() {
}, [showResult]);
const getScoreBg = (s) => {
if (s >= 5) return 'bg-green-500';
if (s >= 4) return 'bg-emerald-500';
if (s >= 3) return 'bg-amber-500';
if (s >= 2) return 'bg-orange-500';
if (s >= 1) return 'bg-red-400';
return 'bg-gray-400';
if (s >= 5) return 'bg-green-600';
if (s >= 4) return 'bg-emerald-600';
if (s >= 3) return 'bg-amber-600';
if (s >= 2) return 'bg-orange-600';
if (s >= 1) return 'bg-red-500';
return 'bg-neutral-500';
};
// Colour alone must not carry the result; every band also gets a word.
const getScoreLabel = (s) => {
if (s >= 5) return 'Pinpoint';
if (s >= 4) return 'Excellent';
if (s >= 3) return 'Good';
if (s >= 2) return 'Fair';
if (s >= 1) return 'Far';
return 'Missed';
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center vn-gradient-bg">
<div className="text-center space-y-4 animate-fade-in-up">
<div className="w-12 h-12 border-4 border-gray-200 border-t-red-600 rounded-full animate-spin mx-auto" />
<p className="text-gray-700 text-lg font-medium">Loading panoramic image...</p>
<p className="text-gray-400 text-sm">{cityNames[location] || location}</p>
<div className="min-h-dvh flex items-center justify-center vn-gradient-bg">
<div className="text-center space-y-4 animate-fade-in-up" role="status" aria-live="polite">
<div className="w-12 h-12 border-4 border-border border-t-brand rounded-full animate-spin mx-auto" aria-hidden="true" />
<p className="text-foreground text-lg font-medium">Loading panoramic image...</p>
<p className="text-muted-foreground text-sm">{cityNames[location] || location}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<div className="min-h-dvh vn-gradient-bg flex flex-col">
{/* Compact Header */}
<header className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-200 shadow-sm">
<header className="flex items-center justify-between px-4 py-2 bg-card border-b border-border shadow-sm">
<Button
onClick={handleGoBack}
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-900"
className="min-h-11 text-muted-foreground hover:text-foreground"
>
← Back
<ArrowLeft className="size-4" aria-hidden="true" />
Back
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-800 hidden sm:inline">VNGeoGuessr</span>
<Badge className="bg-red-600 text-white text-xs">
<span className="text-sm font-bold text-foreground hidden sm:inline">VNGeoGuessr</span>
<Badge className="bg-brand text-brand-foreground text-xs">
{cityNames[location] || location}
</Badge>
</div>
@@ -380,16 +392,18 @@ export default function GameClient() {
onClick={() => setShowDonate(true)}
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-900"
aria-label="Buy me a coffee"
className="min-h-11 text-muted-foreground hover:text-foreground"
>
Buy me a coffee
<Coffee className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">Buy me a coffee</span>
</Button>
</header>
{/* Game Content */}
<div className="flex-1 p-3 grid lg:grid-cols-2 gap-3" style={{ height: 'calc(100vh - 52px)' }}>
<div className="flex-1 p-3 grid lg:grid-cols-2 gap-3 lg:h-[calc(100dvh-52px)]">
{/* Panorama Viewer */}
<div className="bg-gray-900 rounded-lg overflow-hidden">
<div className="bg-neutral-900 rounded-lg overflow-hidden min-h-[45dvh] lg:min-h-0">
{imageData ? (
<PanoramaViewer
key={imageData.id}
@@ -398,15 +412,15 @@ export default function GameClient() {
onError={handlePanoramaError}
/>
) : (
<div className="w-full h-full min-h-[400px] flex items-center justify-center">
<p className="text-gray-400">Loading panorama...</p>
<div className="w-full h-full min-h-[400px] flex items-center justify-center" role="status" aria-live="polite">
<p className="text-neutral-300">Loading panorama...</p>
</div>
)}
</div>
{/* Map and Controls */}
<div className="flex flex-col gap-3">
<div className="bg-white rounded-lg overflow-hidden flex-1 shadow-sm border border-gray-200">
<div className="bg-card rounded-lg overflow-hidden flex-1 shadow-sm border border-border">
<LeafletMap
center={mapCenter}
bbox={CITIES[location]?.bbox}
@@ -419,7 +433,7 @@ export default function GameClient() {
<Button
onClick={handleSubmitGuess}
disabled={!guessCoordinates || loading}
className="flex-1 py-4 text-base font-bold bg-red-600 hover:bg-red-700 text-white"
className="flex-1 min-h-12 py-4 text-base font-bold bg-brand text-brand-foreground hover:bg-brand-hover"
size="lg"
>
{loading ? 'Processing...' : guessCoordinates ? 'Submit Guess' : 'Click the map first'}
@@ -428,7 +442,7 @@ export default function GameClient() {
onClick={handleSkipGuess}
disabled={loading}
variant="outline"
className="py-4 px-5 text-base font-medium"
className="min-h-12 py-4 px-5 text-base font-medium"
size="lg"
>
Skip
@@ -444,35 +458,40 @@ export default function GameClient() {
<DialogTitle className="text-center text-2xl font-bold">Round Result</DialogTitle>
</DialogHeader>
<div className="text-center space-y-4 animate-fade-in-up">
<div className="text-center space-y-4 animate-fade-in-up" role="status" aria-live="polite">
{/* Score circle */}
<div className={`inline-flex items-center justify-center w-20 h-20 rounded-full text-white text-3xl font-extrabold ${getScoreBg(score)}`}>
{score}
<div className="flex flex-col items-center gap-2">
<div className={`inline-flex items-center justify-center w-20 h-20 rounded-full text-white text-3xl font-extrabold ${getScoreBg(score)}`}>
{score}
</div>
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{getScoreLabel(score)}
</span>
</div>
<div>
<Badge variant="outline" className="text-lg font-semibold text-blue-600 px-3 py-1">
<Badge variant="outline" className="text-lg font-semibold px-3 py-1 tabular-nums">
{formatDistance(distance)} away
</Badge>
</div>
<p className="text-gray-600 text-sm">{getResultMessage(score, distance)}</p>
<p className="text-muted-foreground text-sm">{getResultMessage(score, distance)}</p>
{/* Leaderboard ranks */}
{(globalScore !== null || cityScore !== null) && (
<div className="grid grid-cols-2 gap-2 text-sm">
{cityScore !== null && (
<div className="bg-blue-50 rounded-lg p-2">
<p className="font-semibold text-blue-700">{cityNames[location]}</p>
<p className="text-blue-600">Total: {cityScore}</p>
{cityRank && <p className="text-blue-400 text-xs">Rank #{cityRank}</p>}
<div className="bg-brand-subtle text-brand-subtle-foreground rounded-lg p-2">
<p className="font-semibold">{cityNames[location]}</p>
<p className="tabular-nums">Total: {cityScore}</p>
{cityRank && <p className="text-xs opacity-80 tabular-nums">Rank #{cityRank}</p>}
</div>
)}
{globalScore !== null && (
<div className="bg-purple-50 rounded-lg p-2">
<p className="font-semibold text-purple-700">Global</p>
<p className="text-purple-600">Total: {globalScore}</p>
{globalRank && <p className="text-purple-400 text-xs">Rank #{globalRank}</p>}
<div className="bg-muted text-foreground rounded-lg p-2">
<p className="font-semibold">Global</p>
<p className="tabular-nums">Total: {globalScore}</p>
{globalRank && <p className="text-xs text-muted-foreground tabular-nums">Rank #{globalRank}</p>}
</div>
)}
</div>
@@ -481,38 +500,38 @@ export default function GameClient() {
{(globalDistanceRank !== null || cityDistanceRank !== null) && (
<div className="grid grid-cols-2 gap-2 text-xs">
{cityDistanceRank !== null && (
<div className="bg-gray-50 rounded-lg p-2">
<p className="font-medium text-gray-700">{cityNames[location]} Distance</p>
<p className="text-gray-500">Rank #{cityDistanceRank}</p>
<div className="bg-muted rounded-lg p-2">
<p className="font-medium text-foreground">{cityNames[location]} Distance</p>
<p className="text-muted-foreground tabular-nums">Rank #{cityDistanceRank}</p>
</div>
)}
{globalDistanceRank !== null && (
<div className="bg-gray-50 rounded-lg p-2">
<p className="font-medium text-gray-700">Global Distance</p>
<p className="text-gray-500">Rank #{globalDistanceRank}</p>
<div className="bg-muted rounded-lg p-2">
<p className="font-medium text-foreground">Global Distance</p>
<p className="text-muted-foreground tabular-nums">Rank #{globalDistanceRank}</p>
</div>
)}
</div>
)}
{leaderboardMessage && (
<p className="text-sm text-green-600 font-medium">{leaderboardMessage}</p>
<p className="text-sm text-green-700 dark:text-green-400 font-medium">{leaderboardMessage}</p>
)}
<p className="text-xs text-gray-400">
<p className="text-xs text-muted-foreground">
{username || 'Anonymous'} • {cityNames[location] || location}
</p>
</div>
{/* Result Map */}
<div className="rounded-lg overflow-hidden border border-gray-200">
<div className="rounded-lg overflow-hidden border border-border">
<div
ref={resultMapRef}
key={`map-${sessionId}`}
className="h-52 w-full bg-gray-100"
className="h-52 w-full bg-muted"
style={{ minHeight: '208px' }}
/>
<div className="flex justify-between text-xs text-gray-400 px-3 py-1.5 bg-gray-50">
<div className="flex justify-between text-xs text-muted-foreground px-3 py-1.5 bg-muted/50 tabular-nums">
{exactLocation && (
<span>Actual: {exactLocation.lat.toFixed(4)}, {exactLocation.lng.toFixed(4)}</span>
)}
@@ -527,7 +546,7 @@ export default function GameClient() {
<Button
onClick={handleNextRound}
size="lg"
className="flex-1 bg-red-600 hover:bg-red-700 text-white font-bold"
className="flex-1 min-h-12 bg-brand text-brand-foreground hover:bg-brand-hover font-bold"
>
Next Round
</Button>
@@ -535,7 +554,7 @@ export default function GameClient() {
onClick={handleGoBack}
variant="outline"
size="lg"
className="flex-1"
className="flex-1 min-h-12"
>
Menu
</Button>
+29 -23
View File
@@ -1,24 +1,27 @@
"use client";
import { Medal } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { formatDistance, getDistanceColor } from '../../lib/game';
function getScoreColor(score) {
if (score >= 50) return 'text-purple-600';
if (score >= 25) return 'text-green-600';
if (score >= 15) return 'text-blue-600';
if (score >= 10) return 'text-yellow-600';
if (score >= 5) return 'text-orange-600';
return 'text-red-600';
if (score >= 50) return 'text-purple-700 dark:text-purple-300';
if (score >= 25) return 'text-green-700 dark:text-green-300';
if (score >= 15) return 'text-blue-700 dark:text-blue-300';
if (score >= 10) return 'text-yellow-700 dark:text-yellow-300';
if (score >= 5) return 'text-orange-700 dark:text-orange-300';
return 'text-red-700 dark:text-red-300';
}
function getRankIcon(rank) {
// Medal tint for the podium. The rank number stays visible alongside it, so the
// placing never depends on reading the colour or an emoji glyph.
function getMedalClass(rank) {
switch (rank) {
case 1: return '🥇';
case 2: return '🥈';
case 3: return '🥉';
default: return `#${rank}`;
case 1: return 'text-amber-500';
case 2: return 'text-slate-400';
case 3: return 'text-orange-700 dark:text-orange-400';
default: return '';
}
}
@@ -29,7 +32,7 @@ export default function LeaderboardList({ data, loading, currentUsername, type }
return (
<div className="space-y-2">
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-gray-100">
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-muted">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-10" />
<Skeleton className="h-4 w-20" />
@@ -44,8 +47,8 @@ export default function LeaderboardList({ data, loading, currentUsername, type }
if (data.length === 0) {
return (
<div className="text-center py-10">
<p className="text-gray-500 text-base">No {isDistance ? 'records' : 'scores'} yet!</p>
<p className="text-gray-400 text-sm mt-1">Be the first to play!</p>
<p className="text-foreground text-base">No {isDistance ? 'records' : 'scores'} yet!</p>
<p className="text-muted-foreground text-sm mt-1">Be the first to play!</p>
</div>
);
}
@@ -61,31 +64,34 @@ export default function LeaderboardList({ data, loading, currentUsername, type }
key={key}
className={`flex items-center justify-between p-3 rounded-lg transition-all ${
isUser
? 'bg-amber-50 border-2 border-amber-400 shadow-md'
? 'bg-amber-100/70 dark:bg-amber-950/40 border-2 border-amber-500 shadow-md'
: entry.rank <= 3
? 'bg-red-50/50 border border-red-100'
: 'bg-gray-50 hover:bg-gray-100'
? 'bg-brand-subtle/40 border border-brand/20'
: 'bg-muted/50 hover:bg-muted'
}`}
>
<div className="flex items-center gap-3">
<div className="text-lg font-bold w-10 text-center">
{getRankIcon(entry.rank)}
<div className="flex items-center gap-1 w-14 shrink-0">
{entry.rank <= 3 && (
<Medal className={`size-4 shrink-0 ${getMedalClass(entry.rank)}`} aria-hidden="true" />
)}
<span className="text-base font-bold tabular-nums">#{entry.rank}</span>
</div>
<div>
<div className={`font-semibold ${isUser ? 'text-amber-800' : 'text-gray-800'}`}>
<div className={`font-semibold ${isUser ? 'text-amber-900 dark:text-amber-200' : 'text-foreground'}`}>
{entry.username}
{isUser && (
<Badge className="ml-2 bg-amber-500 text-white text-xs">YOU</Badge>
<Badge className="ml-2 bg-amber-700 text-white text-xs">YOU</Badge>
)}
</div>
{isDistance && entry.timestamp && (
<div className="text-xs text-gray-400">
<div className="text-xs text-muted-foreground">
{new Date(entry.timestamp).toLocaleDateString()}
</div>
)}
</div>
</div>
<Badge variant="secondary" className={`text-lg font-bold ${
<Badge variant="secondary" className={`text-lg font-bold tabular-nums ${
isDistance ? getDistanceColor(entry.distance) : getScoreColor(entry.score)
}`}>
{isDistance ? formatDistance(entry.distance) : entry.score}
+1 -1
View File
@@ -124,7 +124,7 @@ export default function LeafletMap({
return (
<div
ref={mapRef}
className={`bg-gray-200 rounded-lg overflow-hidden ${className}`}
className={`bg-muted rounded-lg overflow-hidden ${className}`}
/>
);
}
+2 -2
View File
@@ -32,7 +32,7 @@ function PanoramaViewer({ imageUrl, onReady, onError }) {
if (!imageUrl) {
// Show loading or error state
containerRef.current.innerHTML = `
<div style="width:100%; height:100%; display:flex; justify-content:center; align-items:center; color: white; text-align: center;">
<div role="status" aria-live="polite" style="width:100%; height:100%; display:flex; justify-content:center; align-items:center; color: white; text-align: center;">
<div>Loading panoramic image...</div>
</div>
`;
@@ -140,7 +140,7 @@ function PanoramaViewer({ imageUrl, onReady, onError }) {
return (
<div
ref={containerRef}
className="w-full h-full min-h-[400px] bg-gray-900 rounded-lg overflow-hidden"
className="w-full h-full min-h-[400px] bg-neutral-900 rounded-lg overflow-hidden"
/>
);
}
+15 -6
View File
@@ -60,7 +60,9 @@ export default function UsernameModal({ isOpen, onSubmit, onClose }) {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm font-medium">Username</Label>
<Label htmlFor="username" className="text-sm font-medium">
Username <span className="text-destructive" aria-hidden="true">*</span>
</Label>
<Input
id="username"
value={username}
@@ -68,10 +70,17 @@ export default function UsernameModal({ isOpen, onSubmit, onClose }) {
placeholder="Your username"
maxLength={20}
autoFocus
className="h-10"
required
autoComplete="username"
aria-describedby="username-help"
aria-invalid={error ? true : undefined}
className="h-11"
/>
<p id="username-help" className="text-xs text-muted-foreground">
2-20 characters. Letters, numbers, hyphens and underscores.
</p>
{error && (
<Alert variant="destructive" className="py-2">
<Alert variant="destructive" className="py-2" role="alert" aria-live="assertive">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
)}
@@ -82,20 +91,20 @@ export default function UsernameModal({ isOpen, onSubmit, onClose }) {
type="button"
variant="ghost"
onClick={onClose}
className="flex-1"
className="flex-1 min-h-11"
>
Skip
</Button>
<Button
type="submit"
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
className="flex-1 min-h-11 bg-brand text-brand-foreground hover:bg-brand-hover"
>
Start Playing
</Button>
</div>
</form>
<p className="text-xs text-gray-400 text-center">
<p className="text-xs text-muted-foreground text-center">
Displayed on the leaderboard
</p>
</DialogContent>
+4 -4
View File
@@ -5,10 +5,10 @@ import GameClient from '../components/GameClient';
function GameLoadingFallback() {
return (
<div className="min-h-screen flex items-center justify-center vn-gradient-bg">
<div className="text-center space-y-4 animate-fade-in-up">
<div className="w-12 h-12 border-4 border-gray-200 border-t-red-600 rounded-full animate-spin mx-auto" />
<p className="text-gray-700 text-lg font-medium">Loading game...</p>
<div className="min-h-dvh flex items-center justify-center vn-gradient-bg">
<div role="status" aria-live="polite" className="text-center space-y-4 animate-fade-in-up">
<div className="w-12 h-12 border-4 border-border border-t-brand rounded-full animate-spin mx-auto" aria-hidden="true" />
<p className="text-foreground text-lg font-medium">Loading game...</p>
</div>
</div>
);
+51 -5
View File
@@ -43,6 +43,12 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-brand: var(--brand);
--color-brand-hover: var(--brand-hover);
--color-brand-foreground: var(--brand-foreground);
--color-brand-subtle: var(--brand-subtle);
--color-brand-subtle-foreground: var(--brand-subtle-foreground);
--color-surface: var(--surface);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -62,7 +68,7 @@
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--muted-foreground: oklch(0.54 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
@@ -137,16 +143,56 @@
z-index: 9999 !important;
}
/* Vietnamese brand colors */
/* Vietnamese flag palette. Kept as the brand identity; --brand is the token
every component should reference rather than a raw red utility class. */
:root {
--vn-red: #da251d;
--vn-red-dark: #c41e3a;
--vn-gold: #ffd700;
--brand: var(--vn-red);
--brand-hover: var(--vn-red-dark);
--brand-foreground: #ffffff;
--brand-subtle: oklch(0.962 0.028 22);
--brand-subtle-foreground: oklch(0.45 0.19 27);
/* Page background, one step off --background so cards read as raised. */
--surface: oklch(0.977 0.002 250);
}
/* Dark palette. Red is lightened and desaturated rather than inverted, so it
stays legible on dark surfaces instead of vibrating against them. */
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.75 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--brand: oklch(0.68 0.18 27);
--brand-hover: oklch(0.74 0.17 27);
--brand-foreground: oklch(0.145 0 0);
--brand-subtle: oklch(0.28 0.06 27);
--brand-subtle-foreground: oklch(0.86 0.11 27);
--surface: oklch(0.115 0 0);
}
}
/* Soft clean background */
.vn-gradient-bg {
background: #f8f9fb;
background: var(--surface);
}
/* Spinner animation for loading */
@@ -184,7 +230,7 @@
top: 0;
bottom: 0;
width: 3px;
background: var(--vn-red);
background: var(--brand);
border-radius: 3px 0 0 3px;
opacity: 0;
transition: opacity 0.2s ease;
+10
View File
@@ -18,6 +18,16 @@ export const metadata = {
description: "GeoGuessr for VietNam",
};
// light dark lets the OS preference drive native controls, scrollbars and form
// widgets, matching the token palette in globals.css.
export const viewport = {
colorScheme: "light dark",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f8f9fb" },
{ media: "(prefers-color-scheme: dark)", color: "#0d0d0d" },
],
};
export default function RootLayout({ children }) {
return (
<html lang="en">
+41 -38
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { ArrowRight, Trophy, Coffee, Wrench } from "lucide-react";
import React, { useState, useEffect, useRef } from "react";
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -97,19 +98,19 @@ export default function Home() {
const renderTabContent = (locationKey) => (
<div className="flex gap-3">
<div className="flex flex-col space-y-2 min-w-[100px]">
<div className="text-[10px] font-medium text-gray-400 uppercase tracking-wider px-2">Type</div>
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider px-2">Type</div>
{['score', 'distance'].map(type => (
<button
<Button
key={type}
onClick={() => setActiveTypeTab(type)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors capitalize ${
activeTypeTab === type
? 'bg-red-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
variant={activeTypeTab === type ? 'default' : 'secondary'}
aria-pressed={activeTypeTab === type}
className={`min-h-11 justify-start capitalize ${
activeTypeTab === type ? 'bg-brand text-brand-foreground hover:bg-brand-hover' : ''
}`}
>
{type}
</button>
</Button>
))}
</div>
<div className="flex-1">
@@ -124,31 +125,33 @@ export default function Home() {
);
return (
<div className="min-h-screen vn-gradient-bg">
<div className="min-h-screen">
<div className="min-h-dvh vn-gradient-bg">
<div className="min-h-dvh">
<div className="container mx-auto px-4 py-6 max-w-5xl">
{/* Header */}
<header className="flex justify-between items-center mb-10">
<Link href="/" className="text-2xl font-bold text-gray-900 tracking-wider">
<Link href="/" className="text-2xl font-bold text-foreground tracking-wider">
VNGeoGuessr
</Link>
<div className="flex items-center gap-3">
{username && (
<span className="text-gray-500 text-sm hidden sm:inline">
Playing as <span className="font-semibold text-red-600">{username}</span>
<span className="text-muted-foreground text-sm hidden sm:inline">
Playing as <span className="font-semibold text-brand">{username}</span>
</span>
)}
<Button
onClick={handleLeaderboardClick}
variant="outline"
className="transition-all"
className="min-h-11 transition-all"
>
<Trophy className="size-4" aria-hidden="true" />
Leaderboard
</Button>
<Button
onClick={() => setShowDonateModal(true)}
className="bg-red-600 hover:bg-red-700 text-white transition-all"
className="min-h-11 bg-brand text-brand-foreground hover:bg-brand-hover transition-all"
>
<Coffee className="size-4" aria-hidden="true" />
Buy me a coffee
</Button>
</div>
@@ -156,34 +159,34 @@ export default function Home() {
{/* Hero */}
<div className="text-center mb-12 animate-fade-in-up">
<h1 className="text-5xl sm:text-6xl font-extrabold text-gray-900 mb-3 tracking-tight">
<h1 className="text-5xl sm:text-6xl font-extrabold text-foreground mb-3 tracking-tight">
Guess the Location
</h1>
<p className="text-lg text-gray-500 max-w-lg mx-auto">
<p className="text-lg text-muted-foreground max-w-lg mx-auto">
Explore Vietnamese streets and test your geography skills across iconic cities.
</p>
</div>
<div className="grid lg:grid-cols-5 gap-6 max-w-5xl mx-auto">
{/* How to Play */}
<Card className="lg:col-span-2 bg-white border border-gray-200 shadow-sm">
<Card className="lg:col-span-2 bg-card border-border shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold text-gray-900">How to Play</CardTitle>
<CardTitle className="text-xl font-bold text-card-foreground">How to Play</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{STEP_LABELS.map((label, i) => (
<div key={i} className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-50 text-red-600 flex items-center justify-center text-sm font-bold shrink-0">
<div className="w-8 h-8 rounded-full bg-brand-subtle text-brand-subtle-foreground flex items-center justify-center text-sm font-bold shrink-0">
{i + 1}
</div>
<p className="text-gray-600 text-sm leading-relaxed pt-1">{label}</p>
<p className="text-muted-foreground text-sm leading-relaxed pt-1">{label}</p>
</div>
))}
</div>
<div className="mt-6 pt-4 border-t border-gray-100">
<p className="text-gray-400 text-xs mb-2 uppercase tracking-wider font-medium">Scoring</p>
<div className="grid grid-cols-2 gap-1 text-xs text-gray-500">
<div className="mt-6 pt-4 border-t border-border">
<p className="text-muted-foreground text-xs mb-2 uppercase tracking-wider font-medium">Scoring</p>
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<span>0-50m = 5 pts</span>
<span>50-100m = 4 pts</span>
<span>100-200m = 3 pts</span>
@@ -196,9 +199,9 @@ export default function Home() {
</Card>
{/* City Selection */}
<Card className="lg:col-span-3 bg-white border border-gray-200 shadow-sm">
<Card className="lg:col-span-3 bg-card border-border shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold text-gray-900 text-center">Select a City</CardTitle>
<CardTitle className="text-xl font-bold text-card-foreground text-center">Select a City</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3">
@@ -206,16 +209,15 @@ export default function Home() {
<Link
key={city.code}
href={`/game?location=${city.code}`}
className="city-card-accent block p-4 rounded-lg bg-gray-50 hover:bg-gray-100 border border-gray-200 hover:border-gray-300 transition-all duration-200 group"
className="city-card-accent flex min-h-14 items-center justify-between p-4 rounded-lg bg-muted/40 hover:bg-muted border border-border hover:border-brand/40 transition-colors duration-200 group focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="flex items-center justify-between">
<span className="text-gray-800 font-semibold text-lg group-hover:text-red-600 transition-colors">
{city.name}
</span>
<span className="text-gray-300 group-hover:text-gray-500 transition-colors text-xl">
→
</span>
</div>
<span className="text-foreground font-semibold text-lg group-hover:text-brand transition-colors">
{city.name}
</span>
<ArrowRight
className="size-5 text-muted-foreground group-hover:text-brand transition-colors"
aria-hidden="true"
/>
</Link>
))}
</div>
@@ -234,7 +236,7 @@ export default function Home() {
<DialogTitle className="text-2xl text-center font-bold">Leaderboards</DialogTitle>
</DialogHeader>
<Tabs value={activeLocationTab} onValueChange={setActiveLocationTab} className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${cities.length + 1}, minmax(0, 1fr))` }}>
<TabsTrigger value="global">Global</TabsTrigger>
{cities.map(city => (
<TabsTrigger key={city.code} value={city.code}>{city.code}</TabsTrigger>
@@ -255,10 +257,11 @@ export default function Home() {
{/* Debug Button */}
<Button
asChild
className="fixed bottom-6 right-6 w-12 h-12 rounded-full bg-white hover:bg-gray-100 text-gray-500 border border-gray-200 shadow-sm transition-all duration-200 z-50"
variant="outline"
className="fixed bottom-6 right-6 size-12 rounded-full bg-card text-muted-foreground shadow-sm transition-colors duration-200 z-50"
>
<Link href="/debug" className="flex items-center justify-center">
<span className="text-lg">🔧</span>
<Link href="/debug" aria-label="Open debug tools" className="flex items-center justify-center">
<Wrench className="size-5" aria-hidden="true" />
</Link>
</Button>
</div>
+5 -5
View File
@@ -135,11 +135,11 @@ export function getAccumulatedScoreMessage(newScore, totalScore) {
// Get distance color for leaderboard display
export function getDistanceColor(distance) {
if (distance <= 50) return 'text-green-600';
if (distance <= 100) return 'text-blue-600';
if (distance <= 200) return 'text-yellow-600';
if (distance <= 500) return 'text-orange-600';
return 'text-red-600';
if (distance <= 50) return 'text-green-700 dark:text-green-300';
if (distance <= 100) return 'text-blue-700 dark:text-blue-300';
if (distance <= 200) return 'text-yellow-700 dark:text-yellow-300';
if (distance <= 500) return 'text-orange-700 dark:text-orange-300';
return 'text-red-700 dark:text-red-300';
}
// Get random location within city bbox