feat: stash

This commit is contained in:
tiennm99 committed 2025-08-16 08:30:26 +07:00
1 parent ad0984e0c4
commit 1a27e2d596
27 files changed
+2742 -116

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "acceptEdits"
}
}
+38
View File
@@ -0,0 +1,38 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Documentation
For detailed information about this project, refer to the documentation files in `/docs/`:
- **[Project Overview](/docs/project-overview.md)** - Project summary, key characteristics, and game mechanics
- **[Features](/docs/features.md)** - Detailed game features, scoring system, and security measures
- **[Tech Stack](/docs/tech-stack.md)** - Complete technology stack and dependencies
- **[Development Guidelines](/docs/development.md)** - Development commands, coding standards, and best practices
- **[Game Flow](/docs/game-flow.md)** - Complete gameplay flow from start to finish
- **[Project Structure](/docs/project-structure.md)** - Directory organization and file purposes
## Quick Reference
**Development Commands:**
- `npm run dev` - Start development server with Turbopack
- `npm run build` - Build the application for production
- `npm start` - Start production server
- `npm run lint` - Run ESLint
**Key Guidelines:**
- **JavaScript Only**: No TypeScript files (.ts, .tsx)
- **Function Parameters**: Use individual parameters, not object destructuring
- **File Modifications**: Only modify source code, `/docs`, and `/plans`
- **Security**: Never expose secrets or keys
- **Testing**: Inform user when complete - they handle manual testing
## Critical Instructions
When working on this codebase, always:
1. Read the relevant documentation files in `/docs/` for context
2. Follow the JavaScript-only approach with individual function parameters
3. Maintain server-side security for game sessions and coordinates
4. Use existing patterns and libraries already present in the codebase
5. Highlight any configuration file changes for manual processing
+65 -21
View File
@@ -1,36 +1,80 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
# VNGeoGuessr
## Getting Started
A GeoGuessr clone focused on Vietnamese locations with accurate boundary detection and anti-cheat security. Play geolocation guessing games across 5 Vietnamese locations using real street-level imagery.
First, run the development server:
## 🎮 Features
- **5 Vietnamese Locations**: Ha Noi, Da Nang, Ho Chi Minh, Da Lat, Duc Hoa (Long An)
- **360° Street View**: Mapillary panoramic images with PhotoSphere viewer
- **Anti-Cheat Security**: Server-side session management prevents cheating
- **Distance-Based Scoring**: 0-5 point system based on accuracy
- **Persistent Leaderboards**: Redis-powered top 200 player rankings
- **Accurate Boundaries**: Nominatim API + Turf.js for precise city detection
## 📚 Documentation
For detailed information about this project, see the documentation in `/docs/`:
- **[Project Overview](docs/project-overview.md)** - Project summary and key characteristics
- **[Features](docs/features.md)** - Complete feature list and game mechanics
- **[Tech Stack](docs/tech-stack.md)** - Technology stack and dependencies
- **[Development Guidelines](docs/development.md)** - Development setup and coding standards
- **[Game Flow](docs/game-flow.md)** - Complete gameplay flow documentation
- **[Project Structure](docs/project-structure.md)** - Codebase organization and architecture
## 🚀 Getting Started
### Prerequisites
- Node.js 18+
- npm or yarn
- Redis server (for leaderboards)
### Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
# Build for production
npm run build
# Start production server
npm start
# Run linting
npm run lint
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://localhost:3000](http://localhost:3000) to play the game.
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
## 🏗️ Built With
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- **Frontend**: Next.js 15 + React 19 + Tailwind CSS 4
- **Street View**: Mapillary API + PhotoSphere Viewer
- **Maps**: Leaflet + OpenStreetMap
- **Geographic Data**: Nominatim API + Turf.js
- **Storage**: Redis (leaderboards) + In-memory sessions
- **UI Components**: shadcn/ui
## Learn More
## 🎯 How to Play
To learn more about Next.js, take a look at the following resources:
1. Enter your username
2. Select a Vietnamese location
3. View the 360° street panorama
4. Place your guess on the map
5. Earn points based on accuracy (closer = more points)
6. Compete for the top 200 leaderboard spots!
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## 📋 Development Notes
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
- **JavaScript Only**: No TypeScript files
- **Individual Parameters**: Functions use separate parameters, not object destructuring
- **Server-Side Security**: Game coordinates never sent to client
- **Session Management**: Automatic cleanup after 30 minutes
## Deploy on Vercel
## 🤝 Contributing
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
This project follows specific development guidelines. Please read the [Development Guidelines](docs/development.md) before contributing.
+52
View File
@@ -0,0 +1,52 @@
# Development Guidelines
## Development Commands
- `npm run dev` - Start development server with Turbopack
- `npm run build` - Build the application for production
- `npm start` - Start production server
- `npm run lint` - Run ESLint
## Important Development Guidelines
### JavaScript Only
- This project uses **JavaScript exclusively**
- Never create or suggest TypeScript files (.ts, .tsx)
- All components and utilities should be .js or .jsx files
### Function Parameters
- All functions should use **individual parameters** instead of object destructuring
- Use `function(param1, param2)` instead of `function({param1, param2})`
- This applies to React components, utility functions, and API handlers
### File Modification Policy
- **Only modify source code files**, documentation (/docs), and plans (/plans)
- Configuration changes (package.json, next.config.mjs, eslint.config.mjs, components.json, etc.) should be highlighted for manual processing
- Environment files and build settings require manual review
### Security Best Practices
- Never expose or commit secret keys and sensitive information
- Server-side session management prevents client-side coordinate access
- All geographic calculations must be performed server-side
- Session cleanup after 30 minutes for security
### Testing & Completion
- After completing implementation tasks, inform the user that work is complete
- Ready for manual testing - do NOT attempt to run test commands
- Do NOT start development servers - user handles testing manually
## shadcn/ui Configuration
- **Style**: "new-york"
- **Path aliases**: Configured for `@/components`, `@/lib`, etc.
- **Components**: Use JavaScript (.js) not TypeScript
- **CSS variables**: Enabled for theming
- **Components location**: `src/components/ui/`
## Code Style Standards
- Follow existing code patterns in the codebase
- Use established libraries and utilities already present
- Maintain consistent naming conventions
- No comments unless explicitly requested
- Prefer editing existing files over creating new ones
+35
View File
@@ -0,0 +1,35 @@
# Game Features
## Location Coverage
- **5 Vietnamese Locations**: Ha Noi, Da Nang, Ho Chi Minh, Da Lat, Duc Hoa (Long An)
- **Accurate Location Generation**: Nominatim API + Turf.js for precise city boundary detection
## Street View System
- **Mapillary Integration**: Panoramic street-level images
- **3-Tier Search Logic**: Progressive radius fallback (1km → 2km → 5km)
- **360° Panorama Viewer**: PhotoSphere Viewer with zoom and fullscreen controls
## Interactive Maps
- **Leaflet Integration**: OpenStreetMap-based interactive mapping
- **Click-to-Place**: Intuitive guess marker placement
## Anti-Cheat Security
- **Server-side Session Management**: Target coordinates never sent to client
- **Session Isolation**: Unique session IDs with automatic cleanup
- **30-minute Expiry**: Automatic session cleanup for security
- **Server-side Calculations**: All distance and scoring computed server-side
## Scoring System
Distance-based points (0-5 scale):
- **0-50m**: 5 points
- **50m-100m**: 4 points
- **100m-200m**: 3 points
- **200m-500m**: 2 points
- **500m-1km**: 1 point
- **1km+**: 0 points
## Leaderboards
- **Redis-based Storage**: Persistent leaderboard data
- **Top 100 Players**: Maximum entry limit with auto-trimming
- **Highest Score Only**: Username + best score tracking
- **No Expiration**: Persistent storage across sessions
+70
View File
@@ -0,0 +1,70 @@
# Gameplay Flow
## Complete Game Flow
### 1. Username Setup
- Check localStorage for existing username
- Display UsernameModal if not set
- Store username for leaderboard tracking
### 2. Location Selection
- Choose from 5 Vietnamese locations:
- Ha Noi
- Da Nang
- Ho Chi Minh
- Da Lat
- Duc Hoa (Long An)
### 3. Session Creation
- Server generates unique session ID using UUID
- Server stores exact target location securely in session Map
- Client receives session ID but never the target coordinates
### 4. Location Display Process
- **Boundary Detection**: Server uses Nominatim API + Turf.js for accurate city boundary detection
- **Image Search**: Mapillary API with 3-tier fallback logic:
1. Search within 1km radius
2. Fallback to 2km radius if no results
3. Final fallback to 5km radius
- **Panorama Display**: PhotoSphereViewer renders 360° street view
- **Security**: Client never receives exact target coordinates
### 5. Guessing Phase
- **Panorama Interaction**: View 360° street-level image with PhotoSphereViewer controls
- **Map Interaction**: Place guess marker on interactive Leaflet map with OpenStreetMap
- **Submission**: Click submit button to finalize guess
### 6. Server-Side Processing
- **Input**: Client submits guess coordinates + session ID only
- **Retrieval**: Server retrieves exact location from secure session storage
- **Calculation**: Server calculates distance using Turf.js (prevents client-side cheating)
- **Scoring**: Server-side score calculation with distance-based points
- **Cleanup**: Session destroyed after submission for security
### 7. Scoring System
Distance-based points (0-5 scale):
- **0-50m**: 5 points
- **50m-100m**: 4 points
- **100m-200m**: 3 points
- **200m-500m**: 2 points
- **500m-1km**: 1 point
- **1km+**: 0 points
### 8. Results Display
- Show calculated distance between guess and actual location
- Display earned points based on accuracy
- Reveal exact target coordinates and location details
- Show current leaderboard rank (Vietnam-wide)
- Compare guess vs actual location on map
### 9. Leaderboard Management
- **Redis Storage**: Persistent storage with no expiration
- **Top 200 Limit**: Automatic trimming to maintain top performers only
- **Score Updates**: Only update if new score is higher than existing
- **Data Structure**: Username + highest score tracking
### 10. Continue or Exit
- Option to start next round with new session and location
- Option to return to city selection for different city
- Option to view full leaderboard
- Session cleanup ensures fresh start for each round
+19
View File
@@ -0,0 +1,19 @@
# Project Overview
VNGeoGuessr is a GeoGuessr clone focused on Vietnamese locations, supporting 6 major cities with accurate boundary detection and anti-cheat security. Built with Next.js 15.4.6, React 19, and Tailwind CSS 4.
## Key Characteristics
- **Location Coverage**: 5 Vietnamese locations - Ha Noi, Da Nang, Ho Chi Minh, Da Lat, Duc Hoa (Long An)
- **Accurate Location Generation**: Nominatim API + Turf.js for precise city boundary detection
- **Anti-Cheat Security**: Server-side session management, no client-side target coordinates
- **Persistent Storage**: Redis-based leaderboards with top 200 players
- **Interactive Gameplay**: Click-to-guess map interface
## Game Mechanics
- **Street View**: Mapillary panoramic images with 3-tier search fallback
- **360° Viewer**: PhotoSphere Viewer with zoom and fullscreen controls
- **Interactive Maps**: Leaflet + OpenStreetMap for guess placement
- **Distance-based Scoring**: 0-5 point scale based on accuracy
- **Session Isolation**: Server-side target coordinates for security
+81
View File
@@ -0,0 +1,81 @@
# Project Structure
## Root Directory
- `CLAUDE.md` - Project instructions and guidelines for Claude Code
- `components.json` - shadcn/ui configuration
- `package.json` - Dependencies and scripts
- `next.config.mjs` - Next.js configuration
- `eslint.config.mjs` - ESLint configuration
- `postcss.config.mjs` - PostCSS configuration
- `jsconfig.json` - JavaScript project configuration
## Source Code (`src/`)
### App Router (`src/app/`)
Next.js 15 App Router structure:
#### Pages
- `page.js` - Homepage with city selection
- `layout.js` - Root layout component
- `globals.css` - Global styles
- `favicon.ico` - Site favicon
#### Game Pages
- `game/page.js` - Main game interface
- `leaderboard/page.js` - Leaderboard display
- `debug/page.js` - Development debugging tools
#### API Routes (`src/app/api/`)
- `new-game/route.js` - Creates new game sessions
- `guess/route.js` - Processes guess submissions
- `leaderboard/route.js` - Leaderboard data management
- `debug/mapillary/route.js` - Mapillary API debugging
#### React Components (`src/app/components/`)
- `GameClient.js` - Main game client component
- `LeafletMap.js` - Interactive map for guess placement
- `PanoramaViewer.js` - 360° street view display
- `UsernameModal.js` - Username input modal
### Reusable Components (`src/components/`)
#### shadcn/ui Components (`src/components/ui/`)
- `alert.jsx` - Alert notifications
- `avatar.jsx` - User avatars
- `badge.jsx` - Badge components
- `button.jsx` - Button variants
- `card.jsx` - Card layouts
- `dialog.jsx` - Modal dialogs
- `form.jsx` - Form components
- `input.jsx` - Input fields
- `label.jsx` - Form labels
- `popover.jsx` - Popover components
- `progress.jsx` - Progress indicators
- `separator.jsx` - Visual separators
- `sheet.jsx` - Side sheets
- `skeleton.jsx` - Loading skeletons
- `table.jsx` - Data tables
- `tabs.jsx` - Tab navigation
- `tooltip.jsx` - Tooltips
### Utility Libraries (`src/lib/`)
- `utils.js` - Utility functions including `cn()` for class name merging
- `game.js` - Game logic and session management
- `leaderboard.js` - Leaderboard operations with Redis
- `mapillary.js` - Mapillary API integration
- `nominatim.js` - Geographic boundary detection
## Documentation (`/docs/`)
- `project-overview.md` - Project overview and key characteristics
- `features.md` - Detailed game features documentation
- `tech-stack.md` - Technology stack and dependencies
- `development.md` - Development guidelines and commands
- `game-flow.md` - Complete gameplay flow documentation
- `project-structure.md` - This file - project organization
## Planning (`/plans/`)
- Project planning documents and implementation plans
## Public Assets (`public/`)
- `*.svg` - SVG icons and graphics
- Static assets served directly by Next.js
+40
View File
@@ -0,0 +1,40 @@
# Tech Stack
## Frontend Framework
- **Next.js 15.4.6**: React-based full-stack framework with App Router
- **React 19**: Latest React version for component architecture
- **Tailwind CSS 4**: Utility-first CSS framework for styling
## Street View & Mapping
- **Mapillary API**: Panoramic street-level imagery provider
- **PhotoSphere Viewer**: 360° panorama viewer with zoom and fullscreen
- **Leaflet**: Interactive mapping library
- **OpenStreetMap**: Map tile provider for base maps
## Geographic Processing
- **Nominatim API**: OpenStreetMap geocoding service for city boundaries
- **Turf.js**: Spatial operations library for point-in-polygon detection and distance calculations
- **Geospatial Logic**: Server-side coordinate validation and boundary checking
## Data Storage & Session Management
- **Redis**: Persistent leaderboard storage with automatic trimming
- **In-Memory Map**: Server-side session storage with 30-minute expiry
- **Session Isolation**: Secure target coordinate storage per game session
## UI Components & Styling
- **shadcn/ui**: Component library with "new-york" style
- **Lucide React**: Icon library
- **class-variance-authority**: Component variant management
- **tailwind-merge + clsx**: Dynamic class name handling
- **CSS Variables**: Theme-based styling system
## Development & Analytics
- **ESLint**: Code linting with Next.js configuration
- **Turbopack**: Development server bundler
- **Vercel Analytics**: User analytics tracking
- **Vercel Speed Insights**: Performance monitoring
## Key Dependencies
- **UUID**: Unique session identifier generation
- **JavaScript Only**: No TypeScript - pure JavaScript implementation
- **Individual Parameters**: Functions use separate parameters instead of object destructuring
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server';
export async function POST(request) {
try {
const body = await request.json();
const { bbox } = body;
if (!bbox) {
return NextResponse.json({
success: false,
error: 'Missing bbox parameter'
}, { status: 400 });
}
// Get Mapillary access token
const MAPILLARY_ACCESS_TOKEN = process.env.MAPILLARY_ACCESS_TOKEN;
if (!MAPILLARY_ACCESS_TOKEN) {
return NextResponse.json({
success: false,
error: 'Mapillary access token not configured'
}, { status: 500 });
}
// Validate bbox format
const coords = bbox.split(',').map(coord => parseFloat(coord.trim()));
if (coords.length !== 4) {
return NextResponse.json({
success: false,
error: 'Invalid bbox format. Expected: minLng,minLat,maxLng,maxLat'
}, { status: 400 });
}
const [minLng, minLat, maxLng, maxLat] = coords;
// Validate coordinates
if (coords.some(coord => isNaN(coord))) {
return NextResponse.json({
success: false,
error: 'All bbox coordinates must be valid numbers'
}, { status: 400 });
}
console.log(`Testing Mapillary API with bbox: ${bbox}`);
// Build Mapillary API URL
const apiUrl = `https://graph.mapillary.com/images?access_token=${MAPILLARY_ACCESS_TOKEN}&fields=id,thumb_original_url,geometry,is_pano&limit=50&bbox=${bbox}`;
const response = await fetch(apiUrl, {
headers: { 'Accept': 'application/json' },
next: { revalidate: 0 }
});
if (!response.ok) {
if (response.status === 401) {
return NextResponse.json({
success: false,
error: 'Mapillary authentication failed - check access token'
}, { status: 401 });
}
return NextResponse.json({
success: false,
error: `Mapillary API error: ${response.status} ${response.statusText}`
}, { status: response.status });
}
const data = await response.json();
// Add some analysis
const analysis = {
totalImages: data.data ? data.data.length : 0,
panoramicImages: data.data ? data.data.filter(img => img.is_pano).length : 0,
regularImages: data.data ? data.data.filter(img => !img.is_pano).length : 0,
bboxArea: {
width: maxLng - minLng,
height: maxLat - minLat,
widthKm: ((maxLng - minLng) * 111).toFixed(2),
heightKm: ((maxLat - minLat) * 111).toFixed(2)
}
};
if (data.data && data.data.length > 0) {
return NextResponse.json({
success: true,
data: data.data,
analysis,
apiUrl: apiUrl.replace(MAPILLARY_ACCESS_TOKEN, '[TOKEN_HIDDEN]'), // Hide token in response
message: `Found ${data.data.length} images in the specified bbox`
});
} else {
return NextResponse.json({
success: false,
data: [],
analysis,
apiUrl: apiUrl.replace(MAPILLARY_ACCESS_TOKEN, '[TOKEN_HIDDEN]'),
error: 'No images found in the specified bbox'
});
}
} catch (error) {
console.error('Debug Mapillary API Error:', error);
return NextResponse.json({
success: false,
error: 'Internal server error: ' + error.message
}, { status: 500 });
}
}
export async function GET() {
return NextResponse.json({
success: false,
error: 'GET method not supported. Use POST with bbox parameter.'
}, { status: 405 });
}
+112
View File
@@ -0,0 +1,112 @@
import { NextResponse } from 'next/server';
import { submitScore } from '../../../lib/leaderboard.js';
import { getGameSession, deleteGameSession } from '../../../lib/session.js';
import { calculateDistance, calculateScore } from '../../../lib/game.js';
export async function POST(request) {
try {
const body = await request.json();
const { username, guessLat, guessLng, sessionId } = body;
// Validate required fields
if (!username || !sessionId ||
guessLat === undefined || guessLng === undefined) {
return NextResponse.json({
success: false,
error: 'Missing required fields: username, sessionId, guess coordinates'
}, { status: 400 });
}
// Get session data from Redis
const session = await getGameSession(sessionId);
if (!session) {
return NextResponse.json({
success: false,
error: 'Session not found or expired'
}, { status: 400 });
}
// Get target coordinates from session
const targetLat = session.exactLocation.lat;
const targetLng = session.exactLocation.lng;
// Validate coordinate ranges
const numGuessLat = Number(guessLat);
const numGuessLng = Number(guessLng);
const numTargetLat = Number(targetLat);
const numTargetLng = Number(targetLng);
// Basic coordinate validation
if (Math.abs(numGuessLat) > 90 || Math.abs(numTargetLat) > 90) {
return NextResponse.json({
success: false,
error: 'Invalid latitude values'
}, { status: 400 });
}
if (Math.abs(numGuessLng) > 180 || Math.abs(numTargetLng) > 180) {
return NextResponse.json({
success: false,
error: 'Invalid longitude values'
}, { status: 400 });
}
// Calculate distance between guess and target (server-side)
const distance = calculateDistance(
numGuessLat, numGuessLng,
numTargetLat, numTargetLng
);
// Calculate score based on distance (server-side)
const finalScore = calculateScore(distance);
// Submit to leaderboard with calculated score
const leaderboardResult = await submitScore(username.trim(), finalScore);
// Log the submission for anti-cheat monitoring
console.log('Game submission:', {
username: username.trim(),
distance: `${distance}m`,
score: finalScore,
coordinates: {
guess: [numGuessLat, numGuessLng],
target: [numTargetLat, numTargetLng]
},
timestamp: new Date().toISOString()
});
// Clean up session after successful submission
await deleteGameSession(sessionId);
return NextResponse.json({
success: true,
gameResult: {
distance,
score: finalScore,
rank: leaderboardResult.entry?.rank || null,
exactLocation: {
lat: numTargetLat,
lng: numTargetLng
}
},
leaderboard: leaderboardResult,
message: 'Game result processed successfully'
});
} catch (error) {
console.error('Submit Guess Error:', error);
return NextResponse.json({
success: false,
error: 'Failed to process game result',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}, { status: 500 });
}
}
export async function GET(request) {
return NextResponse.json({
success: false,
error: 'GET method not supported for game submissions'
}, { status: 405 });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { getLeaderboard } from '../../../lib/leaderboard.js';
export async function GET(request) {
try {
// Get top 100 leaderboard entries
const leaderboard = await getLeaderboard(100);
return NextResponse.json({
success: true,
leaderboard,
count: leaderboard.length
});
} catch (error) {
console.error('Leaderboard GET Error:', error);
return NextResponse.json({
success: false,
error: 'Failed to fetch leaderboard',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}, { status: 500 });
}
}
+136
View File
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { cityNames, cityBboxes } from '../../../lib/game.js';
import { fetchMapillaryImages } from '../../../lib/mapillary.js';
import { storeGameSession } from '../../../lib/session.js';
// Generate a unique session ID using uuid
function generateSessionId() {
return uuidv4();
}
export async function GET(request) {
const { searchParams } = new URL(request.url);
const cityCode = searchParams.get('city');
const sessionId = searchParams.get('sessionId');
if (!cityCode) {
return NextResponse.json({ success: false, error: 'Missing city parameter' });
}
const cityName = cityNames[cityCode];
if (!cityName) {
return NextResponse.json({
success: false,
error: `Unsupported city code: ${cityCode}`
}, { status: 400 });
}
try {
console.log('Getting images for city:', cityName);
// Get city bbox
const bbox = cityBboxes[cityCode];
if (!bbox) {
return NextResponse.json({
success: false,
error: `No bbox found for city: ${cityCode}`
}, { status: 400 });
}
// Fetch images from city bbox
const imageResult = await fetchMapillaryImages(bbox);
if (!imageResult.success) {
return NextResponse.json({
success: false,
error: `No street view images found in ${cityName}. This city may not have sufficient Mapillary coverage.`
});
}
// Select a random panoramic image
const panoImages = imageResult.data.filter(img => img.is_pano);
const selectedImage = panoImages.length > 0 ?
panoImages[Math.floor(Math.random() * panoImages.length)] :
imageResult.data[0];
const exactLocation = {
lat: selectedImage.geometry.coordinates[1],
lng: selectedImage.geometry.coordinates[0]
};
// Use the thumbnail URL for now - Mapillary requires special handling for full panoramas
const imageUrl = selectedImage.thumb_original_url;
// Create or update game session
const currentSessionId = sessionId || generateSessionId();
await storeGameSession(currentSessionId, {
sessionId: currentSessionId,
cityCode,
exactLocation,
imageId: selectedImage.id,
createdAt: Date.now()
});
console.log(`Session ${currentSessionId} created with exact location:`, exactLocation);
console.log(`Using image URL:`, imageUrl);
return NextResponse.json({
success: true,
sessionId: currentSessionId,
imageData: {
id: selectedImage.id,
url: imageUrl,
isPano: selectedImage.is_pano || false
}
});
} catch (error) {
console.error('Location/Mapillary API Error:', error);
// Provide specific error messages based on error type
let errorMessage = 'Failed to fetch street view images. Please try again.';
if (error.message.includes('City not found')) {
errorMessage = `City "${cityName}" not found in mapping database.`;
} else if (error.message.includes('No polygon data')) {
errorMessage = `No boundary data available for "${cityName}".`;
} else if (error.message.includes('Mapillary authentication failed')) {
errorMessage = 'Mapillary authentication failed. Please check API token.';
} else if (error.message.includes('fetch')) {
errorMessage = 'Network error. Please check your connection.';
}
return NextResponse.json({
success: false,
error: errorMessage,
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}, { status: 500 });
}
}
// Get session data (for debugging)
export async function POST(request) {
const body = await request.json();
const { sessionId } = body;
if (!sessionId) {
return NextResponse.json({ success: false, error: 'Missing sessionId' });
}
const session = gameSessions.get(sessionId);
if (!session) {
return NextResponse.json({ success: false, error: 'Session not found' });
}
return NextResponse.json({
success: true,
session: {
sessionId: session.sessionId,
cityCode: session.cityCode,
createdAt: session.createdAt
// Don't expose exact location for security
}
});
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { deleteGameSession } from '../../../lib/session.js';
export async function POST(request) {
try {
const body = await request.json();
const { sessionId } = body;
if (!sessionId) {
return NextResponse.json({
success: false,
error: 'Missing sessionId'
}, { status: 400 });
}
// Clean up the session
await deleteGameSession(sessionId);
return NextResponse.json({
success: true,
message: 'Session cleaned up'
});
} catch (error) {
console.error('Session cleanup error:', error);
return NextResponse.json({
success: false,
error: 'Failed to cleanup session'
}, { status: 500 });
}
}
+571
View File
@@ -0,0 +1,571 @@
"use client";
import { useEffect, useState, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import PanoramaViewer from './PanoramaViewer';
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">Loading map...</div>
});
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
import {
CITIES,
cityCenters,
cityNames,
formatDistance,
getUsername,
getResultMessage
} from '../../lib/game';
export default function GameClient() {
const searchParams = useSearchParams();
const router = useRouter();
const [location, setLocation] = useState('TPHCM');
const [imageData, setImageData] = useState(null);
const [sessionId, setSessionId] = useState(null);
const [loading, setLoading] = useState(true);
const [guessCoordinates, setGuessCoordinates] = useState(null);
const [showResult, setShowResult] = useState(false);
const [distance, setDistance] = useState(0);
const [score, setScore] = useState(0);
const [exactLocation, setExactLocation] = useState(null);
const [showDonate, setShowDonate] = useState(false);
const [username, setUsernameState] = useState('');
const [leaderboardRank, setLeaderboardRank] = useState(null);
const [mapCenter, setMapCenter] = useState([10.8231, 106.6297]); // Default to Ho Chi Minh
const resultMapRef = useRef(null);
const resultLeafletMapRef = useRef(null);
const loadLibrariesAndInitialize = useCallback(async (locationCode) => {
setLoading(true);
try {
// Set map center based on location
const center = cityCenters[locationCode];
if (center) {
setMapCenter(center);
}
await getRandomImage(locationCode);
} catch (error) {
console.error('Failed to initialize:', error);
setLoading(false);
}
}, []); // Remove sessionId dependency to prevent infinite loop
useEffect(() => {
const locationParam = searchParams.get('location') || 'TPHCM';
setLocation(locationParam);
// Get existing username (should be set from home page)
const existingUsername = getUsername();
setUsernameState(existingUsername || '');
// Initialize the game
loadLibrariesAndInitialize(locationParam);
}, [searchParams]); // Remove loadLibrariesAndInitialize from deps to prevent infinite loop
const submitGameResult = async (guessCoords) => {
if (!guessCoords || !sessionId) return null;
// Use username or default to 'Anonymous' if not set
const playerName = username || 'Anonymous';
try {
const response = await fetch('/api/guess', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: playerName,
guessLat: guessCoords[0],
guessLng: guessCoords[1],
sessionId
})
});
const data = await response.json();
if (data.success) {
console.log(`Game result processed. Distance: ${data.gameResult.distance}m, Score: ${data.gameResult.score}, Rank: ${data.gameResult.rank}`);
return data.gameResult;
} else {
console.error('Failed to submit game result:', data.error);
return null;
}
} catch (error) {
console.error('Failed to submit game result:', error);
return null;
}
};
const handlePanoramaReady = useCallback(() => {
setLoading(false);
console.log('Panorama viewer ready');
}, []);
const handlePanoramaError = useCallback((error) => {
console.error('Panorama error:', error);
setLoading(false);
}, []);
const handleMapClick = (coordinates) => {
setGuessCoordinates([coordinates.lat, coordinates.lng]);
console.log('Map clicked at:', coordinates);
};
const getRandomImage = async (locationCode) => {
try {
console.log('Fetching random image for city:', locationCode);
const url = sessionId ?
`/api/new-game?city=${locationCode}&sessionId=${sessionId}` :
`/api/new-game?city=${locationCode}`;
const response = await fetch(url);
const data = await response.json();
if (data.success) {
console.log('API response data:', data);
setSessionId(data.sessionId);
const newImageData = {
id: data.imageData.id,
url: data.imageData.url,
isPano: data.imageData.isPano
};
console.log('Setting imageData:', newImageData);
setImageData(newImageData);
// Temporarily set loading to false here to debug
console.log('Setting loading to false');
setLoading(false);
console.log(`Image loaded from city bbox`);
} else {
throw new Error(data.error || 'No images found');
}
} catch (error) {
console.error('Error fetching image after all retries:', error);
console.error('Error details:', error.message);
// Set imageData to null to trigger error display in PanoramaViewer
setImageData(null);
setSessionId(null);
setLoading(false);
// Only show error after all retries are complete
// The API already handles retries internally
alert(`Failed to load street view image after trying different search areas: ${error.message || 'No images found'}`);
}
};
const handleSubmitGuess = async () => {
if (!guessCoordinates || !imageData) return;
setLoading(true);
try {
// Submit to server for processing
const result = await submitGameResult(guessCoordinates);
if (result) {
// Use server-calculated values
setDistance(result.distance);
setScore(result.score);
setExactLocation(result.exactLocation);
setLeaderboardRank(result.rank);
} else {
// Fallback: no calculation possible without exact location
setDistance(99999);
setScore(0);
setExactLocation(null);
setLeaderboardRank(null);
}
} catch (error) {
console.error('Error submitting guess:', error);
// Fallback: no calculation possible
setDistance(99999);
setScore(0);
setExactLocation(null);
setLeaderboardRank(null);
}
setLoading(false);
setShowResult(true);
};
const handleNextRound = () => {
setShowResult(false);
setGuessCoordinates(null);
setLeaderboardRank(null);
setExactLocation(null);
setSessionId(null);
getRandomImage(location);
};
const handleSkipGuess = async () => {
if (!imageData) return;
setLoading(true);
try {
// Just clean up the current session and move to next image
if (sessionId) {
await fetch('/api/skip', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionId })
});
}
} catch (error) {
console.error('Error cleaning up session:', error);
}
// Reset state and load next image
setGuessCoordinates(null);
setLeaderboardRank(null);
setExactLocation(null);
setSessionId(null);
getRandomImage(location);
};
const handleGoBack = () => {
router.push('/');
};
// Create result map when dialog opens
useEffect(() => {
console.log('Result map effect triggered:', { showResult, exactLocation, guessCoordinates, hasRef: !!resultMapRef.current });
if (showResult && guessCoordinates) {
// Clean up existing map
if (resultLeafletMapRef.current) {
resultLeafletMapRef.current.remove();
resultLeafletMapRef.current = null;
}
// Use a more aggressive approach to wait for DOM
const initializeMap = async () => {
if (!resultMapRef.current) {
console.log('Map ref not available, retrying...');
setTimeout(initializeMap, 100);
return;
}
console.log('Map ref found, initializing map...');
try {
// Dynamically import Leaflet only on client side
const L = (await import('leaflet')).default;
await import('leaflet/dist/leaflet.css');
// Fix default marker icons issue (same as in LeafletMap component)
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
// Create map with explicit size settings
const map = L.map(resultMapRef.current, {
preferCanvas: true,
attributionControl: true
});
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
const markers = [];
// Guess marker (red circle) - always show this
const redIcon = L.divIcon({
className: 'custom-div-icon',
html: '<div style="background-color: #ef4444; width: 20px; height: 20px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
const guessMarker = L.marker([guessCoordinates[0], guessCoordinates[1]], {
icon: redIcon
}).addTo(map).bindPopup("Your Guess");
markers.push(guessMarker);
// True location marker (green circle) - only if exactLocation exists
if (exactLocation) {
const greenIcon = L.divIcon({
className: 'custom-div-icon',
html: '<div style="background-color: #22c55e; width: 20px; height: 20px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
const trueLocationMarker = L.marker([exactLocation.lat, exactLocation.lng], {
icon: greenIcon
}).addTo(map).bindPopup("Actual Location");
markers.push(trueLocationMarker);
// Draw line between markers
L.polyline([
[exactLocation.lat, exactLocation.lng],
[guessCoordinates[0], guessCoordinates[1]]
], { color: 'blue', weight: 3 }).addTo(map);
}
// Fit bounds to show all markers
if (markers.length > 1) {
const featureGroup = new L.featureGroup(markers);
map.fitBounds(featureGroup.getBounds(), {
padding: [20, 20],
maxZoom: 16
});
} else {
// Only guess marker, center on it
map.setView([guessCoordinates[0], guessCoordinates[1]], 13);
}
// Force map to recognize its size after a delay
setTimeout(() => {
map.invalidateSize();
}, 100);
// Additional invalidation after longer delay to ensure tiles load
setTimeout(() => {
map.invalidateSize();
}, 500);
resultLeafletMapRef.current = map;
} catch (error) {
console.error('Error creating result map:', error);
}
};
// Start trying to initialize the map
setTimeout(initializeMap, 300);
}
}, [showResult, exactLocation, guessCoordinates]);
// Clean up result map when dialog closes
useEffect(() => {
if (!showResult && resultLeafletMapRef.current) {
resultLeafletMapRef.current.remove();
resultLeafletMapRef.current = null;
}
}, [showResult]);
console.log('GameClient render - loading state:', loading);
if (loading) {
console.log('Showing main loading screen');
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-400 via-blue-500 to-purple-600">
<Card className="w-80">
<CardContent className="p-6">
<div className="text-center space-y-4">
<Skeleton className="h-12 w-12 rounded-full mx-auto" />
<Skeleton className="h-4 w-48 mx-auto" />
<p className="text-muted-foreground">Loading panoramic image...</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-green-400 via-blue-500 to-purple-600">
<div className="min-h-screen bg-black/20">
{/* Header */}
<header className="flex justify-between items-center p-4 bg-black/20">
<Button
onClick={handleGoBack}
variant="destructive"
className="flex items-center gap-2"
>
<span>←</span>
<span>Back</span>
</Button>
<div className="text-center">
<div className="text-white text-2xl font-bold">VNGEOGUESSR</div>
<Badge variant="secondary" className="text-lg bg-blue-500 text-white">
{cityNames[location] || location}
</Badge>
</div>
<Button
onClick={() => setShowDonate(true)}
className="flex items-center gap-2 bg-yellow-500 hover:bg-yellow-600 text-black"
>
<span>☕</span>
<span>Donate</span>
</Button>
</header>
{/* Game Content */}
<div className="p-4 grid lg:grid-cols-2 gap-4 h-[calc(100vh-100px)]">
{/* Panorama Viewer */}
<div className="bg-black rounded-lg overflow-hidden">
{imageData ? (
<PanoramaViewer
key={imageData.id}
imageUrl={imageData.url}
onReady={handlePanoramaReady}
onError={handlePanoramaError}
/>
) : (
<div className="w-full h-full min-h-[400px] flex items-center justify-center">
<div className="text-white">Loading panorama...</div>
</div>
)}
</div>
{/* Map and Submit */}
<div className="flex flex-col gap-4">
<div className="bg-white rounded-lg overflow-hidden flex-1">
<LeafletMap
center={mapCenter}
bbox={CITIES[location]?.bbox}
zoom={10}
onMapClick={handleMapClick}
className="w-full h-full min-h-[400px]"
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleSubmitGuess}
disabled={!guessCoordinates || loading}
className="flex-1 py-4 text-lg font-bold"
size="lg"
>
{loading ? 'PROCESSING...' : 'SUBMIT GUESS'}
</Button>
<Button
onClick={handleSkipGuess}
disabled={loading}
variant="outline"
className="py-4 px-6 text-lg font-bold"
size="lg"
>
SKIP
</Button>
</div>
</div>
</div>
{/* Result Modal */}
<Dialog open={showResult} onOpenChange={() => setShowResult(false)}>
<DialogContent className="sm:max-w-2xl" key={showResult ? 'open' : 'closed'}>
<DialogHeader>
<DialogTitle className="text-3xl text-center">RESULT</DialogTitle>
</DialogHeader>
<div className="text-center space-y-4">
<Badge variant="secondary" className="text-2xl font-bold text-blue-600 px-4 py-2">
{formatDistance(distance)} away
</Badge>
<div className="text-xl">
Score: <Badge className="text-lg font-bold bg-green-600">{score}/5 points</Badge>
</div>
{leaderboardRank && (
<Badge variant="outline" className="text-lg text-purple-600 font-semibold">
Vietnam Leaderboard Rank: #{leaderboardRank}
</Badge>
)}
<p className="text-sm text-muted-foreground">
Playing as: {username || 'Anonymous'} • City: {cityNames[location] || location}
</p>
</div>
<Card className="my-6">
<CardContent className="p-6">
<div className="text-center text-lg mb-4">📍 Actual Location vs Your Guess</div>
<div
ref={resultMapRef}
key={`map-${sessionId}`}
className="h-64 w-full bg-gray-100 rounded-lg overflow-hidden"
style={{ minHeight: '256px' }}
></div>
<div className="text-center text-muted-foreground space-y-2 mt-4">
{exactLocation && (
<p className="text-sm">
Actual: {exactLocation.lat.toFixed(4)}, {exactLocation.lng.toFixed(4)}
</p>
)}
{guessCoordinates && (
<p className="text-sm">
Guess: {guessCoordinates[0].toFixed(4)}, {guessCoordinates[1].toFixed(4)}
</p>
)}
</div>
</CardContent>
</Card>
<div className="text-center space-y-4">
<p className="text-lg">
{getResultMessage(score, distance)}
</p>
<div className="flex gap-4 justify-center">
<Button
onClick={handleNextRound}
size="lg"
className="px-8 py-3"
>
NEXT ROUND
</Button>
<Button
onClick={handleGoBack}
variant="secondary"
size="lg"
className="px-8 py-3"
>
MENU
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Donate Modal */}
<Dialog open={showDonate} onOpenChange={setShowDonate}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl text-center">DONATE HERE</DialogTitle>
</DialogHeader>
<div className="text-center space-y-6">
<Card className="w-64 h-64 mx-auto">
<CardContent className="h-full flex items-center justify-center">
<span className="text-muted-foreground">QR Code Placeholder</span>
</CardContent>
</Card>
<Button
onClick={() => setShowDonate(false)}
className="px-6"
>
CLOSE
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
export default function LeafletMap({
center,
zoom = 10,
bbox = null,
onMapClick,
onReady,
className = "w-full h-full min-h-[400px]"
}) {
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;
useEffect(() => {
const initMap = async () => {
if (!mapRef.current || leafletMapRef.current) return;
try {
// Fix default marker icons issue
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
// Create map
const map = L.map(mapRef.current);
// 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);
}
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Handle click events
map.on('click', (e) => {
if (onMapClickRef.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);
// Call callback
onMapClickRef.current({
lat: e.latlng.lat,
lng: e.latlng.lng
});
}
});
leafletMapRef.current = map;
if (onReadyRef.current) {
onReadyRef.current(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 = [];
}
};
}, [bbox, center, zoom]); // 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);
}
}
}, [center, zoom, bbox]);
return (
<div
ref={mapRef}
className={`bg-gray-200 rounded-lg overflow-hidden ${className}`}
/>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useEffect, useRef, memo } from 'react';
import { Viewer } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';
function PanoramaViewer({ imageUrl, onReady, onError }) {
const containerRef = useRef(null);
const viewerRef = useRef(null);
const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError);
// Update refs when callbacks change
onReadyRef.current = onReady;
onErrorRef.current = onError;
console.log('PanoramaViewer render - imageUrl:', imageUrl);
useEffect(() => {
console.log('PanoramaViewer useEffect triggered - imageUrl:', imageUrl);
console.log('Container ref:', containerRef.current);
let viewer = null;
const initViewer = async () => {
console.log('initViewer called');
if (!containerRef.current) {
console.error('Container ref is null');
return;
}
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>Loading panoramic image...</div>
</div>
`;
return;
}
console.log('Initializing PhotoSphere Viewer with URL:', imageUrl);
console.log('Container element:', containerRef.current);
try {
// Clean up existing viewer
if (viewerRef.current) {
viewerRef.current.destroy();
}
// Set up container with id for PhotoSphere Viewer
containerRef.current.innerHTML = '<div id="pano" style="width:100%; height:100%;"></div>';
console.log('Creating PhotoSphere Viewer...');
// Create new viewer with your specified config
viewer = new Viewer({
container: document.getElementById("pano"),
panorama: imageUrl,
loadingImg: null,
defaultYaw: 0,
defaultZoomLvl: -60,
navbar: ["zoom", "fullscreen"],
mousewheel: true,
touchmoveTwoFingers: true
});
console.log('PhotoSphere Viewer created successfully');
viewer.addEventListener('ready', () => {
console.log("PhotoSphere Viewer loaded successfully");
if (onReadyRef.current) onReadyRef.current();
});
viewer.addEventListener('panorama-loaded', () => {
console.log("Panorama image loaded");
if (onReadyRef.current) onReadyRef.current();
});
viewer.addEventListener('panorama-error', (error) => {
console.error("Error loading panorama:", error);
// Fallback to regular image on panorama error
if (containerRef.current) {
containerRef.current.innerHTML = `
<img
src="${imageUrl}"
style="width:100%; height:100%; object-fit:cover; border-radius:10px;"
alt="Street view"
onload="console.log('Fallback image loaded successfully')"
onerror="console.error('Both panorama and fallback image failed to load')"
/>
`;
if (onReadyRef.current) onReadyRef.current();
}
});
// Also handle general errors
viewer.addEventListener('error', (error) => {
console.error("PhotoSphere Viewer error:", error);
if (onErrorRef.current) onErrorRef.current(error);
});
viewerRef.current = viewer;
} catch (error) {
console.error('Error initializing PhotoSphere Viewer:', error);
// Fallback to regular image
if (containerRef.current) {
containerRef.current.innerHTML = `
<img
src="${imageUrl}"
style="width:100%; height:100%; object-fit:cover; border-radius:10px;"
alt="Street view"
/>
`;
if (onReadyRef.current) onReadyRef.current();
}
if (onErrorRef.current) onErrorRef.current(error);
}
};
initViewer();
// Cleanup function
return () => {
if (viewerRef.current) {
try {
viewerRef.current.destroy();
} catch (error) {
console.warn('Error destroying viewer:', error);
}
viewerRef.current = null;
}
};
}, [imageUrl]); // Remove onReady and onError from deps to prevent constant reinitialization
return (
<div
ref={containerRef}
className="w-full h-full min-h-[400px] bg-gray-900 rounded-lg overflow-hidden"
/>
);
}
export default memo(PanoramaViewer);
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
export default function UsernameModal({ isOpen, onSubmit, onClose }) {
const [username, setUsername] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
const trimmedUsername = username.trim();
if (!trimmedUsername) {
setError('Please enter a username');
return;
}
if (trimmedUsername.length < 2) {
setError('Username must be at least 2 characters');
return;
}
if (trimmedUsername.length > 20) {
setError('Username must be less than 20 characters');
return;
}
// Basic validation - only letters, numbers, and common symbols
const validUsername = /^[a-zA-Z0-9_-]+$/.test(trimmedUsername);
if (!validUsername) {
setError('Username can only contain letters, numbers, hyphens, and underscores');
return;
}
onSubmit(trimmedUsername);
setError('');
setUsername('');
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Welcome to VNGeoGuessr!</DialogTitle>
<DialogDescription>
Enter your username to join the leaderboard
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
maxLength={20}
autoFocus
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
className="flex-1"
>
Skip
</Button>
<Button
type="submit"
className="flex-1"
>
Start Playing
</Button>
</div>
</form>
<p className="text-xs text-muted-foreground text-center">
Your username will be displayed on the leaderboard
</p>
</DialogContent>
</Dialog>
);
}
+314
View File
@@ -0,0 +1,314 @@
"use client";
import { useState, useCallback, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import dynamic from 'next/dynamic';
// Dynamic import for client-side only components
const LeafletMap = dynamic(() => import('../components/LeafletMap'), {
ssr: false,
loading: () => <div className="w-full h-full bg-gray-300 animate-pulse rounded"></div>
});
export default function DebugPage() {
const [bbox, setBbox] = useState('105.8,21.0,105.9,21.1'); // Default: Hanoi area
const [parsedBbox, setParsedBbox] = useState(null);
const [mapCenter, setMapCenter] = useState([21.05, 105.85]);
const [mapZoom, setMapZoom] = useState(12);
const [error, setError] = useState('');
const [mapillaryResults, setMapillaryResults] = useState(null);
const [loading, setLoading] = useState(false);
// Parse bbox string to coordinates
const parseBbox = (bboxString) => {
const coords = bboxString.split(',').map(coord => parseFloat(coord.trim()));
if (coords.length !== 4) {
throw new Error('Bbox must have exactly 4 coordinates: minLng,minLat,maxLng,maxLat');
}
const [minLng, minLat, maxLng, maxLat] = coords;
// Validate coordinates
if (isNaN(minLng) || isNaN(minLat) || isNaN(maxLng) || isNaN(maxLat)) {
throw new Error('All coordinates must be valid numbers');
}
if (minLng >= maxLng || minLat >= maxLat) {
throw new Error('Invalid bbox: min values must be less than max values');
}
if (Math.abs(minLat) > 90 || Math.abs(maxLat) > 90) {
throw new Error('Latitude must be between -90 and 90');
}
if (Math.abs(minLng) > 180 || Math.abs(maxLng) > 180) {
throw new Error('Longitude must be between -180 and 180');
}
return { minLng, minLat, maxLng, maxLat };
};
const handleVisualizeBbox = () => {
try {
setError('');
const parsed = parseBbox(bbox);
setParsedBbox(parsed);
// Set map center to bbox center
const centerLat = (parsed.minLat + parsed.maxLat) / 2;
const centerLng = (parsed.minLng + parsed.maxLng) / 2;
setMapCenter([centerLat, centerLng]);
// Adjust zoom based on bbox size
const latRange = parsed.maxLat - parsed.minLat;
const lngRange = parsed.maxLng - parsed.minLng;
const maxRange = Math.max(latRange, lngRange);
let zoom = 10;
if (maxRange < 0.01) zoom = 16;
else if (maxRange < 0.02) zoom = 15;
else if (maxRange < 0.05) zoom = 14;
else if (maxRange < 0.1) zoom = 13;
else if (maxRange < 0.2) zoom = 12;
else if (maxRange < 0.5) zoom = 11;
setMapZoom(zoom);
} catch (err) {
setError(err.message);
setParsedBbox(null);
}
};
const handleTestMapillary = async () => {
if (!parsedBbox) {
setError('Please visualize the bbox first');
return;
}
setLoading(true);
setMapillaryResults(null);
try {
const response = await fetch('/api/debug/mapillary', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ bbox })
});
const data = await response.json();
setMapillaryResults(data);
} catch (err) {
setError('Failed to test Mapillary API: ' + err.message);
} finally {
setLoading(false);
}
};
const handleMapReady = useCallback(async (map) => {
if (parsedBbox && map && typeof window !== 'undefined') {
// Clear existing layers
map.eachLayer((layer) => {
if (layer.options && layer.options.debugLayer) {
map.removeLayer(layer);
}
});
try {
// Dynamic import of Leaflet for client-side only
const L = (await import('leaflet')).default;
// Add bbox rectangle
const rectangle = L.rectangle(
[[parsedBbox.minLat, parsedBbox.minLng], [parsedBbox.maxLat, parsedBbox.maxLng]],
{
color: '#ff0000',
weight: 2,
fillOpacity: 0.1,
debugLayer: true
}
).addTo(map);
// Add center point
const centerLat = (parsedBbox.minLat + parsedBbox.maxLat) / 2;
const centerLng = (parsedBbox.minLng + parsedBbox.maxLng) / 2;
L.marker([centerLat, centerLng], { debugLayer: true })
.addTo(map)
.bindPopup(`Center: ${centerLat.toFixed(6)}, ${centerLng.toFixed(6)}`);
// Fit map to bbox
map.fitBounds([[parsedBbox.minLat, parsedBbox.minLng], [parsedBbox.maxLat, parsedBbox.maxLng]]);
} catch (error) {
console.error('Error adding bbox visualization:', error);
}
}
}, [parsedBbox]);
return (
<div className="min-h-screen bg-gradient-to-br from-green-400 via-blue-500 to-purple-600">
<div className="min-h-screen bg-black/20 backdrop-blur-sm">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<header className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Debug Tool</h1>
<p className="text-white/80">Bbox Visualization & Mapillary Testing</p>
</div>
<Button asChild>
<Link href="/">Back to Home</Link>
</Button>
</header>
<div className="grid lg:grid-cols-2 gap-8">
{/* Controls Panel */}
<Card className="bg-white/10 backdrop-blur-md border-white/20">
<CardHeader>
<CardTitle className="text-white">Bbox Input</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="bbox" className="text-white">
Bounding Box (minLng,minLat,maxLng,maxLat)
</Label>
<Input
id="bbox"
value={bbox}
onChange={(e) => setBbox(e.target.value)}
placeholder="105.8,21.0,105.9,21.1"
className="mt-1"
/>
<p className="text-xs text-white/60 mt-1">
Format: longitude1,latitude1,longitude2,latitude2
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button onClick={handleVisualizeBbox} className="flex-1">
Visualize Bbox
</Button>
<Button
onClick={handleTestMapillary}
disabled={!parsedBbox || loading}
variant="secondary"
className="flex-1"
>
{loading ? 'Testing...' : 'Test Mapillary'}
</Button>
</div>
{parsedBbox && (
<Card className="bg-black/20">
<CardContent className="p-4">
<h4 className="text-white font-semibold mb-2">Parsed Bbox:</h4>
<div className="text-sm text-white/80 space-y-1">
<div>Min: {parsedBbox.minLng.toFixed(6)}, {parsedBbox.minLat.toFixed(6)}</div>
<div>Max: {parsedBbox.maxLng.toFixed(6)}, {parsedBbox.maxLat.toFixed(6)}</div>
<div>Size: {((parsedBbox.maxLng - parsedBbox.minLng) * 111).toFixed(2)}km × {((parsedBbox.maxLat - parsedBbox.minLat) * 111).toFixed(2)}km</div>
</div>
</CardContent>
</Card>
)}
{mapillaryResults && (
<Card className="bg-black/20">
<CardContent className="p-4">
<h4 className="text-white font-semibold mb-2">Mapillary Results:</h4>
{mapillaryResults.success ? (
<div className="space-y-2">
<Badge className="bg-green-600">
Found {mapillaryResults.data.length} images
</Badge>
<div className="text-sm text-white/80 space-y-1">
<div>Panoramic images: {mapillaryResults.data.filter(img => img.is_pano).length}</div>
<div>Regular images: {mapillaryResults.data.filter(img => !img.is_pano).length}</div>
{mapillaryResults.analysis && (
<div className="pt-1 border-t border-white/20">
<div>Area: {mapillaryResults.analysis.bboxArea.widthKm}km × {mapillaryResults.analysis.bboxArea.heightKm}km</div>
<div>Density: {(mapillaryResults.data.length / (parseFloat(mapillaryResults.analysis.bboxArea.widthKm) * parseFloat(mapillaryResults.analysis.bboxArea.heightKm))).toFixed(1)} images/km²</div>
</div>
)}
</div>
</div>
) : (
<div className="space-y-2">
<Badge variant="destructive">
Error: {mapillaryResults.error}
</Badge>
</div>
)}
</CardContent>
</Card>
)}
</CardContent>
</Card>
{/* Map Panel */}
<Card className="bg-white/10 backdrop-blur-md border-white/20">
<CardHeader>
<CardTitle className="text-white">Map Visualization</CardTitle>
</CardHeader>
<CardContent>
<div className="h-96 bg-gray-200 rounded-lg overflow-hidden">
<LeafletMap
center={mapCenter}
zoom={mapZoom}
onReady={handleMapReady}
className="w-full h-full"
/>
</div>
<p className="text-xs text-white/60 mt-2">
Red rectangle shows the bbox area. Center marker shows the bbox center point.
</p>
</CardContent>
</Card>
</div>
{/* Sample Bboxes */}
<Card className="mt-8 bg-white/10 backdrop-blur-md border-white/20">
<CardHeader>
<CardTitle className="text-white">Sample Bboxes</CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-2">
{[
{ name: 'Hanoi Center', bbox: '105.8,21.0,105.9,21.1' },
{ name: 'HCMC Center', bbox: '106.6,10.7,106.8,10.9' },
{ name: 'Da Nang', bbox: '108.1,16.0,108.3,16.1' },
{ name: 'Small Area (1km)', bbox: '105.85,21.03,105.86,21.04' },
{ name: 'Large Area (10km)', bbox: '105.5,20.8,106.2,21.3' },
{ name: 'Custom', bbox: bbox }
].map((sample) => (
<Button
key={sample.name}
variant="outline"
size="sm"
onClick={() => setBbox(sample.bbox)}
className="text-xs"
>
{sample.name}
</Button>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import GameClient from '../components/GameClient';
export default function GamePage() {
return <GameClient />;
}
+16
View File
@@ -1,6 +1,12 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Import Leaflet CSS */
@import "leaflet/dist/leaflet.css";
/* Import PhotoSphere Viewer CSS */
@import "@photo-sphere-viewer/core/index.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
@@ -120,3 +126,13 @@
@apply bg-background text-foreground;
}
}
/* Ensure dialogs appear above Leaflet maps */
[role="dialog"] {
z-index: 9999 !important;
}
/* Ensure dialog overlay appears above Leaflet maps */
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}
+280 -95
View File
@@ -1,103 +1,288 @@
import Image from "next/image";
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import UsernameModal from './components/UsernameModal';
import { getUsername, setUsername, cities } from '../lib/game';
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.js
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
const [showDonateModal, setShowDonateModal] = useState(false);
const [showUsernameModal, setShowUsernameModal] = useState(false);
const [showLeaderboardModal, setShowLeaderboardModal] = useState(false);
const [username, setUsernameState] = useState('');
const [leaderboard, setLeaderboard] = useState([]);
const [loadingLeaderboard, setLoadingLeaderboard] = useState(false);
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
useEffect(() => {
// Check for existing username on page load
const existingUsername = getUsername();
if (existingUsername) {
setUsernameState(existingUsername);
} else {
setShowUsernameModal(true);
}
}, []);
const handleUsernameSubmit = (newUsername) => {
setUsername(newUsername);
setUsernameState(newUsername);
setShowUsernameModal(false);
};
const handleUsernameSkip = () => {
setShowUsernameModal(false);
};
const fetchLeaderboard = async () => {
setLoadingLeaderboard(true);
try {
const response = await fetch('/api/leaderboard');
const data = await response.json();
if (data.success) {
setLeaderboard(data.leaderboard);
} else {
console.error('Failed to fetch leaderboard:', data.error);
}
} catch (error) {
console.error('Error fetching leaderboard:', error);
} finally {
setLoadingLeaderboard(false);
}
};
const handleLeaderboardClick = () => {
setShowLeaderboardModal(true);
fetchLeaderboard();
};
const getRankIcon = (rank) => {
switch (rank) {
case 1: return '🥇';
case 2: return '🥈';
case 3: return '🥉';
default: return `#${rank}`;
}
};
const getScoreColor = (score) => {
if (score === 5) return 'text-green-600';
if (score >= 4) return 'text-blue-600';
if (score >= 3) return 'text-yellow-600';
if (score >= 2) return 'text-orange-600';
return 'text-red-600';
};
const isCurrentUser = (leaderboardUsername) => {
return username && leaderboardUsername === username;
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-400 via-blue-500 to-purple-600">
<div className="min-h-screen bg-black/20 backdrop-blur-sm">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<header className="flex justify-between items-center mb-8">
<div className="flex items-center">
<Button asChild className="bg-red-600 hover:bg-red-700 transition-all duration-200 transform hover:scale-105">
<Link href="/" className="flex items-center gap-2">
<span>🏠</span>
<span className="font-semibold">VNGEOGUESSR</span>
</Link>
</Button>
</div>
<div className="flex items-center gap-4">
<Button
onClick={handleLeaderboardClick}
className="bg-purple-600 hover:bg-purple-700 transition-all duration-200 transform hover:scale-105 flex items-center gap-2"
>
<span>🏆</span>
<span className="font-semibold">LEADERBOARD</span>
</Button>
<Button
onClick={() => setShowDonateModal(true)}
className="flex items-center gap-2 bg-yellow-500 hover:bg-yellow-600 text-black transition-all duration-200 transform hover:scale-105"
>
<span>☕</span>
<span className="font-semibold">BUY ME COFFEE</span>
</Button>
</div>
</header>
{/* Main Content */}
<div className="text-center mb-12">
<h2 className="text-4xl font-bold text-white mb-4">WELCOME TO VNGEOGUESSR</h2>
</div>
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
{/* Left Panel - Instructions */}
<Card className="bg-white/10 backdrop-blur-md border-white/20">
<CardHeader>
<CardTitle className="text-2xl font-bold text-white">HOW TO PLAY</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 text-white">
<p className="text-lg">1. Choose your city</p>
<p className="text-lg">2. View the street-level panoramic image</p>
<p className="text-lg">3. Guess the location on the map as accurately as possible</p>
<p className="text-lg">4. Earn points based on your accuracy!</p>
</div>
</CardContent>
</Card>
{/* Right Panel - City Selection */}
<Card className="bg-white/10 backdrop-blur-md border-white/20">
<CardHeader>
<CardTitle className="text-2xl font-bold text-white text-center">SELECT CITY</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{cities.map((city) => (
<Button
key={city.code}
asChild
className="w-full font-bold py-3 px-6 transition-all duration-200 transform hover:scale-105"
size="lg"
>
<Link href={`/game?location=${city.code}`}>
{city.name}
</Link>
</Button>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
{/* Donate Modal */}
<Dialog open={showDonateModal} onOpenChange={setShowDonateModal}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl text-center">DONATE HERE</DialogTitle>
</DialogHeader>
<div className="text-center space-y-6">
<Card className="w-64 h-64 mx-auto">
<CardContent className="h-full flex items-center justify-center">
<span className="text-muted-foreground">QR Code Placeholder</span>
</CardContent>
</Card>
<Button
onClick={() => setShowDonateModal(false)}
className="px-6"
>
CLOSE
</Button>
</div>
</DialogContent>
</Dialog>
{/* Username Modal */}
<UsernameModal
isOpen={showUsernameModal}
onSubmit={handleUsernameSubmit}
onClose={handleUsernameSkip}
/>
{/* Leaderboard Modal */}
<Dialog open={showLeaderboardModal} onOpenChange={setShowLeaderboardModal}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-3xl text-center">🏆 Vietnam Leaderboard</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{loadingLeaderboard ? (
<div className="space-y-2">
{[...Array(10)].map((_, i) => (
<div key={i} className="flex items-center justify-between p-4 rounded-lg bg-gray-100">
<div className="flex items-center gap-4">
<Skeleton className="h-8 w-12" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
) : leaderboard.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">No scores yet!</p>
<p className="text-gray-500 mt-2">Be the first to play and make it to the leaderboard!</p>
</div>
) : (
<div className="space-y-2">
{leaderboard.map((entry, index) => (
<div
key={entry.username}
className={`flex items-center justify-between p-4 rounded-lg transition-all ${isCurrentUser(entry.username)
? 'bg-yellow-100 border-2 border-yellow-400 shadow-lg'
: entry.rank <= 3
? 'bg-blue-50 border border-blue-200'
: 'bg-gray-50'
}`}
>
<div className="flex items-center gap-4">
<div className="text-xl font-bold w-12 text-center">
{getRankIcon(entry.rank)}
</div>
<div>
<div className={`font-semibold text-lg ${isCurrentUser(entry.username) ? 'text-yellow-800' : 'text-gray-800'
}`}>
{entry.username}
{isCurrentUser(entry.username) && (
<Badge className="ml-2 bg-yellow-500 text-black">YOU</Badge>
)}
</div>
</div>
</div>
<div>
<Badge variant="secondary" className={`text-xl font-bold ${getScoreColor(entry.score)}`}>
{entry.score}/5
</Badge>
</div>
</div>
))}
</div>
)}
{/* Scoring Legend */}
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-center">Scoring System</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
<div>0-50m = 5 points ⭐⭐⭐⭐⭐</div>
<div>50-100m = 4 points ⭐⭐⭐⭐</div>
<div>100-200m = 3 points ⭐⭐⭐</div>
<div>200-500m = 2 points ⭐⭐</div>
<div>500m-1km = 1 point ⭐</div>
<div>1km+ = 0 points</div>
</div>
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
{/* Floating Debug Button */}
<Button
asChild
className="fixed bottom-6 right-6 w-14 h-14 rounded-full bg-gray-600 hover:bg-gray-700 shadow-lg transition-all duration-200 transform hover:scale-110 z-50"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
<Link href="/debug" className="flex items-center justify-center">
<span className="text-xl">🔧</span>
</Link>
</Button>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import * as turf from '@turf/turf';
// Cities enum-like structure
export const CITIES = {
HN: {
code: 'HN',
name: 'Ha Noi',
center: [21.0285, 105.8542],
bbox: [105.77, 20.96, 105.88, 21.05]
},
DN: {
code: 'DN',
name: 'Da Nang',
center: [16.0544, 108.2022],
bbox: [108.17, 16, 108.25, 16.1]
},
TPHCM: {
code: 'TPHCM',
name: 'Ho Chi Minh',
center: [10.8231, 106.6297],
bbox: [106.62, 10.71, 106.75, 10.83]
},
DL: {
code: 'DL',
name: 'Da Lat',
center: [11.9404, 108.4583],
bbox: [108.38, 11.89, 108.50, 12.00]
},
DH: {
code: 'DH',
name: 'Duc Hoa (Long An)',
center: [10.8888, 106.3825],
bbox: [106.35, 10.85, 106.45, 10.95]
}
};
// Helper functions for backward compatibility
export const cityCenters = Object.fromEntries(
Object.values(CITIES).map(city => [city.code, city.center])
);
export const cityNames = Object.fromEntries(
Object.values(CITIES).map(city => [city.code, city.name])
);
export const cityBboxes = Object.fromEntries(
Object.values(CITIES).map(city => [city.code, city.bbox])
);
// Cities list for UI components
export const cities = Object.values(CITIES).map(city => ({
code: city.code,
name: city.name.toUpperCase()
}));
// Calculate distance between two coordinates in meters using Turf
export function calculateDistance(lat1, lon1, lat2, lon2) {
if (lat1 === lat2 && lon1 === lon2) return 0;
try {
const from = turf.point([lon1, lat1]);
const to = turf.point([lon2, lat2]);
const distanceKm = turf.distance(from, to, { units: 'kilometers' });
// Convert to meters and round
return Math.round(distanceKm * 1000);
} catch (error) {
console.error('Error calculating distance with Turf:', error);
throw new Error(`Failed to calculate distance: ${error.message}`);
}
}
// Calculate score based on distance (0-5 points scale)
export function calculateScore(distance) {
if (distance <= 50) return 5;
if (distance <= 100) return 4;
if (distance <= 200) return 3;
if (distance <= 500) return 2;
if (distance <= 1000) return 1;
return 0;
}
// Format distance for display
export function formatDistance(distance) {
if (distance < 1000) {
return `${distance}m`;
} else {
return `${(distance / 1000).toFixed(2)}km`;
}
}
// Get or set username from localStorage
export function getUsername() {
if (typeof window === 'undefined') return null;
return localStorage.getItem('vngeoguessr_username');
}
export function setUsername(username) {
if (typeof window === 'undefined') return;
localStorage.setItem('vngeoguessr_username', username);
}
// Get result message based on score
export function getResultMessage(score, distance) {
if (score > 4) return "Excellent! Outstanding guess!";
if (score > 2) return "Good job! Nice work!";
if (score > 0) return "Not bad! Keep trying!";
return "Nice try! Better luck next time!";
}
// Get random location within city bbox
export function getRandomCityLocationFromBbox(cityCode) {
const bbox = cityBboxes[cityCode];
if (!bbox) {
throw new Error(`No bbox found for city code: ${cityCode}`);
}
const [minLng, minLat, maxLng, maxLat] = bbox;
const lng = Math.random() * (maxLng - minLng) + minLng;
const lat = Math.random() * (maxLat - minLat) + minLat;
return { lat, lng };
}
+96
View File
@@ -0,0 +1,96 @@
import { getRedis } from './redis.js';
// Global Vietnam leaderboard constants
const LEADERBOARD_KEY = 'leaderboard:vietnam';
const MAX_LEADERBOARD_SIZE = 200;
/**
* Get the global Vietnam leaderboard
* @param {number} limit - Number of entries to return (default: 100)
* @returns {Promise<Array>} Array of leaderboard entries
*/
export async function getLeaderboard(limit = 100) {
try {
const redis = await getRedis();
// Get top entries from the sorted set (highest scores first)
const leaderboardData = await redis.zRangeWithScores(LEADERBOARD_KEY, 0, limit - 1, {
REV: true // Reverse order to get highest scores first
});
const entries = [];
// Process each entry (leaderboardData is array of {value, score} objects)
for (let i = 0; i < leaderboardData.length; i++) {
const entry = leaderboardData[i];
entries.push({
username: entry.value,
score: Number(entry.score),
rank: i + 1 // Calculate rank based on position
});
}
return entries;
} catch (error) {
console.error('Error fetching leaderboard:', error);
throw error; // Pass through the original error for better debugging
}
}
/**
* Submit a score to the global Vietnam leaderboard
* @param {string} username - Player username
* @param {number} score - Score achieved (0-5)
* @returns {Promise<Object>} Submission result with rank
*/
export async function submitScore(username, score) {
try {
const redis = await getRedis();
// Validate input
if (!username || score === undefined) {
throw new Error('Missing required fields: username, score');
}
const trimmedUsername = username.trim();
const numScore = Number(score);
// Check if user already exists in leaderboard
const existingScore = await redis.zScore(LEADERBOARD_KEY, trimmedUsername);
// Only update if new score is higher
if (existingScore === null || numScore > existingScore) {
// Add or update user's score in the leaderboard
await redis.zAdd(LEADERBOARD_KEY, {
score: numScore,
value: trimmedUsername
});
// Keep only top 200 entries to manage storage
await redis.zRemRangeByRank(LEADERBOARD_KEY, 0, -(MAX_LEADERBOARD_SIZE + 1));
}
// Calculate current rank
const rank = await redis.zRevRank(LEADERBOARD_KEY, trimmedUsername);
const actualRank = rank !== null ? rank + 1 : null;
const finalScore = await redis.zScore(LEADERBOARD_KEY, trimmedUsername);
return {
success: true,
entry: {
username: trimmedUsername,
score: Number(finalScore),
rank: actualRank
},
message: numScore > (existingScore || 0) ? 'New high score!' : 'Score submitted'
};
} catch (error) {
console.error('Error submitting score:', error);
throw new Error(error.message || 'Failed to submit score');
}
}
+49
View File
@@ -0,0 +1,49 @@
// Mapillary API utilities
// Fetch images using city bbox
export async function fetchMapillaryImages(bbox) {
const accessToken = process.env.MAPILLARY_ACCESS_TOKEN;
if (!accessToken) {
throw new Error('MAPILLARY_ACCESS_TOKEN environment variable is not set');
}
const bboxString = bbox.join(',');
console.log(`Searching for images within bbox: ${bboxString}`);
const apiUrl = `https://graph.mapillary.com/images?access_token=${accessToken}&fields=id,thumb_original_url,geometry,is_pano&limit=3&bbox=${bboxString}&is_pano=true`;
try {
const response = await fetch(apiUrl, {
headers: { 'Accept': 'application/json' },
next: { revalidate: 0 }
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Mapillary authentication failed');
}
throw new Error(`Mapillary API error: ${response.status}`);
}
const data = await response.json();
if (data.data && data.data.length > 0) {
console.log(`Found ${data.data.length} images within city bbox`);
return {
success: true,
data: data.data
};
}
return {
success: false,
error: 'No street view images found in city bbox'
};
} catch (error) {
console.error('Error fetching Mapillary images:', error);
throw error;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from 'redis';
// Global redis connection - created once and reused
let redis = null;
/**
* Get the global Redis connection
* Creates connection on first call, then reuses it
* @returns {Promise<RedisClient>} Redis client instance
*/
export async function getRedis() {
if (!redis) {
redis = await createClient({ url: process.env.REDIS_URL }).connect();
}
return redis;
}
+64
View File
@@ -0,0 +1,64 @@
import { getRedis } from './redis.js';
// Game session constants
const SESSION_KEY_PREFIX = 'session:';
const SESSION_EXPIRY = 30 * 60; // 30 minutes in seconds
/**
* Store a game session in Redis
* @param {string} sessionId - Unique session identifier
* @param {Object} sessionData - Session data to store
* @returns {Promise<boolean>} Success status
*/
export async function storeGameSession(sessionId, sessionData) {
try {
const redis = await getRedis();
const key = SESSION_KEY_PREFIX + sessionId;
// Store session data with expiry
await redis.setEx(key, SESSION_EXPIRY, JSON.stringify(sessionData));
return true;
} catch (error) {
console.error('Error storing game session:', error);
throw error;
}
}
/**
* Retrieve a game session from Redis
* @param {string} sessionId - Session identifier
* @returns {Promise<Object|null>} Session data or null if not found
*/
export async function getGameSession(sessionId) {
try {
const redis = await getRedis();
const key = SESSION_KEY_PREFIX + sessionId;
const sessionData = await redis.get(key);
return sessionData ? JSON.parse(sessionData) : null;
} catch (error) {
console.error('Error retrieving game session:', error);
throw error;
}
}
/**
* Delete a game session from Redis
* @param {string} sessionId - Session identifier
* @returns {Promise<boolean>} Success status
*/
export async function deleteGameSession(sessionId) {
try {
const redis = await getRedis();
const key = SESSION_KEY_PREFIX + sessionId;
await redis.del(key);
return true;
} catch (error) {
console.error('Error deleting game session:', error);
throw error;
}
}