feat: temp

This commit is contained in:
tiennm99 committed 2025-08-03 16:15:46 +07:00
1 parent 730e9a157a
commit 2db93c38b9
17 files changed
+7001 -6440

No files matched your search

+3 -2
View File
@@ -11,8 +11,9 @@
"Bash(cp:*)",
"Bash(curl:*)",
"WebFetch",
"Bash(grep:*)"
"Bash(grep:*)",
"Bash(pnpm:*)"
],
"deny": []
}
}
}
+22
View File
@@ -260,3 +260,25 @@ Game state managed at page level with React hooks:
- Image loading states and error handling
- Location selection and guess submission
- Distance calculation and result display
## Recent Implementation Updates
### Modern Tech Stack Migration (Latest)
- **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
- **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 new architectural guidelines
- **Development Environment**: Updated to modern toolchain with Prettier formatting and enhanced development experience
### Current Architecture Status
- ✅ **Framework**: Next.js 15 + React 19
- ✅ **Styling**: Tailwind CSS with custom game design tokens
- ✅ **Data Fetching**: React Query hooks for API management
- ✅ **Testing**: Jest + RTL with library mocks
- ✅ **Package Management**: pnpm with modern scripts
- 🚧 **Components**: Existing game components (GameMap, PanoViewer, ResultModal) preserved
- 🚧 **UI Components**: Ready for shadcn/ui migration (Header, DonateModal, UsernameModal)
Project is now modernized and ready for continued development with industry-standard tooling.
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+22
View File
@@ -0,0 +1,22 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
},
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)
+43
View File
@@ -0,0 +1,43 @@
import '@testing-library/jest-dom'
// Mock MapLibre GL JS
jest.mock('maplibre-gl', () => ({
Map: jest.fn(() => ({
on: jest.fn(),
off: jest.fn(),
remove: jest.fn(),
addSource: jest.fn(),
addLayer: jest.fn(),
fitBounds: jest.fn(),
getCanvas: jest.fn(() => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})),
})),
LngLatBounds: jest.fn(() => ({
extend: jest.fn(),
})),
}))
// Mock MapillaryJS
jest.mock('mapillary-js', () => ({
Viewer: jest.fn(() => ({
on: jest.fn(),
off: jest.fn(),
remove: jest.fn(),
getPosition: jest.fn(() => Promise.resolve({ lat: 0, lng: 0 })),
})),
}))
// Mock CSS imports
jest.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
jest.mock('mapillary-js/dist/mapillary.css', () => ({}))
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
global.localStorage = localStorageMock
-6000
View File
File diff suppressed because it is too large. Load diff
+19 -3
View File
@@ -6,17 +6,33 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"format": "prettier --write .",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@tanstack/react-query": "^5.84.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"mapillary-js": "^4.0.0",
"maplibre-gl": "^5.0.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0"
"eslint-config-next": "^15.0.0",
"jest": "^30.0.5",
"jest-environment-jsdom": "^30.0.5",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.11"
}
}
+6679
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+86 -433
View File
@@ -1,460 +1,113 @@
@import url('https://fonts.googleapis.com/css2?family=Jersey+15:wght@400&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
@layer base {
* {
@apply box-border;
}
body {
@apply font-courier bg-background text-text min-h-screen;
}
}
body {
font-family: 'Courier', monospace;
background-color: #1a1a1a;
color: #e6f4e6;
min-height: 100vh;
position: relative;
}
@layer components {
/* Main container */
.main-container {
@apply relative min-h-screen overflow-hidden;
}
/* Main container */
.main-container {
position: relative;
min-height: 100vh;
overflow: hidden;
}
.background-layer {
@apply fixed inset-0 bg-gradient-to-br from-[#0f4c3a] via-[#1a6b4f] to-[#2d8f6d] -z-10;
}
.background-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #0f4c3a 0%, #1a6b4f 50%, #2d8f6d 100%);
z-index: -1;
}
.content-wrapper {
@apply relative z-10 min-h-screen flex flex-col;
}
.content-wrapper {
position: relative;
z-index: 1;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.gradient-overlay {
.gradient-overlay {
@apply flex-1 flex flex-col;
background: linear-gradient(rgba(26, 26, 26, 0.3), rgba(26, 26, 26, 0.7));
flex: 1;
display: flex;
flex-direction: column;
}
}
/* Header styles */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
/* Game sections */
.game-section {
@apply flex-1 flex flex-col p-4;
}
.header-center {
display: flex;
align-items: center;
gap: 15px;
}
.score-header {
@apply flex items-center justify-between mb-4;
}
.logo-icon {
width: 40px;
height: 40px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.score-text {
@apply font-jersey text-3xl text-primary;
}
.logo-title {
font-family: 'Jersey 15', cursive;
font-size: 28px;
color: #e6f4e6;
margin: 0;
}
.play-section {
@apply mb-6;
}
.header-right {
display: flex;
align-items: center;
}
.play-title {
@apply font-jersey text-4xl text-primary text-center;
}
.connect-wallet {
display: flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #057f6d, #024d42);
border: 2px solid #05A38C;
border-radius: 8px;
padding: 8px 16px;
cursor: pointer;
transition: all 0.3s ease;
}
.game-content {
@apply flex flex-1 gap-4;
}
.wallet-text {
font-family: 'Jersey 15', cursive;
font-size: 14px;
color: #e6f4e6;
white-space: nowrap;
}
/* Panoramic viewer */
.pano {
@apply flex-1 rounded-lg bg-background-secondary border-4 border-primary min-h-96;
}
/* Game section */
.game-section {
flex: 1;
padding: 20px 40px;
display: flex;
flex-direction: column;
}
/* Right panel */
.right-panel {
@apply w-80 flex flex-col gap-4;
}
.score-header {
text-align: center;
margin-bottom: 30px;
}
/* Buttons */
.bridge-btn {
@apply bg-primary hover:bg-primary-hover text-white font-jersey text-xl py-3 px-6 rounded-lg transition-colors border-2 border-primary;
}
.score-text {
font-family: 'Jersey 15', cursive;
font-size: 24px;
color: #e6f4e6;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.back-btn {
@apply w-12 h-12 bg-primary hover:bg-primary-hover text-white rounded-lg flex items-center justify-center text-2xl transition-colors;
}
.back-btn {
position: absolute;
top: 20px;
left: 20px;
background: linear-gradient(135deg, #057f6d, #024d42);
border: 2px solid #05A38C;
border-radius: 8px;
padding: 10px;
cursor: pointer;
transition: all 0.3s ease;
}
.check-btn {
@apply w-full bg-primary hover:bg-primary-hover disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-jersey text-lg py-3 rounded-lg transition-colors;
}
.diamond-icon {
width: 24px;
height: 24px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.next-btn {
@apply bg-primary hover:bg-primary-hover text-white font-jersey text-lg py-3 px-8 rounded-lg transition-colors;
}
/* Play section */
.play-section {
text-align: center;
margin-bottom: 40px;
}
/* Modal styles */
.modal {
@apply fixed inset-0 bg-black/60 flex items-center justify-center z-[9999];
}
.play-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
}
.modal-content {
@apply bg-background-secondary p-5 rounded-xl w-[90%] max-w-2xl text-center text-text border-4 border-primary font-jersey text-xl;
}
.play-title {
font-family: 'Jersey 15', cursive;
font-size: 36px;
color: #e6f4e6;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
margin: 0;
}
/* Distance and result text */
.distance-text {
@apply font-jersey text-3xl text-primary;
}
/* Game content */
.game-content {
display: grid;
grid-template-columns: 1fr 300px;
gap: 30px;
flex: 1;
min-height: 500px;
}
/* Instructions */
.instruction-list {
@apply space-y-2 mb-6;
}
.pano {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(5px);
border: 2px solid #05A38C;
position: relative;
min-height: 400px;
}
.instruction {
@apply font-jersey text-lg text-text;
}
.how-title {
font-family: 'Jersey 15', cursive;
font-size: 28px;
color: #e6f4e6;
text-align: center;
margin-bottom: 20px;
}
.instruction-list {
text-align: left;
max-width: 400px;
margin: 0 auto;
}
.instruction {
font-family: 'Jersey 15', cursive;
font-size: 18px;
color: #e6f4e6;
margin-bottom: 10px;
padding: 10px;
background: rgba(5, 163, 140, 0.1);
border-radius: 8px;
border-left: 4px solid #05A38C;
}
/* Right panel */
.right-panel {
display: flex;
flex-direction: column;
gap: 15px;
}
.bridge-btn {
background: linear-gradient(135deg, #057f6d, #024d42);
border: 2px solid #05A38C;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.bridge-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(5, 163, 140, 0.3);
}
.bridge-text {
font-family: 'Jersey 15', cursive;
font-size: 16px;
color: #e6f4e6;
margin: 0;
}
.check-btn {
background: linear-gradient(135deg, #057f6d, #024d42);
border: 2px solid #05A38C;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
margin-top: 20px;
}
.check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.check-text {
font-family: 'Jersey 15', cursive;
font-size: 16px;
color: #e6f4e6;
margin: 0;
}
.disclaimer {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
min-height: 300px;
border: 2px solid #05A38C;
cursor: crosshair;
}
/* Modal styles */
.modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
}
.modal-content {
background-color: #24453D;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
border-radius: 12px;
width: 90%;
max-width: 600px;
text-align: center;
color: #e6f4e6;
border: #05A38C 5px solid;
font-family: 'Jersey 15', cursive;
font-size: 20px;
line-height: 20px;
}
.modal-donate {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #1a1a1a;
padding: 30px;
border-radius: 12px;
text-align: center;
border: #05A38C 5px solid;
font-family: 'Jersey 15', cursive;
color: #e6f4e6;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.username-modal {
max-width: 400px;
width: 90%;
}
.modal-title {
font-family: 'Jersey 15', cursive;
font-size: 24px;
color: #05A38C;
margin-bottom: 10px;
}
.modal-subtitle {
font-family: 'Jersey 15', cursive;
font-size: 16px;
color: #e6f4e6;
margin-bottom: 20px;
}
.username-input {
width: 100%;
padding: 12px;
margin-bottom: 20px;
border: 2px solid #05A38C;
border-radius: 8px;
background-color: #1a1a1a;
color: #e6f4e6;
font-family: 'Jersey 15', cursive;
font-size: 16px;
text-align: center;
}
.username-input:focus {
outline: none;
border-color: #05A38C;
box-shadow: 0 0 0 2px rgba(5, 163, 140, 0.3);
}
.username-submit-btn {
width: 100%;
padding: 12px;
background-color: #05A38C;
color: #fff;
border: none;
border-radius: 8px;
font-family: 'Jersey 15', cursive;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
.username-submit-btn:hover:not(:disabled) {
background-color: #048a76;
}
.username-submit-btn:disabled {
background-color: #666;
cursor: not-allowed;
}
.distance-text {
font-family: 'Jersey 15', cursive;
font-size: 32px;
color: #05A38C;
display: block;
margin: 20px 0;
}
.container {
margin: 20px 0;
height: 300px;
border-radius: 8px;
overflow: hidden;
}
.next-btn {
margin-top: 20px;
background: linear-gradient(135deg, #057f6d, #024d42);
border: #05A38C 5px solid;
border-radius: 12px;
padding: 12px 24px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
min-width: 200px;
}
.next-text {
font-family: 'Jersey 15', cursive;
font-size: 16px;
line-height: 16px;
color: #e6f4e6;
}
.qr {
border-radius: 8px;
margin: 20px 0;
}
/* Responsive design */
@media (max-width: 768px) {
.header {
padding: 15px 20px;
}
.game-section {
padding: 15px 20px;
}
.game-content {
grid-template-columns: 1fr;
gap: 20px;
}
.play-title {
font-size: 28px;
}
.logo-title {
font-size: 20px;
}
.score-text {
font-size: 18px;
}
}
/* Animation for loading */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
.how-title {
@apply font-jersey text-2xl text-primary text-center mb-4;
}
}
+4 -2
View File
@@ -1,5 +1,5 @@
import { Inter } from 'next/font/google'
import Script from 'next/script'
import ReactQueryProvider from '@/lib/react-query'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
@@ -27,7 +27,9 @@ export default function RootLayout({ children }) {
<head>
</head>
<body className={inter.className}>
{children}
<ReactQueryProvider>
{children}
</ReactQueryProvider>
</body>
</html>
)
+2
View File
@@ -0,0 +1,2 @@
export { useMapillaryImage } from './useMapillary';
export { useScoring } from './useScoring';
+18
View File
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
export function useMapillaryImage(city) {
return useQuery({
queryKey: ['mapillary', city],
queryFn: async () => {
const response = await fetch(`/api/mapillary?location=${city}`);
if (!response.ok) {
throw new Error('Failed to fetch image');
}
return response.json();
},
enabled: !!city,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
+21
View File
@@ -0,0 +1,21 @@
import { useMutation } from '@tanstack/react-query';
export function useScoring() {
return useMutation({
mutationFn: async (scoreData) => {
const response = await fetch('/api/scoring', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(scoreData),
});
if (!response.ok) {
throw new Error('Failed to submit score');
}
return response.json();
},
retry: 2,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 10000),
});
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export default function ReactQueryProvider({ children }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+31
View File
@@ -0,0 +1,31 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
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: {
primary: {
DEFAULT: '#05A38C',
hover: '#048a76',
},
background: {
DEFAULT: '#1a1a1a',
secondary: '#24453D',
},
text: {
DEFAULT: '#e6f4e6',
secondary: '#ccc',
}
},
fontFamily: {
'jersey': ['Jersey 15', 'cursive'],
'courier': ['Courier', 'monospace'],
},
},
},
plugins: [],
}