mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
feat: stash
This commit is contained in:
1 parent
ad0984e0c4
commit
1a27e2d596
27 files changed
+2742
-116
No files matched your search
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import GameClient from '../components/GameClient';
|
||||
|
||||
export default function GamePage() {
|
||||
return <GameClient />;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user