diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0eacf3b..4ca106d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "Bash(npm run lint)", "Bash(npm uninstall:*)", "Bash(find:*)", - "Bash(pkill:*)" + "Bash(pkill:*)", + "Bash(cp:*)" ], "deny": [] } diff --git a/.gitignore b/.gitignore index 08987b1..45b0e22 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,5 @@ yarn-error.log* # vercel .vercel -# typescript -*.tsbuildinfo -next-env.d.ts - # legacy files -default.js \ No newline at end of file +default.js diff --git a/CLAUDE.md b/CLAUDE.md index 45da134..ba0af4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,13 +17,41 @@ VIGEOGUESSR is a Vietnamese geography guessing game built with Next.js 14. Playe - `npm run start` - Start production server - `npm run lint` - Run ESLint to check code quality +## Project Structure (Standard Next.js) + +``` +src/ +├── app/ # Next.js App Router pages +│ ├── game/ # Game page +│ ├── globals.css # Global styles +│ ├── layout.js # Root layout +│ └── page.js # Home page +├── components/ # React components +│ ├── ui/ # Reusable UI components +│ │ ├── Header.js # Navigation header +│ │ ├── DonateModal.js # Donation modal +│ │ └── index.js # Component exports +│ └── features/ # Game-specific components +│ ├── GameMap.js # Interactive Leaflet map +│ ├── PanoViewer.js # 360° image viewer +│ ├── ResultModal.js # Game result display +│ └── index.js # Component exports +├── constants/ # Application constants +│ └── locations.js # Vietnamese city data +├── utils/ # Utility functions +│ ├── mapillary.js # Mapillary API integration +│ ├── distance.js # Distance calculations +│ └── index.js # Utility exports +└── types/ # TypeScript definitions (ready for TS migration) +``` + ## Architecture ### Core Game Flow -1. **Image Fetching**: `lib/gameUtils.js:getRandomMapillaryImage()` fetches random panoramic images from Mapillary API within predefined Vietnamese city boundaries -2. **Display**: `components/PanoViewer.js` renders 360° images using Photo Sphere Viewer library with fallback to regular images -3. **Interaction**: `components/GameMap.js` provides Leaflet-based interactive map for location guessing -4. **Scoring**: Distance calculation using haversine formula in `lib/gameUtils.js:calculateDistance()` +1. **Image Fetching**: `src/utils/mapillary.js:getRandomMapillaryImage()` fetches random panoramic images from Mapillary API within predefined Vietnamese city boundaries +2. **Display**: `src/components/features/PanoViewer.js` renders 360° images using Photo Sphere Viewer library with fallback to regular images +3. **Interaction**: `src/components/features/GameMap.js` provides Leaflet-based interactive map for location guessing +4. **Scoring**: Distance calculation using haversine formula in `src/utils/distance.js:calculateDistance()` ### Key Components - **GameMap**: Leaflet.js integration with click-based location selection, automatic centering based on city choice @@ -31,7 +59,7 @@ VIGEOGUESSR is a Vietnamese geography guessing game built with Next.js 14. Playe - **Game Page**: Main game orchestration with state management for loading, guessing, and results ### Data Structure -- City boundaries defined in `boundingBoxVN` object with coordinates and delta values for API queries +- City boundaries defined in `src/constants/locations.js:boundingBoxVN` 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) ### External Dependencies @@ -49,4 +77,12 @@ Game state managed at page level with React hooks: - Maps and Photo Sphere Viewer are loaded via CDN and initialized with retry logic - Components use refs for DOM manipulation and cleanup - Mapillary API calls include retry logic for failed requests -- Image fallback system handles panoramic vs regular image display \ No newline at end of file +- Image fallback system handles panoramic vs regular image display + +### Recent Refactoring (Completed) +- **Structure**: Migrated to standard Next.js `src/` directory structure +- **Code Organization**: Separated utilities (`src/utils/`), constants (`src/constants/`), UI components (`src/components/ui/`), and feature components (`src/components/features/`) +- **Import Optimization**: Updated all import paths and added index files for cleaner imports +- **Configuration**: Updated `jsconfig.json` path aliases to work with new src structure +- **Cleanup**: Removed 16 unused assets and redundant npm dependencies +- Project now follows Next.js 14 best practices and is ready for TypeScript migration \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json index e829f99..8f0b21c 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -19,9 +19,9 @@ ], "baseUrl": ".", "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, - "include": ["**/*.js", "**/*.jsx", ".next/types/**/*.ts"], + "include": ["src/**/*.js", "src/**/*.jsx"], "exclude": ["node_modules"] -} \ No newline at end of file +} diff --git a/app/game/page.js b/src/app/game/page.js similarity index 91% rename from app/game/page.js rename to src/app/game/page.js index 90db34d..7a34fc5 100644 --- a/app/game/page.js +++ b/src/app/game/page.js @@ -2,12 +2,13 @@ import { useState, useEffect, useCallback, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import Header from '@/components/Header'; -import DonateModal from '@/components/DonateModal'; -import PanoViewer from '@/components/PanoViewer'; -import GameMap from '@/components/GameMap'; -import ResultModal from '@/components/ResultModal'; -import { getRandomMapillaryImage, calculateDistance } from '@/lib/gameUtils'; +import Header from '@/components/ui/Header'; +import DonateModal from '@/components/ui/DonateModal'; +import PanoViewer from '@/components/features/PanoViewer'; +import GameMap from '@/components/features/GameMap'; +import ResultModal from '@/components/features/ResultModal'; +import { getRandomMapillaryImage } from '@/utils/mapillary'; +import { calculateDistance } from '@/utils/distance'; function GamePageContent() { const router = useRouter(); diff --git a/app/globals.css b/src/app/globals.css similarity index 100% rename from app/globals.css rename to src/app/globals.css diff --git a/app/layout.js b/src/app/layout.js similarity index 100% rename from app/layout.js rename to src/app/layout.js diff --git a/app/page.js b/src/app/page.js similarity index 94% rename from app/page.js rename to src/app/page.js index d9caccf..ae2ec0d 100644 --- a/app/page.js +++ b/src/app/page.js @@ -2,9 +2,9 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import Header from '@/components/Header'; -import DonateModal from '@/components/DonateModal'; -import { locationNames } from '@/lib/gameUtils'; +import Header from '@/components/ui/Header'; +import DonateModal from '@/components/ui/DonateModal'; +import { locationNames } from '@/constants/locations'; export default function Home() { const [showDonateModal, setShowDonateModal] = useState(false); diff --git a/components/GameMap.js b/src/components/features/GameMap.js similarity index 98% rename from components/GameMap.js rename to src/components/features/GameMap.js index 41eb4f7..54634a3 100644 --- a/components/GameMap.js +++ b/src/components/features/GameMap.js @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef, useState, useCallback } from 'react'; -import { boundingBoxVN } from '@/lib/gameUtils'; +import { boundingBoxVN } from '@/constants/locations'; export default function GameMap({ choiceLocation, onLocationSelect }) { const mapRef = useRef(null); diff --git a/components/PanoViewer.js b/src/components/features/PanoViewer.js similarity index 100% rename from components/PanoViewer.js rename to src/components/features/PanoViewer.js diff --git a/components/ResultModal.js b/src/components/features/ResultModal.js similarity index 100% rename from components/ResultModal.js rename to src/components/features/ResultModal.js diff --git a/src/components/features/index.js b/src/components/features/index.js new file mode 100644 index 0000000..42f71fb --- /dev/null +++ b/src/components/features/index.js @@ -0,0 +1,3 @@ +export { default as GameMap } from './GameMap'; +export { default as PanoViewer } from './PanoViewer'; +export { default as ResultModal } from './ResultModal'; \ No newline at end of file diff --git a/components/DonateModal.js b/src/components/ui/DonateModal.js similarity index 100% rename from components/DonateModal.js rename to src/components/ui/DonateModal.js diff --git a/components/Header.js b/src/components/ui/Header.js similarity index 100% rename from components/Header.js rename to src/components/ui/Header.js diff --git a/src/components/ui/index.js b/src/components/ui/index.js new file mode 100644 index 0000000..da083c3 --- /dev/null +++ b/src/components/ui/index.js @@ -0,0 +1,2 @@ +export { default as Header } from './Header'; +export { default as DonateModal } from './DonateModal'; \ No newline at end of file diff --git a/src/constants/locations.js b/src/constants/locations.js new file mode 100644 index 0000000..767f6f0 --- /dev/null +++ b/src/constants/locations.js @@ -0,0 +1,26 @@ +/** + * Vietnamese city boundaries for the geography guessing game + * Format: [minLong, minLat, maxLong, maxLat, delta] + */ +export const boundingBoxVN = { + 'HN': [105.77, 20.96, 105.88, 21.05, 0.003], + 'TPHCM': [106.62, 10.71, 106.75, 10.83, 0.005], + 'HP': [106.65, 20.8, 106.75, 20.9, 0.05], + 'ND': [106, 20.35, 106.25, 20.5, 0.005], + 'DN': [108.17, 16, 108.25, 16.1, 0.005], + 'DL': [108.38, 11.89, 108.50, 12.00, 0.005], + 'DHLA': [106.35, 10.85, 106.45, 10.95, 0.005] +}; + +/** + * Display names for Vietnamese cities + */ +export const locationNames = { + 'HN': 'HA NOI', + 'TPHCM': 'TP. HO CHI MINH', + 'HP': 'HAI PHONG', + 'ND': 'NAM DINH', + 'DN': 'DA NANG', + 'DL': 'DALAT', + 'DHLA': 'DUC HOA' +}; \ No newline at end of file diff --git a/src/utils/distance.js b/src/utils/distance.js new file mode 100644 index 0000000..87da068 --- /dev/null +++ b/src/utils/distance.js @@ -0,0 +1,32 @@ +/** + * Calculates the distance between two geographic coordinates using the Haversine formula + * @param {number} lat1 - Latitude of first point + * @param {number} lon1 - Longitude of first point + * @param {number} lat2 - Latitude of second point + * @param {number} lon2 - Longitude of second point + * @returns {number} Distance in meters, rounded to 1 decimal place + */ +export function calculateDistance(lat1, lon1, lat2, lon2) { + if (lat1 === lat2 && lon1 === lon2) { + return 0; + } + + const radlat1 = Math.PI * lat1 / 180; + const radlat2 = Math.PI * lat2 / 180; + const theta = lon1 - lon2; + const radtheta = Math.PI * theta / 180; + + let dist = Math.sin(radlat1) * Math.sin(radlat2) + + Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta); + + if (dist > 1) { + dist = 1; + } + + dist = Math.acos(dist); + dist = dist * 180 / Math.PI; + dist = dist * 60 * 1.1515; + dist = dist * 1609.34; // Convert to meters + + return parseFloat(dist.toFixed(1)); +} \ No newline at end of file diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..6dc8a51 --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,2 @@ +export { getRandomMapillaryImage } from './mapillary'; +export { calculateDistance } from './distance'; \ No newline at end of file diff --git a/lib/gameUtils.js b/src/utils/mapillary.js similarity index 57% rename from lib/gameUtils.js rename to src/utils/mapillary.js index 57a7201..026cf7f 100644 --- a/lib/gameUtils.js +++ b/src/utils/mapillary.js @@ -1,23 +1,10 @@ -export const boundingBoxVN = { - 'HN': [105.77, 20.96, 105.88, 21.05, 0.003], - 'TPHCM': [106.62, 10.71, 106.75, 10.83, 0.005], - 'HP': [106.65, 20.8, 106.75, 20.9, 0.05], - 'ND': [106, 20.35, 106.25, 20.5, 0.005], - 'DN': [108.17, 16, 108.25, 16.1, 0.005], - 'DL': [108.38, 11.89, 108.50, 12.00, 0.005], - 'DHLA': [106.35, 10.85, 106.45, 10.95, 0.005] -}; - -export const locationNames = { - 'HN': 'HA NOI', - 'TPHCM': 'TP. HO CHI MINH', - 'HP': 'HAI PHONG', - 'ND': 'NAM DINH', - 'DN': 'DA NANG', - 'DL': 'DALAT', - 'DHLA': 'DUC HOA' -}; +import { boundingBoxVN } from '@/constants/locations'; +/** + * Fetches a random panoramic image from Mapillary API within the specified Vietnamese city bounds + * @param {string} choiceLocation - The location code (e.g., 'HN', 'TPHCM') + * @returns {Promise} Image data with coordinates and URL, or null if failed + */ export async function getRandomMapillaryImage(choiceLocation) { const [minLong, minLat, maxLong, maxLat, delta] = boundingBoxVN[choiceLocation]; @@ -59,29 +46,4 @@ export async function getRandomMapillaryImage(choiceLocation) { console.error("Error fetching image:", error); return null; } -} - -export function calculateDistance(lat1, lon1, lat2, lon2) { - if (lat1 === lat2 && lon1 === lon2) { - return 0; - } - - const radlat1 = Math.PI * lat1 / 180; - const radlat2 = Math.PI * lat2 / 180; - const theta = lon1 - lon2; - const radtheta = Math.PI * theta / 180; - - let dist = Math.sin(radlat1) * Math.sin(radlat2) + - Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta); - - if (dist > 1) { - dist = 1; - } - - dist = Math.acos(dist); - dist = dist * 180 / Math.PI; - dist = dist * 60 * 1.1515; - dist = dist * 1609.34; // Convert to meters - - return parseFloat(dist.toFixed(1)); } \ No newline at end of file