From 1a27e2d596d9465815eba3e074dc21e7cd444d73 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 Aug 2025 16:20:37 +0700 Subject: [PATCH] feat: stash --- .claude/settings.local.json | 5 + CLAUDE.md | 38 ++ README.md | 86 +++- docs/development.md | 52 +++ docs/features.md | 35 ++ docs/game-flow.md | 70 ++++ docs/project-overview.md | 19 + docs/project-structure.md | 81 ++++ docs/tech-stack.md | 40 ++ src/app/api/debug/mapillary/route.js | 114 ++++++ src/app/api/guess/route.js | 112 ++++++ src/app/api/leaderboard/route.js | 23 ++ src/app/api/new-game/route.js | 136 +++++++ src/app/api/skip/route.js | 31 ++ src/app/components/GameClient.js | 571 +++++++++++++++++++++++++++ src/app/components/LeafletMap.js | 130 ++++++ src/app/components/PanoramaViewer.js | 148 +++++++ src/app/components/UsernameModal.js | 104 +++++ src/app/debug/page.js | 314 +++++++++++++++ src/app/game/page.js | 7 + src/app/globals.css | 16 + src/app/page.js | 375 +++++++++++++----- src/lib/game.js | 126 ++++++ src/lib/leaderboard.js | 96 +++++ src/lib/mapillary.js | 49 +++ src/lib/redis.js | 16 + src/lib/session.js | 64 +++ 27 files changed, 2742 insertions(+), 116 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 docs/development.md create mode 100644 docs/features.md create mode 100644 docs/game-flow.md create mode 100644 docs/project-overview.md create mode 100644 docs/project-structure.md create mode 100644 docs/tech-stack.md create mode 100644 src/app/api/debug/mapillary/route.js create mode 100644 src/app/api/guess/route.js create mode 100644 src/app/api/leaderboard/route.js create mode 100644 src/app/api/new-game/route.js create mode 100644 src/app/api/skip/route.js create mode 100644 src/app/components/GameClient.js create mode 100644 src/app/components/LeafletMap.js create mode 100644 src/app/components/PanoramaViewer.js create mode 100644 src/app/components/UsernameModal.js create mode 100644 src/app/debug/page.js create mode 100644 src/app/game/page.js create mode 100644 src/lib/game.js create mode 100644 src/lib/leaderboard.js create mode 100644 src/lib/mapillary.js create mode 100644 src/lib/redis.js create mode 100644 src/lib/session.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..aba839c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "defaultMode": "acceptEdits" + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..33f23d7 --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 66bb426..920dc35 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..a23922a --- /dev/null +++ b/docs/development.md @@ -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 \ No newline at end of file diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..f6ef975 --- /dev/null +++ b/docs/features.md @@ -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 diff --git a/docs/game-flow.md b/docs/game-flow.md new file mode 100644 index 0000000..250e03f --- /dev/null +++ b/docs/game-flow.md @@ -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 \ No newline at end of file diff --git a/docs/project-overview.md b/docs/project-overview.md new file mode 100644 index 0000000..9b40f3d --- /dev/null +++ b/docs/project-overview.md @@ -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 \ No newline at end of file diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..71b16ff --- /dev/null +++ b/docs/project-structure.md @@ -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 \ No newline at end of file diff --git a/docs/tech-stack.md b/docs/tech-stack.md new file mode 100644 index 0000000..9a78de7 --- /dev/null +++ b/docs/tech-stack.md @@ -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 \ No newline at end of file diff --git a/src/app/api/debug/mapillary/route.js b/src/app/api/debug/mapillary/route.js new file mode 100644 index 0000000..c8c6ca4 --- /dev/null +++ b/src/app/api/debug/mapillary/route.js @@ -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 }); +} \ No newline at end of file diff --git a/src/app/api/guess/route.js b/src/app/api/guess/route.js new file mode 100644 index 0000000..5971c5b --- /dev/null +++ b/src/app/api/guess/route.js @@ -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 }); +} diff --git a/src/app/api/leaderboard/route.js b/src/app/api/leaderboard/route.js new file mode 100644 index 0000000..2e92c2e --- /dev/null +++ b/src/app/api/leaderboard/route.js @@ -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 }); + } +} diff --git a/src/app/api/new-game/route.js b/src/app/api/new-game/route.js new file mode 100644 index 0000000..147e420 --- /dev/null +++ b/src/app/api/new-game/route.js @@ -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 + } + }); +} diff --git a/src/app/api/skip/route.js b/src/app/api/skip/route.js new file mode 100644 index 0000000..111ec3a --- /dev/null +++ b/src/app/api/skip/route.js @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/components/GameClient.js b/src/app/components/GameClient.js new file mode 100644 index 0000000..97904f6 --- /dev/null +++ b/src/app/components/GameClient.js @@ -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: () =>
Loading map...
+}); +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: '
', + 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: '
', + 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 ( +
+ + +
+ + +

Loading panoramic image...

+
+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + +
+
VNGEOGUESSR
+ + {cityNames[location] || location} + +
+ + +
+ + {/* Game Content */} +
+ {/* Panorama Viewer */} +
+ {imageData ? ( + + ) : ( +
+
Loading panorama...
+
+ )} +
+ + {/* Map and Submit */} +
+
+ +
+
+ + +
+
+
+ + {/* Result Modal */} + setShowResult(false)}> + + + RESULT + + +
+ + {formatDistance(distance)} away + + +
+ Score: {score}/5 points +
+ + {leaderboardRank && ( + + Vietnam Leaderboard Rank: #{leaderboardRank} + + )} + +

+ Playing as: {username || 'Anonymous'} • City: {cityNames[location] || location} +

+
+ + + +
📍 Actual Location vs Your Guess
+
+
+ {exactLocation && ( +

+ Actual: {exactLocation.lat.toFixed(4)}, {exactLocation.lng.toFixed(4)} +

+ )} + {guessCoordinates && ( +

+ Guess: {guessCoordinates[0].toFixed(4)}, {guessCoordinates[1].toFixed(4)} +

+ )} +
+
+
+ +
+

+ {getResultMessage(score, distance)} +

+
+ + +
+
+
+
+ + + {/* Donate Modal */} + + + + DONATE HERE + + +
+ + + QR Code Placeholder + + + + +
+
+
+
+
+ ); +} diff --git a/src/app/components/LeafletMap.js b/src/app/components/LeafletMap.js new file mode 100644 index 0000000..ac13b3f --- /dev/null +++ b/src/app/components/LeafletMap.js @@ -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 ( +
+ ); +} \ No newline at end of file diff --git a/src/app/components/PanoramaViewer.js b/src/app/components/PanoramaViewer.js new file mode 100644 index 0000000..185ca26 --- /dev/null +++ b/src/app/components/PanoramaViewer.js @@ -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 = ` +
+
Loading panoramic image...
+
+ `; + 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 = '
'; + + 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 = ` + Street view + `; + 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 = ` + 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 ( +
+ ); +} + +export default memo(PanoramaViewer); \ No newline at end of file diff --git a/src/app/components/UsernameModal.js b/src/app/components/UsernameModal.js new file mode 100644 index 0000000..a7e444e --- /dev/null +++ b/src/app/components/UsernameModal.js @@ -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 ( + + + + Welcome to VNGeoGuessr! + + Enter your username to join the leaderboard + + + +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + maxLength={20} + autoFocus + /> + {error && ( + + {error} + + )} +
+ +
+ + +
+
+ +

+ Your username will be displayed on the leaderboard +

+
+
+ ); +} \ No newline at end of file diff --git a/src/app/debug/page.js b/src/app/debug/page.js new file mode 100644 index 0000000..0182577 --- /dev/null +++ b/src/app/debug/page.js @@ -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: () =>
+}); + +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 ( +
+
+
+ {/* Header */} +
+
+

Debug Tool

+

Bbox Visualization & Mapillary Testing

+
+ +
+ +
+ {/* Controls Panel */} + + + Bbox Input + + +
+ + setBbox(e.target.value)} + placeholder="105.8,21.0,105.9,21.1" + className="mt-1" + /> +

+ Format: longitude1,latitude1,longitude2,latitude2 +

+
+ + {error && ( + + {error} + + )} + +
+ + +
+ + {parsedBbox && ( + + +

Parsed Bbox:

+
+
Min: {parsedBbox.minLng.toFixed(6)}, {parsedBbox.minLat.toFixed(6)}
+
Max: {parsedBbox.maxLng.toFixed(6)}, {parsedBbox.maxLat.toFixed(6)}
+
Size: {((parsedBbox.maxLng - parsedBbox.minLng) * 111).toFixed(2)}km × {((parsedBbox.maxLat - parsedBbox.minLat) * 111).toFixed(2)}km
+
+
+
+ )} + + {mapillaryResults && ( + + +

Mapillary Results:

+ {mapillaryResults.success ? ( +
+ + Found {mapillaryResults.data.length} images + +
+
Panoramic images: {mapillaryResults.data.filter(img => img.is_pano).length}
+
Regular images: {mapillaryResults.data.filter(img => !img.is_pano).length}
+ {mapillaryResults.analysis && ( +
+
Area: {mapillaryResults.analysis.bboxArea.widthKm}km × {mapillaryResults.analysis.bboxArea.heightKm}km
+
Density: {(mapillaryResults.data.length / (parseFloat(mapillaryResults.analysis.bboxArea.widthKm) * parseFloat(mapillaryResults.analysis.bboxArea.heightKm))).toFixed(1)} images/km²
+
+ )} +
+
+ ) : ( +
+ + Error: {mapillaryResults.error} + +
+ )} +
+
+ )} +
+
+ + {/* Map Panel */} + + + Map Visualization + + +
+ +
+

+ Red rectangle shows the bbox area. Center marker shows the bbox center point. +

+
+
+
+ + {/* Sample Bboxes */} + + + Sample Bboxes + + +
+ {[ + { 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) => ( + + ))} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/game/page.js b/src/app/game/page.js new file mode 100644 index 0000000..c9f6fc3 --- /dev/null +++ b/src/app/game/page.js @@ -0,0 +1,7 @@ +"use client"; + +import GameClient from '../components/GameClient'; + +export default function GamePage() { + return ; +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index dc98be7..557d90a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; +} diff --git a/src/app/page.js b/src/app/page.js index d0d0a6a..93ce40f 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -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 ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.js - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ 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); -
- - Vercel logomark - Deploy now - - - Read our docs - + 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 ( +
+
+
+ {/* Header */} +
+
+ +
+
+ + +
+
+ + {/* Main Content */} +
+

WELCOME TO VNGEOGUESSR

+
+ +
+ {/* Left Panel - Instructions */} + + + HOW TO PLAY + + +
+

1. Choose your city

+

2. View the street-level panoramic image

+

3. Guess the location on the map as accurately as possible

+

4. Earn points based on your accuracy!

+
+
+
+ + {/* Right Panel - City Selection */} + + + SELECT CITY + + +
+ {cities.map((city) => ( + + ))} +
+
+
+
-
- + + 🔧 + + +
); } diff --git a/src/lib/game.js b/src/lib/game.js new file mode 100644 index 0000000..e4fa699 --- /dev/null +++ b/src/lib/game.js @@ -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 }; +} diff --git a/src/lib/leaderboard.js b/src/lib/leaderboard.js new file mode 100644 index 0000000..5d59b89 --- /dev/null +++ b/src/lib/leaderboard.js @@ -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 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} 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'); + } +} + diff --git a/src/lib/mapillary.js b/src/lib/mapillary.js new file mode 100644 index 0000000..7716721 --- /dev/null +++ b/src/lib/mapillary.js @@ -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; + } +} diff --git a/src/lib/redis.js b/src/lib/redis.js new file mode 100644 index 0000000..c412090 --- /dev/null +++ b/src/lib/redis.js @@ -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} Redis client instance + */ +export async function getRedis() { + if (!redis) { + redis = await createClient({ url: process.env.REDIS_URL }).connect(); + } + return redis; +} \ No newline at end of file diff --git a/src/lib/session.js b/src/lib/session.js new file mode 100644 index 0000000..edc66a7 --- /dev/null +++ b/src/lib/session.js @@ -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} 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} 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} 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; + } +} \ No newline at end of file