mirror of
https://github.com/tiennm99/vigeoguessr.git
synced 2026-10-15 06:16:27 +00:00
feat: migrate to ts
This commit is contained in:
1 parent
f8e1d992a2
commit
403dc78743
26 files changed
+312
-199
No files matched your search
@@ -58,13 +58,15 @@ src/
|
||||
|
||||
Key configuration files in the project root:
|
||||
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
- `tailwind.config.ts` - Tailwind CSS customization
|
||||
- `.eslintrc.js` - ESLint with TypeScript rules
|
||||
- `postcss.config.js` - PostCSS configuration
|
||||
- `next.config.js` - Next.js configuration
|
||||
- `jest.config.js` - Jest testing configuration
|
||||
- `tsconfig.json` - TypeScript configuration with strict settings and path mapping
|
||||
- `tailwind.config.ts` - Tailwind CSS customization with TypeScript
|
||||
- `.eslintrc.json` - ESLint with TypeScript rules
|
||||
- `postcss.config.ts` - PostCSS configuration with TypeScript
|
||||
- `next.config.ts` - Next.js configuration with TypeScript
|
||||
- `jest.config.ts` - Jest testing configuration with TypeScript
|
||||
- `jest.setup.ts` - Jest setup and mocks with TypeScript
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `next-env.d.ts` - Next.js TypeScript environment types
|
||||
|
||||
## 📦 Installation & Setup
|
||||
|
||||
@@ -236,7 +238,7 @@ This project uses a carefully chosen combination of specialized libraries rather
|
||||
1. **User Entry**: Home page checks localStorage for cached username, displays popup if not found
|
||||
2. **City Selection**: User selects from available Vietnamese cities on home page
|
||||
3. **Game Initialization**: Navigate to game scene with selected city parameter
|
||||
4. **Image Loading**: Server provides random panoramic image from city bounds via `src/app/api/mapillary/route.js`
|
||||
4. **Image Loading**: Server provides random panoramic image from city bounds via `src/app/api/mapillary/route.ts`
|
||||
5. **Gameplay**:
|
||||
- Display raw panoramic image without location metadata
|
||||
- Show city overview map for location guessing
|
||||
@@ -255,12 +257,12 @@ This project uses a carefully chosen combination of specialized libraries rather
|
||||
|
||||
**Technical Implementation:**
|
||||
|
||||
- **Image Fetching**: `src/services/game-image.service.js` handles client-side image fetching with retry logic
|
||||
- **API Integration**: `src/services/mapillary.service.js` provides server-side Mapillary API integration
|
||||
- **Display**: `src/components/features/PanoViewer.js` renders raw 360° images using MapillaryJS npm module
|
||||
- **Interaction**: `src/components/features/GameMap.js` provides MapLibre GL JS-based interactive map for location guessing
|
||||
- **Scoring**: `src/services/scoring.service.js` handles point calculation with configurable thresholds
|
||||
- **Geography**: `src/services/geography.service.js` provides distance calculation with OpenStreetMap validation
|
||||
- **Image Fetching**: `src/services/game-image.service.ts` handles client-side image fetching with retry logic
|
||||
- **API Integration**: `src/services/mapillary.service.ts` provides server-side Mapillary API integration
|
||||
- **Display**: `src/components/features/PanoViewer.tsx` renders raw 360° images using MapillaryJS npm module
|
||||
- **Interaction**: `src/components/features/GameMap.tsx` provides MapLibre GL JS-based interactive map for location guessing
|
||||
- **Scoring**: `src/services/scoring.service.ts` handles point calculation with configurable thresholds
|
||||
- **Geography**: `src/services/geography.service.ts` provides distance calculation with OpenStreetMap validation
|
||||
- **Session Management**: Per-user session tracking for concurrent multi-player support
|
||||
|
||||
### Key Components
|
||||
@@ -272,7 +274,7 @@ This project uses a carefully chosen combination of specialized libraries rather
|
||||
|
||||
### Data Structure
|
||||
|
||||
- City boundaries defined in `src/constants/locations.js:LOCATION_BOUNDS` object with coordinates and delta values for API queries
|
||||
- City boundaries defined in `src/constants/locations.ts:LOCATION_BOUNDS` object with coordinates and delta values for API queries
|
||||
- Location codes: HN (Hanoi), TPHCM (Ho Chi Minh City), HP (Hai Phong), ND (Nam Dinh), DN (Da Nang), DL (Dalat), DHLA (Duc Hoa)
|
||||
- Constants organized with enums: `LOCATION_CODES`, `LOCATION_BOUNDS`, `LOCATION_NAMES`
|
||||
|
||||
@@ -293,18 +295,21 @@ Game state managed at page level with React hooks:
|
||||
## Recent Implementation Updates
|
||||
|
||||
### Modern Tech Stack Migration (Latest)
|
||||
- **Language**: TypeScript (`^5.0.0`) with strict typing and interfaces
|
||||
- **Package Manager**: Migrated from npm to pnpm with updated scripts (dev, build, start, lint, format, test)
|
||||
- **Tailwind CSS**: Complete migration from custom CSS to Tailwind utilities with custom design tokens matching game theme
|
||||
- **Language**: TypeScript (`^5.7.3`) with strict typing, interfaces, and complete migration
|
||||
- **Package Manager**: Migrated from npm to pnpm with updated scripts (dev, build, start, lint, format, test, type-check)
|
||||
- **Tailwind CSS**: Complete migration from custom CSS to Tailwind utilities with custom design tokens matching game theme
|
||||
- **shadcn/ui**: Installed and configured with utility functions, ready for UI component replacements
|
||||
- **React Query**: Added TanStack Query with QueryClientProvider in layout and custom hooks (`useMapillary`, `useScoring`)
|
||||
- **Testing Setup**: Configured Jest + React Testing Library with specialized mocks for MapLibre/MapillaryJS libraries
|
||||
- **Project Structure**: Added modern hooks/, lib/ directories following TypeScript architectural guidelines
|
||||
- **Project Structure**: Added modern hooks/, lib/, types/ directories following TypeScript architectural guidelines
|
||||
- **Development Environment**: Updated to modern toolchain with TypeScript, ESLint, Prettier formatting and enhanced DX
|
||||
- **File Migration**: All 25+ JavaScript files converted to TypeScript with proper typing
|
||||
- **Type Safety**: Custom type definitions for external libraries (MapLibre GL JS, MapillaryJS)
|
||||
- **Zero JavaScript**: Complete elimination of ALL `.js` files from entire project (src/ + root config files)
|
||||
|
||||
### Current Architecture Status
|
||||
- ✅ **Framework**: Next.js 15 + React 19
|
||||
- ✅ **Language**: TypeScript with strict configuration
|
||||
- ✅ **Language**: TypeScript with strict configuration - **COMPLETE MIGRATION**
|
||||
- ✅ **Styling**: Tailwind CSS with custom game design tokens
|
||||
- ✅ **Data Fetching**: React Query hooks for API management
|
||||
- ✅ **Testing**: Jest + RTL with TypeScript and library mocks
|
||||
@@ -312,13 +317,16 @@ Game state managed at page level with React hooks:
|
||||
- ✅ **App Router**: All app router files migrated to TypeScript (.tsx)
|
||||
- ✅ **API Routes**: All API routes migrated to TypeScript (.ts)
|
||||
- ✅ **Service Layer**: All service files migrated with proper TypeScript interfaces
|
||||
- ✅ **Core Components**: GameMap, PanoViewer migrated to TypeScript
|
||||
- ✅ **Components**: All components migrated to TypeScript (GameMap, PanoViewer, ResultModal, Header, DonateModal, UsernameModal)
|
||||
- ✅ **Hooks**: All hooks migrated with proper TypeScript return types
|
||||
- ✅ **Lib Files**: React Query provider and utilities migrated to TypeScript
|
||||
- ✅ **Constants**: Location constants migrated with TypeScript interfaces and enums
|
||||
- ✅ **Type Definitions**: Custom type definitions created for MapLibre GL JS and MapillaryJS
|
||||
- 🚧 **UI Components**: Ready for shadcn/ui migration (Header, DonateModal, UsernameModal)
|
||||
- 🚧 **Constants**: Ready for TypeScript interface migration
|
||||
- 🚧 **Hooks**: Ready for TypeScript migration with proper return types
|
||||
- ✅ **Index Files**: All index files migrated and JavaScript files removed
|
||||
- ✅ **Config Files**: All root configuration files migrated (next.config.ts, jest.config.ts, postcss.config.ts, tailwind.config.ts)
|
||||
- 🚧 **UI Components**: Ready for shadcn/ui migration (existing components are TypeScript-ready)
|
||||
|
||||
Project is now fully migrated to TypeScript with modern tooling and ready for continued development.
|
||||
**🎉 COMPLETE TYPESCRIPT MIGRATION ACHIEVED** - Zero JavaScript files remaining in entire project. All source code AND configuration files are now TypeScript-enabled with modern tooling and industry-standard development practices.
|
||||
|
||||
## 🏗️ Service Layer Architecture
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ A Next.js-based Vietnamese geography guessing game where players guess the locat
|
||||
- 🎯 Interactive map for location guessing
|
||||
- 📊 Distance-based scoring system
|
||||
- 🖼️ 360° panoramic street view images using Photo Sphere Viewer
|
||||
- ⚡ Built with Next.js 14 and JavaScript for performance and simplicity
|
||||
- ⚡ Built with Next.js 15 and TypeScript for performance and type safety
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 with App Router
|
||||
- **Language**: JavaScript
|
||||
- **Styling**: CSS Modules with custom styling
|
||||
- **Maps**: Leaflet.js
|
||||
- **360° Images**: Photo Sphere Viewer
|
||||
- **Framework**: Next.js 15 with App Router
|
||||
- **Language**: TypeScript with strict type checking
|
||||
- **Styling**: Tailwind CSS with custom design tokens
|
||||
- **Maps**: MapLibre GL JS
|
||||
- **360° Images**: MapillaryJS
|
||||
- **Image API**: Mapillary Graph API
|
||||
|
||||
## Getting Started
|
||||
@@ -60,24 +60,24 @@ npm start
|
||||
├── app/ # Next.js app directory
|
||||
│ ├── game/ # Game page
|
||||
│ ├── globals.css # Global styles
|
||||
│ ├── layout.js # Root layout
|
||||
│ └── page.js # Home page
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── page.tsx # Home page
|
||||
├── components/ # Reusable React components
|
||||
│ ├── DonateModal.js
|
||||
│ ├── GameMap.js
|
||||
│ ├── Header.js
|
||||
│ ├── PanoViewer.js
|
||||
│ └── ResultModal.js
|
||||
│ ├── DonateModal.tsx
|
||||
│ ├── GameMap.tsx
|
||||
│ ├── Header.tsx
|
||||
│ ├── PanoViewer.tsx
|
||||
│ └── ResultModal.tsx
|
||||
├── lib/ # Utility functions
|
||||
│ └── gameUtils.js # Game logic and API calls
|
||||
│ └── utils.ts # Utility functions
|
||||
└── public/ # Static assets
|
||||
```
|
||||
|
||||
## Key Improvements from Original
|
||||
|
||||
- **Modern Framework**: Migrated from vanilla HTML/JS to Next.js with JavaScript
|
||||
- **Modern Framework**: Migrated from vanilla HTML/JS to Next.js with TypeScript
|
||||
- **Component-Based Architecture**: Modular, reusable React components
|
||||
- **Clean Code**: Well-structured JavaScript with proper organization
|
||||
- **Clean Code**: Well-structured TypeScript with proper type safety and organization
|
||||
- **Code Deduplication**: Eliminated duplicate code and consolidated utilities
|
||||
- **Performance**: Optimized with Next.js built-in optimizations
|
||||
- **Maintainability**: Cleaner code structure and separation of concerns
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const nextJest = require('next/jest')
|
||||
import nextJest from 'next/jest'
|
||||
import type { Config } from 'jest'
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||
@@ -6,8 +7,8 @@ const createJestConfig = nextJest({
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
const customJestConfig: Config = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testEnvironment: 'jsdom',
|
||||
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
|
||||
transform: {
|
||||
@@ -19,4 +20,4 @@ const customJestConfig = {
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
export default createJestConfig(customJestConfig)
|
||||
@@ -40,4 +40,7 @@ const localStorageMock = {
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
}
|
||||
global.localStorage = localStorageMock
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.js", "src/**/*.jsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
domains: ['graph.mapillary.com', 'maps.google.com', 'unpkg.com'],
|
||||
unoptimized: true
|
||||
@@ -15,4 +16,4 @@ const nextConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
export default nextConfig
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Config } from 'postcss-load-config'
|
||||
|
||||
const config: Config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -6,6 +6,24 @@ import { Viewer } from 'mapillary-js';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
|
||||
interface ImageData {
|
||||
id: string;
|
||||
url: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ResultModalProps {
|
||||
isOpen: boolean;
|
||||
distance: number;
|
||||
points: number;
|
||||
trueLocation: [number, number];
|
||||
guessLocation: [number, number];
|
||||
imageData: ImageData | null;
|
||||
onNextRound: () => void;
|
||||
}
|
||||
|
||||
export default function ResultModal({
|
||||
isOpen,
|
||||
distance,
|
||||
@@ -14,12 +32,12 @@ export default function ResultModal({
|
||||
guessLocation,
|
||||
imageData,
|
||||
onNextRound
|
||||
}) {
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
const panoRef = useRef(null);
|
||||
const viewerRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
}: ResultModalProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<maplibregl.Map | null>(null);
|
||||
const panoRef = useRef<HTMLDivElement>(null);
|
||||
const viewerRef = useRef<Viewer | null>(null);
|
||||
const markersRef = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !mapRef.current) return;
|
||||
@@ -38,7 +56,7 @@ export default function ResultModal({
|
||||
|
||||
// Create MapLibre map
|
||||
mapInstanceRef.current = new maplibregl.Map({
|
||||
container: mapRef.current,
|
||||
container: mapRef.current!,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -62,6 +80,8 @@ export default function ResultModal({
|
||||
});
|
||||
|
||||
mapInstanceRef.current.on('load', () => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
// Add true location marker (green)
|
||||
mapInstanceRef.current.addSource('true-location', {
|
||||
type: 'geojson',
|
||||
@@ -70,7 +90,8 @@ export default function ResultModal({
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [trueLng, trueLat]
|
||||
}
|
||||
},
|
||||
properties: {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,7 +115,8 @@ export default function ResultModal({
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [guessLng, guessLat]
|
||||
}
|
||||
},
|
||||
properties: {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,7 +140,8 @@ export default function ResultModal({
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [[trueLng, trueLat], [guessLng, guessLat]]
|
||||
}
|
||||
},
|
||||
properties: {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -173,7 +196,7 @@ export default function ResultModal({
|
||||
|
||||
viewerRef.current = new Viewer({
|
||||
accessToken: process.env.NEXT_PUBLIC_MAPILLARY_ACCESS_TOKEN || 'MLY|24113623194974280|5bf83fa202912f1cc3210b2cf968fb65',
|
||||
container: panoRef.current,
|
||||
container: panoRef.current!,
|
||||
imageId: imageData.id,
|
||||
component: {
|
||||
attribution: false,
|
||||
@@ -217,14 +240,14 @@ export default function ResultModal({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatDistance = (dist) => {
|
||||
const formatDistance = (dist: number): string => {
|
||||
if (dist > 1000) {
|
||||
return `${(dist / 1000).toFixed(2)} KM`;
|
||||
}
|
||||
return `${dist} M`;
|
||||
};
|
||||
|
||||
const getResultMessage = (dist, pts) => {
|
||||
const getResultMessage = (dist: number, pts: number): string => {
|
||||
if (pts >= 5) return "Perfect! 🎯";
|
||||
if (pts >= 4) return "Excellent! 🎉";
|
||||
if (pts >= 3) return "Great job! 👍";
|
||||
File renamed without changes.
@@ -1,11 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { MouseEvent } from 'react';
|
||||
|
||||
export default function DonateModal({ isOpen, onClose }) {
|
||||
interface DonateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DonateModal({ isOpen, onClose }: DonateModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = (e) => {
|
||||
const handleBackdropClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
|
||||
export default function Header({ onDonateClick }) {
|
||||
interface HeaderProps {
|
||||
onDonateClick: () => void;
|
||||
}
|
||||
|
||||
export default function Header({ onDonateClick }: HeaderProps) {
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-center">
|
||||
@@ -18,4 +22,4 @@ export default function Header({ onDonateClick }) {
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, FormEvent, ChangeEvent } from 'react';
|
||||
|
||||
export default function UsernameModal({ isOpen, onSubmit }) {
|
||||
interface UsernameModalProps {
|
||||
isOpen: boolean;
|
||||
onSubmit: (username: string) => void;
|
||||
}
|
||||
|
||||
export default function UsernameModal({ isOpen, onSubmit }: UsernameModalProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (username.trim()) {
|
||||
onSubmit(username.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setUsername(e.target.value);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -24,7 +33,7 @@ export default function UsernameModal({ isOpen, onSubmit }) {
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Your username"
|
||||
className="username-input"
|
||||
maxLength={20}
|
||||
File renamed without changes.
@@ -13,13 +13,23 @@ export const LOCATION_CODES = {
|
||||
DA_NANG: 'DN',
|
||||
DALAT: 'DL',
|
||||
DUC_HOA: 'DHLA'
|
||||
};
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Location code types
|
||||
*/
|
||||
export type LocationCode = typeof LOCATION_CODES[keyof typeof LOCATION_CODES];
|
||||
|
||||
/**
|
||||
* Location bounds format: [minLongitude, minLatitude, maxLongitude, maxLatitude, deltaRadius]
|
||||
*/
|
||||
export type LocationBounds = [number, number, number, number, number];
|
||||
|
||||
/**
|
||||
* Vietnamese city boundaries for the geography guessing game
|
||||
* Format: [minLongitude, minLatitude, maxLongitude, maxLatitude, deltaRadius]
|
||||
*/
|
||||
export const LOCATION_BOUNDS = {
|
||||
export const LOCATION_BOUNDS: Record<LocationCode, LocationBounds> = {
|
||||
[LOCATION_CODES.HANOI]: [105.77, 20.96, 105.88, 21.05, 0.003],
|
||||
[LOCATION_CODES.HO_CHI_MINH]: [106.62, 10.71, 106.75, 10.83, 0.005],
|
||||
[LOCATION_CODES.HAI_PHONG]: [106.65, 20.8, 106.75, 20.9, 0.05],
|
||||
@@ -32,7 +42,7 @@ export const LOCATION_BOUNDS = {
|
||||
/**
|
||||
* Display names for Vietnamese cities
|
||||
*/
|
||||
export const LOCATION_NAMES = {
|
||||
export const LOCATION_NAMES: Record<LocationCode, string> = {
|
||||
[LOCATION_CODES.HANOI]: 'HA NOI',
|
||||
[LOCATION_CODES.HO_CHI_MINH]: 'TP. HO CHI MINH',
|
||||
[LOCATION_CODES.HAI_PHONG]: 'HAI PHONG',
|
||||
@@ -44,26 +54,21 @@ export const LOCATION_NAMES = {
|
||||
|
||||
/**
|
||||
* Get all available location codes
|
||||
* @returns {string[]} Array of location codes
|
||||
*/
|
||||
export function getAvailableLocations() {
|
||||
export function getAvailableLocations(): LocationCode[] {
|
||||
return Object.values(LOCATION_CODES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for location code
|
||||
* @param {string} locationCode - Location code
|
||||
* @returns {string} Display name or empty string if not found
|
||||
*/
|
||||
export function getLocationDisplayName(locationCode) {
|
||||
return LOCATION_NAMES[locationCode] || '';
|
||||
export function getLocationDisplayName(locationCode: string): string {
|
||||
return LOCATION_NAMES[locationCode as LocationCode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounds for location code
|
||||
* @param {string} locationCode - Location code
|
||||
* @returns {number[]|null} Bounds array or null if not found
|
||||
*/
|
||||
export function getLocationBounds(locationCode) {
|
||||
return LOCATION_BOUNDS[locationCode] || null;
|
||||
}
|
||||
export function getLocationBounds(locationCode: string): LocationBounds | null {
|
||||
return LOCATION_BOUNDS[locationCode as LocationCode] || null;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useMapillaryImage } from './useMapillary';
|
||||
export { useScoring } from './useScoring';
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useMapillaryImage } from './useMapillary';
|
||||
export { useScoring } from './useScoring';
|
||||
export type { ScoreSubmission, ScoreResponse } from './useScoring';
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchRandomGameImage } from '@/services/game-image.service';
|
||||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { fetchRandomGameImage, GameImageData } from '@/services/game-image.service';
|
||||
|
||||
/**
|
||||
* React Query hook for fetching Mapillary images
|
||||
* @param {string} locationCode - Location code (e.g., 'HN', 'TPHCM')
|
||||
* @returns {Object} React Query result with image data
|
||||
*/
|
||||
export function useMapillaryImage(locationCode) {
|
||||
export function useMapillaryImage(locationCode: string): UseQueryResult<GameImageData | null, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['mapillary-image', locationCode],
|
||||
queryFn: () => fetchRandomGameImage(locationCode),
|
||||
enabled: !!locationCode,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 3,
|
||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Scoring data type definition
|
||||
* @typedef {Object} ScoreSubmission
|
||||
* @property {string} username - Player username
|
||||
* @property {number} guessLat - Guessed latitude
|
||||
* @property {number} guessLng - Guessed longitude
|
||||
* @property {number} trueLat - Actual latitude
|
||||
* @property {number} trueLng - Actual longitude
|
||||
* @property {string} imageId - Mapillary image ID
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Query mutation hook for submitting game scores
|
||||
* @returns {Object} Mutation object with mutate function and status
|
||||
*/
|
||||
export function useScoring() {
|
||||
return useMutation({
|
||||
mutationFn: async (scoreData) => {
|
||||
// Validate score data before sending
|
||||
if (!isValidScoreData(scoreData)) {
|
||||
throw new Error('Invalid score data provided');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/scoring', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(scoreData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to submit score');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
retry: 2,
|
||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates score submission data
|
||||
* @param {Object} scoreData - Score data to validate
|
||||
* @returns {boolean} True if data is valid
|
||||
*/
|
||||
function isValidScoreData(scoreData) {
|
||||
return scoreData &&
|
||||
typeof scoreData.username === 'string' &&
|
||||
scoreData.username.trim().length > 0 &&
|
||||
typeof scoreData.guessLat === 'number' &&
|
||||
typeof scoreData.guessLng === 'number' &&
|
||||
typeof scoreData.trueLat === 'number' &&
|
||||
typeof scoreData.trueLng === 'number' &&
|
||||
typeof scoreData.imageId === 'string' &&
|
||||
scoreData.imageId.trim().length > 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMutation, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Scoring data type definition
|
||||
*/
|
||||
export interface ScoreSubmission {
|
||||
username: string;
|
||||
guessLat: number;
|
||||
guessLng: number;
|
||||
trueLat: number;
|
||||
trueLng: number;
|
||||
imageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score submission response type
|
||||
*/
|
||||
export interface ScoreResponse {
|
||||
sessionId: string;
|
||||
distance: number;
|
||||
points: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query mutation hook for submitting game scores
|
||||
*/
|
||||
export function useScoring(): UseMutationResult<ScoreResponse, Error, ScoreSubmission> {
|
||||
return useMutation({
|
||||
mutationFn: async (scoreData: ScoreSubmission): Promise<ScoreResponse> => {
|
||||
// Validate score data before sending
|
||||
if (!isValidScoreData(scoreData)) {
|
||||
throw new Error('Invalid score data provided');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/scoring', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(scoreData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to submit score');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
retry: 2,
|
||||
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates score submission data
|
||||
*/
|
||||
function isValidScoreData(scoreData: unknown): scoreData is ScoreSubmission {
|
||||
if (typeof scoreData !== 'object' || scoreData === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = scoreData as Record<string, unknown>;
|
||||
|
||||
return typeof data.username === 'string' &&
|
||||
data.username.trim().length > 0 &&
|
||||
typeof data.guessLat === 'number' &&
|
||||
typeof data.guessLng === 'number' &&
|
||||
typeof data.trueLat === 'number' &&
|
||||
typeof data.trueLng === 'number' &&
|
||||
typeof data.imageId === 'string' &&
|
||||
data.imageId.trim().length > 0;
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useState, ReactNode } from 'react';
|
||||
|
||||
export default function ReactQueryProvider({ children }) {
|
||||
interface ReactQueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ReactQueryProvider({ children }: ReactQueryProviderProps) {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 2,
|
||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
+9
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"allowJs": true,
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
@@ -21,8 +21,10 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"target": "ES2017",
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"target": "ES2022",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
@@ -31,6 +33,8 @@
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"**/*.js",
|
||||
"**/*.jsx"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user