Initial commit

This commit is contained in:
Brian McGonagill 2026-03-18 09:02:21 -05:00
commit b3a51a4115
10336 changed files with 2381973 additions and 0 deletions

222
README.md Normal file
View file

@ -0,0 +1,222 @@
# Treasure Trails
An online scavenger hunt application where Game Masters create and manage hunts, Teams compete to complete clues, and Spectators can follow along in real-time.
## Features
- **Game Master Dashboard**: Create scavenger hunts with legs/clues, manage live games, view team progress
- **Team Interface**: Mobile-friendly gameplay with clues, map navigation, photo submissions, and real-time chat
- **Spectator View**: Public dashboard showing team positions and leaderboard
- **Real-time Updates**: Live tracking via WebSockets (Socket.io)
- **OpenStreetMap Integration**: All maps use OpenStreetMap data
## Tech Stack
| Component | Technology |
|-----------|------------|
| Frontend | Vue 3 + TypeScript + Vite |
| Backend | Node.js + Express |
| Real-time | Socket.io |
| Database | PostgreSQL + Prisma |
| Maps | Leaflet + OpenStreetMap |
## Prerequisites
- Node.js 20+ (for local development)
- Docker & Docker Compose
## Quick Start with Docker
The easiest way to run the application:
```bash
# Start all services (PostgreSQL, Backend, Frontend)
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
The application will be available at:
- **Frontend**: http://localhost:5173
- **Backend API**: http://localhost:3001
## Local Development
### 1. Clone and Install Dependencies
```bash
# Backend
cd backend
npm install
# Frontend
cd ../frontend
npm install
```
### 2. Set Up Database
Create a PostgreSQL database:
```bash
# Connect to PostgreSQL
psql -U postgres
# Create database
CREATE DATABASE treasure_trails;
```
Update the database connection in `backend/.env`:
```
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/treasure_trails?schema=public"
```
### 3. Initialize Database Schema
```bash
cd backend
npx prisma generate
npx prisma db push
```
### 4. Run the Backend
```bash
cd backend
npm run dev
```
The backend API will run on http://localhost:3001
### 5. Run the Frontend
```bash
cd frontend
npm run dev
```
The frontend will run on http://localhost:5173
## Usage
### As a Game Master
1. Register a new account
2. Go to Dashboard and click "Create New Game"
3. Fill in game details and click on the map to set the treasure location
4. Click "Create Game" to save as draft
5. Click "Edit Game" to add legs/clues
6. Once legs are added, click "Publish Game" to make it live
7. Use the "Live Dashboard" to monitor teams
### As a Team Member
1. Register or login
2. Browse public games or use an invite link
3. Join a game by creating a team (3-5 members) or joining an existing team
4. During the game, use the "Play" interface to:
- View current clue
- See your position on the map
- Submit photo proof
- Chat with other teams and the Game Master
### As a Spectator
1. Navigate to any public live game
2. Click "Spectate" to view:
- Live map with all team positions
- Leaderboard showing progress
## Project Structure
```
/home/brian/Development/scavenge
├── docker-compose.yml # Docker Compose configuration
├── backend/
│ ├── Dockerfile # Backend container
│ ├── prisma/
│ │ └── schema.prisma # Database schema
│ ├── src/
│ │ ├── index.ts # Server entry point
│ │ ├── middleware/
│ │ │ └── auth.ts # JWT authentication
│ │ ├── routes/
│ │ │ ├── auth.ts # Auth endpoints
│ │ │ ├── games.ts # Game CRUD
│ │ │ ├── legs.ts # Leg/clue management
│ │ │ ├── teams.ts # Team management
│ │ │ └── upload.ts # File uploads
│ │ └── socket/
│ │ └── index.ts # Socket.io handlers
│ ├── .env # Environment variables
│ └── package.json
├── frontend/
│ ├── Dockerfile # Frontend container
│ ├── nginx.conf # Nginx configuration
│ ├── src/
│ │ ├── pages/ # Vue page components
│ │ ├── stores/ # Pinia state management
│ │ ├── services/ # API service
│ │ ├── types/ # TypeScript types
│ │ ├── router/ # Vue Router config
│ │ ├── App.vue
│ │ └── main.ts
│ └── package.json
├── application_requirements.md # Original requirements
└── README.md # This file
```
## API Endpoints
### Authentication
- `POST /api/auth/register` - Create account
- `POST /api/auth/login` - Login
- `GET /api/auth/me` - Get current user
### Games
- `GET /api/games` - List public games
- `GET /api/games/my-games` - List user's games
- `POST /api/games` - Create game
- `GET /api/games/:id` - Get game details
- `PUT /api/games/:id` - Update game
- `POST /api/games/:id/publish` - Publish game
- `GET /api/games/:id/invite` - Get invite code
### Legs
- `POST /api/legs/game/:gameId` - Add leg
- `PUT /api/legs/:legId` - Update leg
- `DELETE /api/legs/:legId` - Delete leg
- `POST /api/legs/:legId/photo` - Submit photo
### Teams
- `GET /api/teams/game/:gameId` - List teams
- `POST /api/teams/game/:gameId` - Create team
- `POST /api/teams/:teamId/join` - Join team
- `POST /api/teams/:teamId/advance` - GM advances team
- `POST /api/teams/:teamId/deduct` - GM applies time penalty
- `POST /api/teams/:teamId/disqualify` - GM disqualifies team
## Socket Events
- `join-game` - Join a game's socket room
- `team-location` - Broadcast team position
- `chat-message` - Send/receive chat
- `team-advanced` - Notify team advancement
## Environment Variables
### Backend (.env)
```
DATABASE_URL="postgresql://..."
JWT_SECRET="your-secret-key"
PORT=3001
```
## License
MIT

171
application_requirements.md Normal file
View file

@ -0,0 +1,171 @@
# Treasure Trails - Development Stories
This document outlines the Epics and User Stories for the "Treasure Trails" online scavenger hunt application.
Base requirements:
1. All libraries and dependecies should be open source.
2. We will use OpenStreetMaps for all mapping.
3. This is meant to be a fun game.
## I. Epic: Game Master Functionality - Design, Manage, and Oversee Hunts
**Goal:** Enable users to design, manage, and oversee scavenger hunts.
### Story 1: Create a Scavenger Hunt
**As a Game Master, I need to create a scavenger hunt with configurable details so that I can design engaging and unique experiences.**
* **Requirements:**
* Ability to name the scavenger hunt.
* Ability to set a start date and time.
* Ability to choose between public and private visibility.
* Ability to add a description and prize details.
* Ability to define a location and search radius on OpenStreetMap for treasure locations.
* Ability to create and sequence "legs" or clues with descriptions and conditions (e.g., photo proof, purchase, etc.).
* Ability to see individual leg distances in km or miles.
* Ability to see total sum of different routes (combinations of legs) distnace in km or miles.
* Ability to set time limits per leg.
* Ability to set a time deduction penalty for navigation warnings.
* **Acceptance Criteria:**
* All fields are validated (e.g., name required, date in the future).
* OpenStreetMap integration works for location selection and searching.
* Leg creation is intuitive and supports different condition types.
* Game master can save a draft game.
* **Testing Steps:**
1. Create a new scavenger hunt with valid data.
2. Create a new scavenger hunt with invalid data (e.g., missing name, past date).
3. Verify that OpenStreetMap functionality allows for accurate location selection.
4. Verify leg creation and sequencing functionality.
* **Negative Testing:**
1. Attempt to create a hunt with blank fields. Verify appropriate error messages.
2. Attempt to save a hunt with a start date in the past. Verify an error.
3. Simulate slow network conditions during save. Ensure proper loading indicators and error handling.
### Story 2: Manage a Live Game (Game Master Dashboard)
**As a Game Master, I need a real-time dashboard to monitor and control a live scavenger hunt so that I can efficiently guide teams and ensure fair play.**
* **Requirements:**
* Display a list of participating teams with their current clue/location.
* Real-time map view showing team positions (approximate within 500m).
* Ability to view chat messages between teams and the Game Master.
* Ability to view photo submissions as proof of completion.
* Ability to advance teams to the next clue (individually or multiple teams).
* Ability to manually apply time deductions to teams.
* Ability to declare a winning team.
* Ability to disqualify teams.
* Display a leaderboard of clues completed.
* **Acceptance Criteria:**
* Dashboard updates in near real-time.
* Map icons clearly represent teams and their location.
* Time deduction functionality applies the penalty correctly, with countdown timer visible.
* Disqualification removes team from the game and displays their status.
* **Testing Steps:**
1. Simulate a live game and verify all dashboard elements update correctly.
2. Verify advancing teams and applying time deductions.
3. Verify disqualification functionality.
* **Negative Testing:**
1. Simulate high game participation to test dashboard performance under load.
2. Disconnect/reconnect the dashboard. Verify data is recovered correctly.
### Story 3: Public/Private Game Visibility
**As a Game Master, I need to control the visibility of my scavenger hunt so that I can cater to both open and invite-only experiences.**
* **Requirements:**
* Option to set game to 'Public' or 'Private' during creation.
* Public games listed on a searchable/filterable game list.
* Private games accessible only via invite.
* Option for the game master to publish a private game for public viewing after the game has started.
* **Acceptance Criteria:**
* Public games are displayed on the list.
* Private games are not shown on the list and require an invite link.
* Invite links are unique and secure.
* **Testing Steps:**
1. Create a public game and verify it appears on the game list.
2. Create a private game and confirm it's not visible on the list.
3. Create a private game and verify access with an invite link.
* **Negative Testing:**
1. Attempt to access a private game without an invite.
2. Try to modify game visibility without proper authorization.
## II. Epic: Team Member Functionality - Participate in Hunts
**Goal:** Allow users to join and participate in scavenger hunts.
### Story 4: Join a Scavenger Hunt & Team Formation
**As a User, I need to be able to find, join, and create a team for a scavenger hunt so that I can participate with friends or strangers.**
* **Requirements:**
* Browse and search for public scavenger hunts.
* Receive invites to private scavenger hunts.
* Create a team of 3-5 members.
* Ability for team members to elect a captain.
* View a list of games you will be a part of, including their scheduled date and time if any.
* Have a simple way to join a game when it starts if you are the team captain.
* **Acceptance Criteria:**
* Searching and filtering for public hunts works correctly.
* Invite links grant access to the correct game.
* Team creation enforces the size limits.
* Captain election is managed via the device during game startup.
* **Testing Steps:**
1. Search for a public game and join.
2. Receive an invite link and verify access.
3. Create a team and add/remove members.
4. Verify the captain election process.
* **Negative Testing:**
1. Attempt to join a hunt with an invalid invite link.
2. Try to create a team with fewer or more than the allowed number of members.
### Story 5: Team Device Interface During Game
**As a Team Captain, I need a mobile-friendly interface with real-time updates and tools to complete the hunt so that I can navigate and interact with the game effectively.**
* **Requirements:**
* Display the current clue.
* Display the team's position on the map.
* Display approximate positions of other teams (within 500m).
* Photo submission button.
* Chat functionality to communicate with the Game Master and other teams.
* Navigation warning system if the browser tab is closed or a new tab opened.
* **Acceptance Criteria:**
* Map displays accurate locations.
* Photo submissions are clear and easy to send.
* Chat messages are delivered in real-time.
* Navigation warnings are displayed appropriately with time deduction information.
* **Testing Steps:**
1. Verify all interface elements display correctly.
2. Test photo submission functionality.
3. Test chat functionality.
4. Simulate browser tab closure/new tab opening, and verify the warning message.
* **Negative Testing:**
1. Simulate slow network conditions.
2. Attempt to submit a corrupted photo.
## III. Epic: Public Spectator Functionality - View Live Progress
**Goal:** Enable anyone to view the progress of a public scavenger hunt.
### Story 6: Public Game Spectator Dashboard
**As a Spectator, I need to view the live progress of a public scavenger hunt so that I can follow along and experience the excitement.**
* **Requirements:**
* Display a map showing team locations (same approximation as teams have).
* Display a leaderboard of clues completed.
* Display team names and statuses.
* **Acceptance Criteria:**
* The spectator dashboard accurately reflects the real-time game progress.
* **Testing Steps:**
1. View a public game as a spectator.
2. Verify that the map and leaderboard update dynamically.
* **Negative Testing:**
1. Attempt to access a spectator dashboard of a private game.
**Additional Notes:**
* **Technology Stack:** Assumed web-based architecture (React/Angular/Vue.js, Node.js/Python/Ruby). Requires integration with OpenStreetMaps API.
* **Scalability:** Prioritize scalability for the Game Master dashboard and real-time updates (consider WebSockets).
* **Security:** Implement robust authentication and authorization.
* **Mobile-First Design:** Prioritize mobile usability for the Team Device Interface.

4
backend/.env.example Normal file
View file

@ -0,0 +1,4 @@
# For local development
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/treasure_trails?schema=public"
JWT_SECRET="treasure-trails-jwt-secret-change-in-production"
PORT=3001

5
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

17
backend/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM node:20-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
EXPOSE 3001
CMD ["sh", "-c", "npx prisma db push && npm run dev"]

2
backend/dist/index.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
import { PrismaClient } from '@prisma/client';
export declare const prisma: PrismaClient<import(".prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;

46
backend/dist/index.js vendored Normal file
View file

@ -0,0 +1,46 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.prisma = void 0;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const http_1 = require("http");
const socket_io_1 = require("socket.io");
const client_1 = require("@prisma/client");
const auth_js_1 = __importDefault(require("./routes/auth.js"));
const games_js_1 = __importDefault(require("./routes/games.js"));
const teams_js_1 = __importDefault(require("./routes/teams.js"));
const legs_js_1 = __importDefault(require("./routes/legs.js"));
const upload_js_1 = __importDefault(require("./routes/upload.js"));
const index_js_1 = __importDefault(require("./socket/index.js"));
const app = (0, express_1.default)();
const httpServer = (0, http_1.createServer)(app);
const io = new socket_io_1.Server(httpServer, {
cors: {
origin: ['http://localhost:5173', 'http://localhost:3000'],
methods: ['GET', 'POST']
}
});
exports.prisma = new client_1.PrismaClient();
app.use((0, cors_1.default)());
app.use(express_1.default.json());
app.use('/uploads', express_1.default.static('uploads'));
app.use('/api/auth', auth_js_1.default);
app.use('/api/games', games_js_1.default);
app.use('/api/teams', teams_js_1.default);
app.use('/api/legs', legs_js_1.default);
app.use('/api/upload', upload_js_1.default);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
(0, index_js_1.default)(io);
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
process.on('SIGINT', async () => {
await exports.prisma.$disconnect();
process.exit();
});

10
backend/dist/middleware/auth.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
import { Request, Response, NextFunction } from 'express';
export interface AuthRequest extends Request {
user?: {
id: string;
email: string;
name: string;
};
}
export declare const authenticate: (req: AuthRequest, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>>>;
export declare const optionalAuth: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>;

54
backend/dist/middleware/auth.js vendored Normal file
View file

@ -0,0 +1,54 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.optionalAuth = exports.authenticate = void 0;
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const index_js_1 = require("../index.js");
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
const authenticate = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
const user = await index_js_1.prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
});
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
}
catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
};
exports.authenticate = authenticate;
const optionalAuth = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.split(' ')[1];
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
const user = await index_js_1.prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
});
if (user) {
req.user = user;
}
next();
}
catch {
next();
}
};
exports.optionalAuth = optionalAuth;

2
backend/dist/routes/auth.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;

83
backend/dist/routes/auth.js vendored Normal file
View file

@ -0,0 +1,83 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const index_js_1 = require("../index.js");
const router = (0, express_1.Router)();
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required' });
}
const existingUser = await index_js_1.prisma.user.findUnique({ where: { email } });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const passwordHash = await bcryptjs_1.default.hash(password, 10);
const user = await index_js_1.prisma.user.create({
data: { email, passwordHash, name }
});
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
});
}
catch (error) {
console.error('Register error:', error);
res.status(500).json({ error: 'Failed to register' });
}
});
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
const user = await index_js_1.prisma.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const validPassword = await bcryptjs_1.default.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
});
}
catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Failed to login' });
}
});
router.get('/me', async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
const user = await index_js_1.prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true, createdAt: true }
});
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
res.json(user);
}
catch {
res.status(401).json({ error: 'Invalid token' });
}
});
exports.default = router;

2
backend/dist/routes/games.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;

248
backend/dist/routes/games.js vendored Normal file
View file

@ -0,0 +1,248 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const index_js_1 = require("../index.js");
const auth_js_1 = require("../middleware/auth.js");
const uuid_1 = require("uuid");
const router = (0, express_1.Router)();
router.get('/', async (req, res) => {
try {
const { search, status } = req.query;
const where = {
visibility: 'PUBLIC'
};
if (status) {
where.status = status;
}
if (search) {
where.name = { contains: search, mode: 'insensitive' };
}
const games = await index_js_1.prisma.game.findMany({
where,
include: {
gameMaster: { select: { id: true, name: true } },
_count: { select: { teams: true, legs: true } }
},
orderBy: { createdAt: 'desc' }
});
res.json(games);
}
catch (error) {
console.error('List games error:', error);
res.status(500).json({ error: 'Failed to list games' });
}
});
router.get('/my-games', auth_js_1.authenticate, async (req, res) => {
try {
const games = await index_js_1.prisma.game.findMany({
where: { gameMasterId: req.user.id },
include: {
_count: { select: { teams: true, legs: true } }
},
orderBy: { createdAt: 'desc' }
});
res.json(games);
}
catch (error) {
console.error('My games error:', error);
res.status(500).json({ error: 'Failed to get games' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const game = await index_js_1.prisma.game.findUnique({
where: { id },
include: {
gameMaster: { select: { id: true, name: true } },
legs: { orderBy: { sequenceNumber: 'asc' } },
teams: {
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
currentLeg: true
}
}
}
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
const isOwner = req.user?.id === game.gameMasterId;
if (game.visibility === 'PRIVATE' && !isOwner) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(game);
}
catch (error) {
console.error('Get game error:', error);
res.status(500).json({ error: 'Failed to get game' });
}
});
router.post('/', auth_js_1.authenticate, async (req, res) => {
try {
const { name, description, prizeDetails, visibility, startDate, locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
if (startDate && new Date(startDate) < new Date()) {
return res.status(400).json({ error: 'Start date must be in the future' });
}
const inviteCode = (0, uuid_1.v4)().slice(0, 8);
const game = await index_js_1.prisma.game.create({
data: {
name,
description,
prizeDetails,
visibility: visibility || 'PUBLIC',
startDate: startDate ? new Date(startDate) : null,
locationLat,
locationLng,
searchRadius,
timeLimitPerLeg,
timeDeductionPenalty,
gameMasterId: req.user.id,
inviteCode
}
});
res.json(game);
}
catch (error) {
console.error('Create game error:', error);
res.status(500).json({ error: 'Failed to create game' });
}
});
router.put('/:id', auth_js_1.authenticate, async (req, res) => {
try {
const { id } = req.params;
const { name, description, prizeDetails, visibility, startDate, locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty, status } = req.body;
const game = await index_js_1.prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await index_js_1.prisma.game.update({
where: { id },
data: {
name,
description,
prizeDetails,
visibility,
startDate: startDate ? new Date(startDate) : undefined,
locationLat,
locationLng,
searchRadius,
timeLimitPerLeg,
timeDeductionPenalty,
status
}
});
res.json(updated);
}
catch (error) {
console.error('Update game error:', error);
res.status(500).json({ error: 'Failed to update game' });
}
});
router.delete('/:id', auth_js_1.authenticate, async (req, res) => {
try {
const { id } = req.params;
const game = await index_js_1.prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
await index_js_1.prisma.game.delete({ where: { id } });
res.json({ message: 'Game deleted' });
}
catch (error) {
console.error('Delete game error:', error);
res.status(500).json({ error: 'Failed to delete game' });
}
});
router.post('/:id/publish', auth_js_1.authenticate, async (req, res) => {
try {
const { id } = req.params;
const game = await index_js_1.prisma.game.findUnique({
where: { id },
include: { legs: true }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
if (game.legs.length === 0) {
return res.status(400).json({ error: 'Game must have at least one leg' });
}
const updated = await index_js_1.prisma.game.update({
where: { id },
data: { status: 'LIVE' }
});
res.json(updated);
}
catch (error) {
console.error('Publish game error:', error);
res.status(500).json({ error: 'Failed to publish game' });
}
});
router.post('/:id/end', auth_js_1.authenticate, async (req, res) => {
try {
const { id } = req.params;
const game = await index_js_1.prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await index_js_1.prisma.game.update({
where: { id },
data: { status: 'ENDED' }
});
res.json(updated);
}
catch (error) {
console.error('End game error:', error);
res.status(500).json({ error: 'Failed to end game' });
}
});
router.get('/:id/invite', async (req, res) => {
try {
const { id } = req.params;
const game = await index_js_1.prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (!game.inviteCode) {
return res.status(404).json({ error: 'No invite code' });
}
res.json({ inviteCode: game.inviteCode });
}
catch (error) {
console.error('Get invite error:', error);
res.status(500).json({ error: 'Failed to get invite code' });
}
});
router.get('/invite/:code', async (req, res) => {
try {
const { code } = req.params;
const game = await index_js_1.prisma.game.findUnique({
where: { inviteCode: code },
include: { gameMaster: { select: { id: true, name: true } } }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
res.json(game);
}
catch (error) {
console.error('Get game by invite error:', error);
res.status(500).json({ error: 'Failed to get game' });
}
});
exports.default = router;

2
backend/dist/routes/legs.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;

148
backend/dist/routes/legs.js vendored Normal file
View file

@ -0,0 +1,148 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const index_js_1 = require("../index.js");
const auth_js_1 = require("../middleware/auth.js");
const router = (0, express_1.Router)();
router.get('/game/:gameId', auth_js_1.authenticate, async (req, res) => {
try {
const { gameId } = req.params;
const game = await index_js_1.prisma.game.findUnique({
where: { id: gameId },
include: { legs: { orderBy: { sequenceNumber: 'asc' } } }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
res.json(game.legs);
}
catch (error) {
console.error('Get legs error:', error);
res.status(500).json({ error: 'Failed to get legs' });
}
});
router.post('/game/:gameId', auth_js_1.authenticate, async (req, res) => {
try {
const { gameId } = req.params;
const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body;
const game = await index_js_1.prisma.game.findUnique({
where: { id: gameId },
include: { legs: true }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const maxSequence = game.legs.reduce((max, leg) => Math.max(max, leg.sequenceNumber), 0);
const leg = await index_js_1.prisma.leg.create({
data: {
gameId,
sequenceNumber: maxSequence + 1,
description,
conditionType: conditionType || 'photo',
conditionDetails,
locationLat,
locationLng,
timeLimit
}
});
res.json(leg);
}
catch (error) {
console.error('Create leg error:', error);
res.status(500).json({ error: 'Failed to create leg' });
}
});
router.put('/:legId', auth_js_1.authenticate, async (req, res) => {
try {
const { legId } = req.params;
const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body;
const leg = await index_js_1.prisma.leg.findUnique({
where: { id: legId },
include: { game: true }
});
if (!leg) {
return res.status(404).json({ error: 'Leg not found' });
}
if (leg.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await index_js_1.prisma.leg.update({
where: { id: legId },
data: {
description,
conditionType,
conditionDetails,
locationLat,
locationLng,
timeLimit
}
});
res.json(updated);
}
catch (error) {
console.error('Update leg error:', error);
res.status(500).json({ error: 'Failed to update leg' });
}
});
router.delete('/:legId', auth_js_1.authenticate, async (req, res) => {
try {
const { legId } = req.params;
const leg = await index_js_1.prisma.leg.findUnique({
where: { id: legId },
include: { game: true }
});
if (!leg) {
return res.status(404).json({ error: 'Leg not found' });
}
if (leg.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
await index_js_1.prisma.leg.delete({ where: { id: legId } });
res.json({ message: 'Leg deleted' });
}
catch (error) {
console.error('Delete leg error:', error);
res.status(500).json({ error: 'Failed to delete leg' });
}
});
router.post('/:legId/photo', auth_js_1.authenticate, async (req, res) => {
try {
const { legId } = req.params;
const { teamId, photoUrl } = req.body;
if (!teamId || !photoUrl) {
return res.status(400).json({ error: 'Team ID and photo URL are required' });
}
const leg = await index_js_1.prisma.leg.findUnique({
where: { id: legId },
include: { game: true }
});
if (!leg) {
return res.status(404).json({ error: 'Leg not found' });
}
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId }
});
if (!team || team.gameId !== leg.gameId) {
return res.status(403).json({ error: 'Team not in this game' });
}
const submission = await index_js_1.prisma.photoSubmission.create({
data: {
teamId,
legId,
photoUrl
}
});
res.json(submission);
}
catch (error) {
console.error('Submit photo error:', error);
res.status(500).json({ error: 'Failed to submit photo' });
}
});
exports.default = router;

2
backend/dist/routes/teams.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;

290
backend/dist/routes/teams.js vendored Normal file
View file

@ -0,0 +1,290 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const index_js_1 = require("../index.js");
const auth_js_1 = require("../middleware/auth.js");
const router = (0, express_1.Router)();
router.get('/game/:gameId', async (req, res) => {
try {
const { gameId } = req.params;
const teams = await index_js_1.prisma.team.findMany({
where: { gameId },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } },
currentLeg: true
},
orderBy: { createdAt: 'asc' }
});
res.json(teams);
}
catch (error) {
console.error('Get teams error:', error);
res.status(500).json({ error: 'Failed to get teams' });
}
});
router.post('/game/:gameId', auth_js_1.authenticate, async (req, res) => {
try {
const { gameId } = req.params;
const { name } = req.body;
const game = await index_js_1.prisma.game.findUnique({
where: { id: gameId },
include: { teams: true }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.status !== 'DRAFT' && game.status !== 'LIVE') {
return res.status(400).json({ error: 'Cannot join game at this time' });
}
const existingMember = await index_js_1.prisma.teamMember.findFirst({
where: {
userId: req.user.id,
team: { gameId }
}
});
if (existingMember) {
return res.status(400).json({ error: 'Already in a team for this game' });
}
const team = await index_js_1.prisma.team.create({
data: {
gameId,
name,
captainId: req.user.id
}
});
await index_js_1.prisma.teamMember.create({
data: {
teamId: team.id,
userId: req.user.id
}
});
const created = await index_js_1.prisma.team.findUnique({
where: { id: team.id },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } }
}
});
res.json(created);
}
catch (error) {
console.error('Create team error:', error);
res.status(500).json({ error: 'Failed to create team' });
}
});
router.post('/:teamId/join', auth_js_1.authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId },
include: { game: true, members: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.members.length >= 5) {
return res.status(400).json({ error: 'Team is full (max 5 members)' });
}
const existingMember = await index_js_1.prisma.teamMember.findFirst({
where: {
userId: req.user.id,
teamId
}
});
if (existingMember) {
return res.status(400).json({ error: 'Already in this team' });
}
const gameMember = await index_js_1.prisma.teamMember.findFirst({
where: {
userId: req.user.id,
team: { gameId: team.gameId }
}
});
if (gameMember) {
return res.status(400).json({ error: 'Already in another team for this game' });
}
await index_js_1.prisma.teamMember.create({
data: {
teamId,
userId: req.user.id
}
});
res.json({ message: 'Joined team successfully' });
}
catch (error) {
console.error('Join team error:', error);
res.status(500).json({ error: 'Failed to join team' });
}
});
router.post('/:teamId/leave', auth_js_1.authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.captainId === req.user.id) {
return res.status(400).json({ error: 'Captain cannot leave the team' });
}
await index_js_1.prisma.teamMember.deleteMany({
where: {
teamId,
userId: req.user.id
}
});
res.json({ message: 'Left team successfully' });
}
catch (error) {
console.error('Leave team error:', error);
res.status(500).json({ error: 'Failed to leave team' });
}
});
router.post('/:teamId/advance', auth_js_1.authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId },
include: {
game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } },
currentLeg: true
}
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const legs = team.game.legs;
const currentLeg = team.currentLeg;
let nextLeg = null;
if (currentLeg) {
const currentIndex = legs.findIndex((l) => l.id === currentLeg.id);
if (currentIndex < legs.length - 1) {
nextLeg = legs[currentIndex + 1];
}
}
else if (legs.length > 0) {
nextLeg = legs[0];
}
const updated = await index_js_1.prisma.team.update({
where: { id: teamId },
data: {
currentLegId: nextLeg?.id || null,
status: nextLeg ? 'ACTIVE' : 'FINISHED'
},
include: {
members: { include: { user: { select: { id: true, name: true } } } },
currentLeg: true
}
});
res.json(updated);
}
catch (error) {
console.error('Advance team error:', error);
res.status(500).json({ error: 'Failed to advance team' });
}
});
router.post('/:teamId/deduct', auth_js_1.authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const { seconds } = req.body;
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId },
include: { game: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const deduction = seconds || team.game.timeDeductionPenalty || 60;
const updated = await index_js_1.prisma.team.update({
where: { id: teamId },
data: { totalTimeDeduction: { increment: deduction } }
});
res.json(updated);
}
catch (error) {
console.error('Deduct time error:', error);
res.status(500).json({ error: 'Failed to deduct time' });
}
});
router.post('/:teamId/disqualify', auth_js_1.authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId },
include: { game: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await index_js_1.prisma.team.update({
where: { id: teamId },
data: { status: 'DISQUALIFIED' }
});
res.json(updated);
}
catch (error) {
console.error('Disqualify team error:', error);
res.status(500).json({ error: 'Failed to disqualify team' });
}
});
router.post('/:teamId/location', auth_js_1.authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const { lat, lng } = req.body;
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
const member = await index_js_1.prisma.teamMember.findFirst({
where: { teamId, userId: req.user.id }
});
if (!member && team.captainId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await index_js_1.prisma.team.update({
where: { id: teamId },
data: { lat, lng }
});
res.json(updated);
}
catch (error) {
console.error('Update location error:', error);
res.status(500).json({ error: 'Failed to update location' });
}
});
router.get('/:teamId', auth_js_1.authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await index_js_1.prisma.team.findUnique({
where: { id: teamId },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } },
currentLeg: true,
game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } }
}
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
res.json(team);
}
catch (error) {
console.error('Get team error:', error);
res.status(500).json({ error: 'Failed to get team' });
}
});
exports.default = router;

2
backend/dist/routes/upload.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;

47
backend/dist/routes/upload.js vendored Normal file
View file

@ -0,0 +1,47 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const multer_1 = __importDefault(require("multer"));
const path_1 = __importDefault(require("path"));
const uuid_1 = require("uuid");
const auth_js_1 = require("../middleware/auth.js");
const router = (0, express_1.Router)();
const storage = multer_1.default.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
const ext = path_1.default.extname(file.originalname);
cb(null, `${(0, uuid_1.v4)()}${ext}`);
}
});
const upload = (0, multer_1.default)({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path_1.default.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
return cb(null, true);
}
cb(new Error('Only image files are allowed'));
}
});
router.post('/upload', auth_js_1.authenticate, upload.single('photo'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const url = `/uploads/${req.file.filename}`;
res.json({ url });
}
catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Failed to upload file' });
}
});
exports.default = router;

2
backend/dist/socket/index.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
import { Server } from 'socket.io';
export default function setupSocket(io: Server): Server<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>;

54
backend/dist/socket/index.js vendored Normal file
View file

@ -0,0 +1,54 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = setupSocket;
const index_js_1 = require("../index.js");
function setupSocket(io) {
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
socket.on('join-game', async (gameId) => {
socket.join(`game:${gameId}`);
console.log(`Socket ${socket.id} joined game:${gameId}`);
});
socket.on('leave-game', (gameId) => {
socket.leave(`game:${gameId}`);
});
socket.on('team-location', async (data) => {
await index_js_1.prisma.team.update({
where: { id: data.teamId },
data: { lat: data.lat, lng: data.lng }
});
io.to(`game:${data.gameId}`).emit('team-location', {
teamId: data.teamId,
lat: data.lat,
lng: data.lng
});
});
socket.on('chat-message', async (data) => {
const chatMessage = await index_js_1.prisma.chatMessage.create({
data: {
gameId: data.gameId,
teamId: data.teamId,
userId: data.userId,
message: data.message
}
});
io.to(`game:${data.gameId}`).emit('chat-message', {
id: chatMessage.id,
teamId: data.teamId,
userId: data.userId,
userName: data.userName,
message: data.message,
sentAt: chatMessage.sentAt
});
});
socket.on('team-advanced', async (data) => {
io.to(`game:${data.gameId}`).emit('team-advanced', {
teamId: data.teamId
});
});
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
return io;
}

2335
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

42
backend/package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "treasure-trails-backend",
"version": "1.0.0",
"main": "src/index.ts",
"scripts": {
"dev": "nodemon --exec 'npx ts-node --transpile-only src/index.ts'",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "Treasure Trails Backend API",
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"pg": "^8.14.0",
"socket.io": "^4.8.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.21",
"@types/uuid": "^10.0.0",
"nodemon": "^3.1.9",
"prisma": "^5.22.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}
}

View file

@ -0,0 +1,128 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
games Game[] @relation("GameMaster")
teams TeamMember[]
captainOf Team? @relation("TeamCaptain")
chatMessages ChatMessage[]
}
model Game {
id String @id @default(uuid())
name String
description String?
prizeDetails String?
visibility Visibility @default(PUBLIC)
startDate DateTime?
locationLat Float?
locationLng Float?
searchRadius Float?
timeLimitPerLeg Int?
timeDeductionPenalty Int?
status GameStatus @default(DRAFT)
inviteCode String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
gameMasterId String
gameMaster User @relation("GameMaster", fields: [gameMasterId], references: [id])
legs Leg[]
teams Team[]
chatMessages ChatMessage[]
}
model Leg {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
sequenceNumber Int
description String
conditionType String @default("photo")
conditionDetails String?
locationLat Float?
locationLng Float?
timeLimit Int?
teams Team[]
}
model Team {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
name String
captainId String? @unique
captain User? @relation("TeamCaptain", fields: [captainId], references: [id])
currentLegId String?
currentLeg Leg? @relation(fields: [currentLegId], references: [id])
status TeamStatus @default(ACTIVE)
totalTimeDeduction Int @default(0)
lat Float?
lng Float?
rank Int?
createdAt DateTime @default(now())
members TeamMember[]
photoSubmissions PhotoSubmission[]
chatMessages ChatMessage[]
}
model TeamMember {
id String @id @default(uuid())
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
joinedAt DateTime @default(now())
@@unique([teamId, userId])
}
model PhotoSubmission {
id String @id @default(uuid())
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
legId String
photoUrl String
approved Boolean @default(false)
submittedAt DateTime @default(now())
}
model ChatMessage {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
teamId String?
team Team? @relation(fields: [teamId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
message String
sentAt DateTime @default(now())
}
enum Visibility {
PUBLIC
PRIVATE
}
enum GameStatus {
DRAFT
LIVE
ENDED
}
enum TeamStatus {
ACTIVE
DISQUALIFIED
FINISHED
}

49
backend/src/index.ts Normal file
View file

@ -0,0 +1,49 @@
import express from 'express';
import cors from 'cors';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { PrismaClient } from '@prisma/client';
import authRoutes from './routes/auth';
import gameRoutes from './routes/games';
import teamRoutes from './routes/teams';
import legRoutes from './routes/legs';
import uploadRoutes from './routes/upload';
import setupSocket from './socket/index';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: ['http://localhost:5173', 'http://localhost:3000'],
methods: ['GET', 'POST']
}
});
export const prisma = new PrismaClient();
app.use(cors());
app.use(express.json());
app.use('/uploads', express.static('uploads'));
app.use('/api/auth', authRoutes);
app.use('/api/games', gameRoutes);
app.use('/api/teams', teamRoutes);
app.use('/api/legs', legRoutes);
app.use('/api/upload', uploadRoutes);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
setupSocket(io);
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
process.on('SIGINT', async () => {
await prisma.$disconnect();
process.exit();
});

View file

@ -0,0 +1,63 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../index';
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
export interface AuthRequest extends Request {
user?: {
id: string;
email: string;
name: string;
};
}
export const authenticate = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
});
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
};
export const optionalAuth = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
});
if (user) {
req.user = user;
}
next();
} catch {
next();
}
};

View file

@ -0,0 +1,94 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../index';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
router.post('/register', async (req: Request, res: Response) => {
try {
const { email, password, name } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required' });
}
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { email, passwordHash, name }
});
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
});
} catch (error) {
console.error('Register error:', error);
res.status(500).json({ error: 'Failed to register' });
}
});
router.post('/login', async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Failed to login' });
}
});
router.get('/me', async (req: Request, res: Response) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true, createdAt: true }
});
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
res.json(user);
} catch {
res.status(401).json({ error: 'Invalid token' });
}
});
export default router;

294
backend/src/routes/games.ts Normal file
View file

@ -0,0 +1,294 @@
import { Router, Response } from 'express';
import { prisma } from '../index';
import { authenticate, AuthRequest } from '../middleware/auth';
import { v4 as uuidv4 } from 'uuid';
const router = Router();
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { search, status } = req.query;
const where: any = {
visibility: 'PUBLIC'
};
if (status) {
where.status = status;
}
if (search) {
where.name = { contains: search as string, mode: 'insensitive' };
}
const games = await prisma.game.findMany({
where,
include: {
gameMaster: { select: { id: true, name: true } },
_count: { select: { teams: true, legs: true } }
},
orderBy: { createdAt: 'desc' }
});
res.json(games);
} catch (error) {
console.error('List games error:', error);
res.status(500).json({ error: 'Failed to list games' });
}
});
router.get('/my-games', authenticate, async (req: AuthRequest, res: Response) => {
try {
const games = await prisma.game.findMany({
where: { gameMasterId: req.user!.id },
include: {
_count: { select: { teams: true, legs: true } }
},
orderBy: { createdAt: 'desc' }
});
res.json(games);
} catch (error) {
console.error('My games error:', error);
res.status(500).json({ error: 'Failed to get games' });
}
});
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const game = await prisma.game.findUnique({
where: { id: id as string },
include: {
gameMaster: { select: { id: true, name: true } },
legs: { orderBy: { sequenceNumber: 'asc' } },
teams: {
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
currentLeg: true
}
}
}
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
const isOwner = req.user?.id === game.gameMasterId;
if (game.visibility === 'PRIVATE' && !isOwner) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(game);
} catch (error) {
console.error('Get game error:', error);
res.status(500).json({ error: 'Failed to get game' });
}
});
router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
try {
const {
name, description, prizeDetails, visibility, startDate,
locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
if (startDate && new Date(startDate) < new Date()) {
return res.status(400).json({ error: 'Start date must be in the future' });
}
const inviteCode = uuidv4().slice(0, 8);
const game = await prisma.game.create({
data: {
name,
description,
prizeDetails,
visibility: visibility || 'PUBLIC',
startDate: startDate ? new Date(startDate) : null,
locationLat,
locationLng,
searchRadius,
timeLimitPerLeg,
timeDeductionPenalty,
gameMasterId: req.user!.id,
inviteCode
}
});
res.json(game);
} catch (error) {
console.error('Create game error:', error);
res.status(500).json({ error: 'Failed to create game' });
}
});
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const id = req.params.id as string;
const {
name, description, prizeDetails, visibility, startDate,
locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty, status
} = req.body;
const game = await prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await prisma.game.update({
where: { id: id as string },
data: {
name,
description,
prizeDetails,
visibility,
startDate: startDate ? new Date(startDate) : undefined,
locationLat,
locationLng,
searchRadius,
timeLimitPerLeg,
timeDeductionPenalty,
status
}
});
res.json(updated);
} catch (error) {
console.error('Update game error:', error);
res.status(500).json({ error: 'Failed to update game' });
}
});
router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
const id = req.params.id as string;
const game = await prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
await prisma.game.delete({ where: { id } });
res.json({ message: 'Game deleted' });
} catch (error) {
console.error('Delete game error:', error);
res.status(500).json({ error: 'Failed to delete game' });
}
});
router.post('/:id/publish', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const game = await prisma.game.findUnique({
where: { id: id as string },
include: { legs: true }
}) as any;
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
if (!game.legs || game.legs.length === 0) {
return res.status(400).json({ error: 'Game must have at least one leg' });
}
const updated = await prisma.game.update({
where: { id: id as string },
data: { status: 'LIVE' }
});
res.json(updated);
} catch (error) {
console.error('Publish game error:', error);
res.status(500).json({ error: 'Failed to publish game' });
}
});
router.post('/:id/end', authenticate, async (req: AuthRequest, res: Response) => {
try {
const id = req.params.id as string;
const game = await prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await prisma.game.update({
where: { id },
data: { status: 'ENDED' }
});
res.json(updated);
} catch (error) {
console.error('End game error:', error);
res.status(500).json({ error: 'Failed to end game' });
}
});
router.get('/:id/invite', async (req: AuthRequest, res: Response) => {
try {
const id = req.params.id as string;
const game = await prisma.game.findUnique({ where: { id } });
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (!game.inviteCode) {
return res.status(404).json({ error: 'No invite code' });
}
res.json({ inviteCode: game.inviteCode });
} catch (error) {
console.error('Get invite error:', error);
res.status(500).json({ error: 'Failed to get invite code' });
}
});
router.get('/invite/:code', async (req: AuthRequest, res: Response) => {
try {
const code = req.params.code as string;
const game = await prisma.game.findUnique({
where: { inviteCode: code },
include: { gameMaster: { select: { id: true, name: true } } }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
res.json(game);
} catch (error) {
console.error('Get game by invite error:', error);
res.status(500).json({ error: 'Failed to get game' });
}
});
export default router;

175
backend/src/routes/legs.ts Normal file
View file

@ -0,0 +1,175 @@
import { Router, Response } from 'express';
import { prisma } from '../index';
import { authenticate, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/game/:gameId', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { gameId } = req.params;
const game = await prisma.game.findUnique({
where: { id: gameId },
include: { legs: { orderBy: { sequenceNumber: 'asc' } } }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
res.json(game.legs);
} catch (error) {
console.error('Get legs error:', error);
res.status(500).json({ error: 'Failed to get legs' });
}
});
router.post('/game/:gameId', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { gameId } = req.params;
const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body;
const game = await prisma.game.findUnique({
where: { id: gameId },
include: { legs: true }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const maxSequence = game.legs.reduce((max: number, leg: { sequenceNumber: number }) => Math.max(max, leg.sequenceNumber), 0);
const leg = await prisma.leg.create({
data: {
gameId,
sequenceNumber: maxSequence + 1,
description,
conditionType: conditionType || 'photo',
conditionDetails,
locationLat,
locationLng,
timeLimit
}
});
res.json(leg);
} catch (error) {
console.error('Create leg error:', error);
res.status(500).json({ error: 'Failed to create leg' });
}
});
router.put('/:legId', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { legId } = req.params;
const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body;
const leg = await prisma.leg.findUnique({
where: { id: legId },
include: { game: true }
});
if (!leg) {
return res.status(404).json({ error: 'Leg not found' });
}
if (leg.game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await prisma.leg.update({
where: { id: legId },
data: {
description,
conditionType,
conditionDetails,
locationLat,
locationLng,
timeLimit
}
});
res.json(updated);
} catch (error) {
console.error('Update leg error:', error);
res.status(500).json({ error: 'Failed to update leg' });
}
});
router.delete('/:legId', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { legId } = req.params;
const leg = await prisma.leg.findUnique({
where: { id: legId },
include: { game: true }
});
if (!leg) {
return res.status(404).json({ error: 'Leg not found' });
}
if (leg.game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
await prisma.leg.delete({ where: { id: legId } });
res.json({ message: 'Leg deleted' });
} catch (error) {
console.error('Delete leg error:', error);
res.status(500).json({ error: 'Failed to delete leg' });
}
});
router.post('/:legId/photo', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { legId } = req.params;
const { teamId, photoUrl } = req.body;
if (!teamId || !photoUrl) {
return res.status(400).json({ error: 'Team ID and photo URL are required' });
}
const leg = await prisma.leg.findUnique({
where: { id: legId },
include: { game: true }
});
if (!leg) {
return res.status(404).json({ error: 'Leg not found' });
}
const team = await prisma.team.findUnique({
where: { id: teamId }
});
if (!team || team.gameId !== leg.gameId) {
return res.status(403).json({ error: 'Team not in this game' });
}
const submission = await prisma.photoSubmission.create({
data: {
teamId,
legId,
photoUrl
}
});
res.json(submission);
} catch (error) {
console.error('Submit photo error:', error);
res.status(500).json({ error: 'Failed to submit photo' });
}
});
export default router;

341
backend/src/routes/teams.ts Normal file
View file

@ -0,0 +1,341 @@
import { Router, Response } from 'express';
import { prisma } from '../index';
import { authenticate, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/game/:gameId', async (req: AuthRequest, res: Response) => {
try {
const { gameId } = req.params;
const teams = await prisma.team.findMany({
where: { gameId },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } },
currentLeg: true
},
orderBy: { createdAt: 'asc' }
});
res.json(teams);
} catch (error) {
console.error('Get teams error:', error);
res.status(500).json({ error: 'Failed to get teams' });
}
});
router.post('/game/:gameId', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { gameId } = req.params;
const { name } = req.body;
const game = await prisma.game.findUnique({
where: { id: gameId },
include: { teams: true }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.status !== 'DRAFT' && game.status !== 'LIVE') {
return res.status(400).json({ error: 'Cannot join game at this time' });
}
const existingMember = await prisma.teamMember.findFirst({
where: {
userId: req.user!.id,
team: { gameId }
}
});
if (existingMember) {
return res.status(400).json({ error: 'Already in a team for this game' });
}
const team = await prisma.team.create({
data: {
gameId,
name,
captainId: req.user!.id
}
});
await prisma.teamMember.create({
data: {
teamId: team.id,
userId: req.user!.id
}
});
const created = await prisma.team.findUnique({
where: { id: team.id },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } }
}
});
res.json(created);
} catch (error) {
console.error('Create team error:', error);
res.status(500).json({ error: 'Failed to create team' });
}
});
router.post('/:teamId/join', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: { game: true, members: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.members.length >= 5) {
return res.status(400).json({ error: 'Team is full (max 5 members)' });
}
const existingMember = await prisma.teamMember.findFirst({
where: {
userId: req.user!.id,
teamId
}
});
if (existingMember) {
return res.status(400).json({ error: 'Already in this team' });
}
const gameMember = await prisma.teamMember.findFirst({
where: {
userId: req.user!.id,
team: { gameId: team.gameId }
}
});
if (gameMember) {
return res.status(400).json({ error: 'Already in another team for this game' });
}
await prisma.teamMember.create({
data: {
teamId,
userId: req.user!.id
}
});
res.json({ message: 'Joined team successfully' });
} catch (error) {
console.error('Join team error:', error);
res.status(500).json({ error: 'Failed to join team' });
}
});
router.post('/:teamId/leave', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.captainId === req.user!.id) {
return res.status(400).json({ error: 'Captain cannot leave the team' });
}
await prisma.teamMember.deleteMany({
where: {
teamId,
userId: req.user!.id
}
});
res.json({ message: 'Left team successfully' });
} catch (error) {
console.error('Leave team error:', error);
res.status(500).json({ error: 'Failed to leave team' });
}
});
router.post('/:teamId/advance', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } },
currentLeg: true
}
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const legs = team.game.legs;
const currentLeg = team.currentLeg;
let nextLeg = null;
if (currentLeg) {
const currentIndex = legs.findIndex((l: { id: string }) => l.id === currentLeg.id);
if (currentIndex < legs.length - 1) {
nextLeg = legs[currentIndex + 1];
}
} else if (legs.length > 0) {
nextLeg = legs[0];
}
const updated = await prisma.team.update({
where: { id: teamId },
data: {
currentLegId: nextLeg?.id || null,
status: nextLeg ? 'ACTIVE' : 'FINISHED'
},
include: {
members: { include: { user: { select: { id: true, name: true } } } },
currentLeg: true
}
});
res.json(updated);
} catch (error) {
console.error('Advance team error:', error);
res.status(500).json({ error: 'Failed to advance team' });
}
});
router.post('/:teamId/deduct', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { teamId } = req.params;
const { seconds } = req.body;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: { game: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const deduction = seconds || team.game.timeDeductionPenalty || 60;
const updated = await prisma.team.update({
where: { id: teamId },
data: { totalTimeDeduction: { increment: deduction } }
});
res.json(updated);
} catch (error) {
console.error('Deduct time error:', error);
res.status(500).json({ error: 'Failed to deduct time' });
}
});
router.post('/:teamId/disqualify', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: { game: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await prisma.team.update({
where: { id: teamId },
data: { status: 'DISQUALIFIED' }
});
res.json(updated);
} catch (error) {
console.error('Disqualify team error:', error);
res.status(500).json({ error: 'Failed to disqualify team' });
}
});
router.post('/:teamId/location', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { teamId } = req.params;
const { lat, lng } = req.body;
const team = await prisma.team.findUnique({
where: { id: teamId }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
const member = await prisma.teamMember.findFirst({
where: { teamId, userId: req.user!.id }
});
if (!member && team.captainId !== req.user!.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await prisma.team.update({
where: { id: teamId },
data: { lat, lng }
});
res.json(updated);
} catch (error) {
console.error('Update location error:', error);
res.status(500).json({ error: 'Failed to update location' });
}
});
router.get('/:teamId', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } },
currentLeg: true,
game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } }
}
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
res.json(team);
} catch (error) {
console.error('Get team error:', error);
res.status(500).json({ error: 'Failed to get team' });
}
});
export default router;

View file

@ -0,0 +1,47 @@
import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { authenticate, AuthRequest } from '../middleware/auth';
const router = Router();
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
return cb(null, true);
}
cb(new Error('Only image files are allowed'));
}
});
router.post('/upload', authenticate, upload.single('photo'), (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const url = `/uploads/${req.file.filename}`;
res.json({ url });
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Failed to upload file' });
}
});
export default router;

View file

@ -0,0 +1,61 @@
import { Server, Socket } from 'socket.io';
import { prisma } from '../index';
export default function setupSocket(io: Server) {
io.on('connection', (socket: Socket) => {
console.log('Client connected:', socket.id);
socket.on('join-game', async (gameId: string) => {
socket.join(`game:${gameId}`);
console.log(`Socket ${socket.id} joined game:${gameId}`);
});
socket.on('leave-game', (gameId: string) => {
socket.leave(`game:${gameId}`);
});
socket.on('team-location', async (data: { gameId: string; teamId: string; lat: number; lng: number }) => {
await prisma.team.update({
where: { id: data.teamId },
data: { lat: data.lat, lng: data.lng }
});
io.to(`game:${data.gameId}`).emit('team-location', {
teamId: data.teamId,
lat: data.lat,
lng: data.lng
});
});
socket.on('chat-message', async (data: { gameId: string; teamId?: string; message: string; userId: string; userName: string }) => {
const chatMessage = await prisma.chatMessage.create({
data: {
gameId: data.gameId,
teamId: data.teamId,
userId: data.userId,
message: data.message
}
});
io.to(`game:${data.gameId}`).emit('chat-message', {
id: chatMessage.id,
teamId: data.teamId,
userId: data.userId,
userName: data.userName,
message: data.message,
sentAt: chatMessage.sentAt
});
});
socket.on('team-advanced', async (data: { gameId: string; teamId: string }) => {
io.to(`game:${data.gameId}`).emit('team-advanced', {
teamId: data.teamId
});
});
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
return io;
}

17
backend/tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"esModuleInterop": true,
"strict": false,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"declaration": true,
"noImplicitAny": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

47
docker-compose.yml Normal file
View file

@ -0,0 +1,47 @@
services:
db:
image: postgres:16-alpine
container_name: treasure_trails_db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: treasure_trails
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: treasure_trails_backend
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/treasure_trails?schema=public
JWT_SECRET: treasure-trails-jwt-secret-change-in-production
PORT: 3001
ports:
- "3001:3001"
depends_on:
db:
condition: service_healthy
volumes:
- ./backend/uploads:/app/uploads
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: treasure_trails_frontend
ports:
- "5173:5173"
depends_on:
- backend
volumes:
postgres_data:

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

19
frontend/Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM node:20-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build:fast
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5173
CMD ["nginx", "-g", "daemon off;"]

5
frontend/README.md Normal file
View file

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

28
frontend/nginx.conf Normal file
View file

@ -0,0 +1,28 @@
server {
listen 5173;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /socket.io {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

1823
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
frontend/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build:fast": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@types/leaflet": "^1.9.21",
"axios": "^1.13.6",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"socket.io-client": "^4.8.3",
"vue": "^3.5.30",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"typescript": "~5.9.3",
"vite": "^8.0.0",
"vue-tsc": "^3.2.5"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

7
frontend/src/App.vue Normal file
View file

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View file

@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

12
frontend/src/main.ts Normal file
View file

@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,286 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gameService } from '../services/api';
const router = useRouter();
const name = ref('');
const description = ref('');
const prizeDetails = ref('');
const visibility = ref<'PUBLIC' | 'PRIVATE'>('PUBLIC');
const startDate = ref('');
const locationLat = ref<number | null>(null);
const locationLng = ref<number | null>(null);
const searchRadius = ref(500);
const timeLimitPerLeg = ref(30);
const timeDeductionPenalty = ref(60);
const mapContainer = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let marker: L.Marker | null = null;
const loading = ref(false);
const error = ref('');
function initMap() {
if (!mapContainer.value) return;
map = L.map(mapContainer.value).setView([51.505, -0.09], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
map.on('click', (e: L.LeafletMouseEvent) => {
locationLat.value = e.latlng.lat;
locationLng.value = e.latlng.lng;
if (marker) {
marker.setLatLng(e.latlng);
} else {
marker = L.marker(e.latlng).addTo(map!);
}
});
}
async function handleSubmit() {
error.value = '';
if (!name.value) {
error.value = 'Game name is required';
return;
}
if (startDate.value && new Date(startDate.value) < new Date()) {
error.value = 'Start date must be in the future';
return;
}
loading.value = true;
try {
const response = await gameService.create({
name: name.value,
description: description.value || undefined,
prizeDetails: prizeDetails.value || undefined,
visibility: visibility.value,
startDate: startDate.value || undefined,
locationLat: locationLat.value || undefined,
locationLng: locationLng.value || undefined,
searchRadius: searchRadius.value,
timeLimitPerLeg: timeLimitPerLeg.value,
timeDeductionPenalty: timeDeductionPenalty.value
});
router.push(`/games/${response.data.id}/edit`);
} catch (err) {
error.value = 'Failed to create game';
console.error(err);
} finally {
loading.value = false;
}
}
onMounted(() => {
initMap();
});
onUnmounted(() => {
if (map) {
map.remove();
}
});
</script>
<template>
<div class="create-game">
<header class="page-header">
<h1>Create New Game</h1>
</header>
<form @submit.prevent="handleSubmit" class="game-form">
<div v-if="error" class="error">{{ error }}</div>
<div class="form-section">
<h2>Basic Information</h2>
<div class="form-group">
<label for="name">Game Name *</label>
<input id="name" v-model="name" type="text" required placeholder="Enter game name" />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" v-model="description" rows="3" placeholder="Describe your scavenger hunt"></textarea>
</div>
<div class="form-group">
<label for="prizeDetails">Prize Details</label>
<input id="prizeDetails" v-model="prizeDetails" type="text" placeholder="What's the prize?" />
</div>
<div class="form-group">
<label for="visibility">Visibility</label>
<select id="visibility" v-model="visibility">
<option value="PUBLIC">Public</option>
<option value="PRIVATE">Private (Invite Only)</option>
</select>
</div>
<div class="form-group">
<label for="startDate">Start Date & Time</label>
<input id="startDate" v-model="startDate" type="datetime-local" />
</div>
</div>
<div class="form-section">
<h2>Location</h2>
<p class="hint">Click on the map to set the treasure location</p>
<div ref="mapContainer" class="map-container"></div>
<div class="location-inputs">
<div class="form-group">
<label>Latitude</label>
<input v-model.number="locationLat" type="number" step="any" readonly />
</div>
<div class="form-group">
<label>Longitude</label>
<input v-model.number="locationLng" type="number" step="any" readonly />
</div>
<div class="form-group">
<label for="searchRadius">Search Radius (meters)</label>
<input id="searchRadius" v-model.number="searchRadius" type="number" min="100" />
</div>
</div>
</div>
<div class="form-section">
<h2>Game Rules</h2>
<div class="form-group">
<label for="timeLimitPerLeg">Time Limit per Leg (minutes)</label>
<input id="timeLimitPerLeg" v-model.number="timeLimitPerLeg" type="number" min="1" />
</div>
<div class="form-group">
<label for="timeDeductionPenalty">Navigation Warning Penalty (seconds)</label>
<input id="timeDeductionPenalty" v-model.number="timeDeductionPenalty" type="number" min="0" />
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Creating...' : 'Create Game' }}
</button>
<RouterLink to="/dashboard" class="btn btn-secondary">Cancel</RouterLink>
</div>
</form>
</div>
</template>
<style scoped>
.create-game {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.game-form {
display: flex;
flex-direction: column;
gap: 2rem;
}
.form-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-section h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
.hint {
color: #666;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.map-container {
height: 300px;
border-radius: 8px;
margin-bottom: 1rem;
}
.location-inputs {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-size: 1rem;
cursor: pointer;
border: none;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn:disabled {
opacity: 0.7;
}
.error {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 6px;
}
</style>

View file

@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import type { Game } from '../types';
import { gameService } from '../services/api';
const authStore = useAuthStore();
const games = ref<Game[]>([]);
const loading = ref(false);
async function loadGames() {
loading.value = true;
try {
const response = await gameService.myGames();
games.value = response.data;
} catch (error) {
console.error('Failed to load games:', error);
} finally {
loading.value = false;
}
}
onMounted(() => {
loadGames();
});
</script>
<template>
<div class="dashboard">
<header class="dashboard-header">
<h1>My Dashboard</h1>
<div class="user-info">
<span>Welcome, {{ authStore.user?.name }}</span>
<button @click="authStore.logout()" class="btn btn-logout">Logout</button>
</div>
</header>
<section class="games-section">
<div class="section-header">
<h2>My Games</h2>
<RouterLink to="/games/new" class="btn btn-primary">Create New Game</RouterLink>
</div>
<div v-if="loading" class="loading">Loading games...</div>
<div v-else-if="games.length === 0" class="empty">
<p>You haven't created any games yet.</p>
<RouterLink to="/games/new" class="btn btn-primary">Create Your First Game</RouterLink>
</div>
<div v-else class="games-list">
<div v-for="game in games" :key="game.id" class="game-item">
<div class="game-info">
<h3>{{ game.name }}</h3>
<div class="game-meta">
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
<span>{{ game._count?.legs || 0 }} legs</span>
<span>{{ game._count?.teams || 0 }} teams</span>
</div>
</div>
<div class="game-actions">
<RouterLink :to="`/games/${game.id}`" class="btn btn-secondary">View</RouterLink>
<RouterLink
v-if="game.status === 'DRAFT'"
:to="`/games/${game.id}/edit`"
class="btn btn-secondary"
>
Edit
</RouterLink>
<RouterLink
v-if="game.status === 'LIVE'"
:to="`/games/${game.id}/live`"
class="btn btn-primary"
>
Live Dashboard
</RouterLink>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.dashboard {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.875rem;
cursor: pointer;
border: none;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-logout {
background: transparent;
color: #666;
border: 1px solid #ddd;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.games-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.game-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
.game-info h3 {
margin: 0 0 0.5rem;
}
.game-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: #666;
}
.status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status.draft {
background: #fff3cd;
color: #856404;
}
.status.live {
background: #d4edda;
color: #155724;
}
.status.ended {
background: #e2e3e5;
color: #383d41;
}
.game-actions {
display: flex;
gap: 0.5rem;
}
.loading, .empty {
text-align: center;
padding: 3rem;
color: #666;
}
.empty .btn {
margin-top: 1rem;
}
</style>

View file

@ -0,0 +1,401 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { Game, Leg } from '../types';
import { gameService, legService } from '../services/api';
const route = useRoute();
const router = useRouter();
const game = ref<Game | null>(null);
const legs = ref<Leg[]>([]);
const loading = ref(true);
const saving = ref(false);
const gameId = computed(() => route.params.id as string);
const mapContainer = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let markers: L.Marker[] = [];
const newLeg = ref({
description: '',
conditionType: 'photo',
conditionDetails: '',
locationLat: null as number | null,
locationLng: null as number | null,
timeLimit: 30
});
async function loadGame() {
loading.value = true;
try {
const response = await gameService.get(gameId.value);
game.value = response.data;
legs.value = response.data.legs || [];
} catch (err) {
console.error('Failed to load game:', err);
} finally {
loading.value = false;
}
}
function initMap() {
if (!mapContainer.value) return;
const center = game.value?.locationLat && game.value?.locationLng
? [game.value.locationLat, game.value.locationLng] as [number, number]
: [51.505, -0.09] as [number, number];
map = L.map(mapContainer.value).setView(center, 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
if (game.value?.locationLat && game.value?.locationLng) {
L.circle([game.value.locationLat, game.value.locationLng], {
radius: game.value.searchRadius || 500,
color: '#667eea',
fillColor: '#667eea',
fillOpacity: 0.1
}).addTo(map);
}
legs.value.forEach((leg, index) => {
if (leg.locationLat && leg.locationLng) {
const marker = L.marker([leg.locationLat, leg.locationLng])
.addTo(map!)
.bindPopup(`Leg ${index + 1}`);
markers.push(marker);
}
});
map.on('click', (e: L.LeafletMouseEvent) => {
newLeg.value.locationLat = e.latlng.lat;
newLeg.value.locationLng = e.latlng.lng;
});
}
async function addLeg() {
if (!newLeg.value.description) {
alert('Please enter a description');
return;
}
saving.value = true;
try {
const response = await legService.create(gameId.value, {
description: newLeg.value.description,
conditionType: newLeg.value.conditionType,
conditionDetails: newLeg.value.conditionDetails || undefined,
locationLat: newLeg.value.locationLat || undefined,
locationLng: newLeg.value.locationLng || undefined,
timeLimit: newLeg.value.timeLimit
});
legs.value.push(response.data);
newLeg.value = {
description: '',
conditionType: 'photo',
conditionDetails: '',
locationLat: null,
locationLng: null,
timeLimit: 30
};
if (map) {
markers.forEach(m => m.remove());
markers = [];
legs.value.forEach((leg, index) => {
if (leg.locationLat && leg.locationLng) {
const marker = L.marker([leg.locationLat, leg.locationLng])
.addTo(map!)
.bindPopup(`Leg ${index + 1}`);
markers.push(marker);
}
});
}
} catch (err) {
alert('Failed to add leg');
} finally {
saving.value = false;
}
}
async function deleteLeg(legId: string) {
if (!confirm('Are you sure you want to delete this leg?')) return;
try {
await legService.delete(legId);
legs.value = legs.value.filter(l => l.id !== legId);
} catch (err) {
alert('Failed to delete leg');
}
}
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function getTotalDistance(): number {
if (!game.value?.locationLat || !game.value?.locationLng || legs.value.length === 0) return 0;
let total = 0;
let prevLat = game.value.locationLat;
let prevLng = game.value.locationLng;
for (const leg of legs.value) {
if (leg.locationLat && leg.locationLng) {
total += calculateDistance(prevLat, prevLng, leg.locationLat, leg.locationLng);
prevLat = leg.locationLat;
prevLng = leg.locationLng;
}
}
return total;
}
onMounted(() => {
loadGame().then(() => {
setTimeout(initMap, 100);
});
});
onUnmounted(() => {
if (map) map.remove();
});
</script>
<template>
<div class="edit-game">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="game">
<header class="page-header">
<div>
<RouterLink :to="`/games/${game.id}`" class="back-link">&larr; Back to Game</RouterLink>
<h1>Edit: {{ game.name }}</h1>
</div>
</header>
<div class="edit-content">
<section class="legs-section">
<h2>Legs ({{ legs.length }})</h2>
<p class="hint">Total route distance: {{ getTotalDistance().toFixed(2) }} km</p>
<div v-if="legs.length" class="legs-list">
<div v-for="(leg, index) in legs" :key="leg.id" class="leg-item">
<div class="leg-number">{{ index + 1 }}</div>
<div class="leg-info">
<p>{{ leg.description }}</p>
<div class="leg-meta">
<span>Type: {{ leg.conditionType }}</span>
<span v-if="leg.timeLimit">{{ leg.timeLimit }} min</span>
<span v-if="leg.locationLat && leg.locationLng">
{{ leg.locationLat.toFixed(4) }}, {{ leg.locationLng.toFixed(4) }}
</span>
</div>
</div>
<button @click="deleteLeg(leg.id)" class="btn btn-danger">Delete</button>
</div>
</div>
<div v-else class="empty">No legs added yet</div>
</section>
<section class="add-leg-section">
<h2>Add New Leg</h2>
<div ref="mapContainer" class="map-container"></div>
<p class="hint">Click on map to set location</p>
<form @submit.prevent="addLeg" class="leg-form">
<div class="form-group">
<label>Description / Clue</label>
<textarea v-model="newLeg.description" rows="2" required></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Condition Type</label>
<select v-model="newLeg.conditionType">
<option value="photo">Photo Proof</option>
<option value="purchase">Purchase</option>
<option value="text">Text Answer</option>
</select>
</div>
<div class="form-group">
<label>Time Limit (minutes)</label>
<input v-model.number="newLeg.timeLimit" type="number" min="1" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Latitude</label>
<input v-model.number="newLeg.locationLat" type="number" step="any" readonly />
</div>
<div class="form-group">
<label>Longitude</label>
<input v-model.number="newLeg.locationLng" type="number" step="any" readonly />
</div>
</div>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Adding...' : 'Add Leg' }}
</button>
</form>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.edit-game {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.875rem;
}
.page-header {
margin-bottom: 2rem;
}
.edit-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.legs-section, .add-leg-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.legs-section h2, .add-leg-section h2 {
margin-bottom: 1rem;
}
.hint {
font-size: 0.875rem;
color: #666;
margin-bottom: 1rem;
}
.legs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.leg-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 6px;
}
.leg-number {
width: 32px;
height: 32px;
background: #667eea;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.leg-info {
flex: 1;
}
.leg-info p {
margin: 0 0 0.5rem;
}
.leg-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #666;
}
.map-container {
height: 250px;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.leg-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-group label {
font-size: 0.875rem;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 6px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
border: none;
font-size: 0.875rem;
}
.btn-primary { background: #667eea; color: white; }
.btn-danger { background: #dc3545; color: white; }
.empty {
text-align: center;
padding: 2rem;
color: #666;
}
</style>

View file

@ -0,0 +1,403 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { io, Socket } from 'socket.io-client';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useAuthStore } from '../stores/auth';
import type { Game, Team, ChatMessage } from '../types';
import { gameService, teamService } from '../services/api';
const route = useRoute();
const authStore = useAuthStore();
const game = ref<Game | null>(null);
const teams = ref<Team[]>([]);
const chatMessages = ref<ChatMessage[]>([]);
const loading = ref(true);
const gameId = computed(() => route.params.id as string);
let socket: Socket | null = null;
let map: L.Map | null = null;
let teamMarkers: { [key: string]: L.Marker } = {};
const chatMessage = ref('');
const selectedTeam = ref<Team | null>(null);
async function loadGame() {
loading.value = true;
try {
const [gameRes, teamsRes] = await Promise.all([
gameService.get(gameId.value),
teamService.getByGame(gameId.value)
]);
game.value = gameRes.data;
teams.value = teamsRes.data;
} catch (err) {
console.error('Failed to load game:', err);
} finally {
loading.value = false;
}
}
function initMap() {
if (!game.value?.locationLat || !game.value?.locationLng) return;
map = L.map('map').setView([game.value.locationLat, game.value.locationLng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
L.circle([game.value.locationLat, game.value.locationLng], {
radius: game.value.searchRadius || 500,
color: '#667eea',
fillColor: '#667eea',
fillOpacity: 0.1
}).addTo(map);
teams.value.forEach(team => {
if (team.lat && team.lng) {
const marker = L.marker([team.lat, team.lng])
.addTo(map!)
.bindPopup(team.name);
teamMarkers[team.id] = marker;
}
});
}
function connectSocket() {
socket = io('http://localhost:3001');
socket.on('connect', () => {
socket?.emit('join-game', gameId.value);
});
socket.on('team-location', (data: { teamId: string; lat: number; lng: number }) => {
const team = teams.value.find(t => t.id === data.teamId);
if (team) {
team.lat = data.lat;
team.lng = data.lng;
if (teamMarkers[data.teamId]) {
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
} else if (map) {
const marker = L.marker([data.lat, data.lng])
.addTo(map)
.bindPopup(team.name);
teamMarkers[data.teamId] = marker;
}
}
});
socket.on('chat-message', (data: ChatMessage) => {
chatMessages.value.push(data);
});
socket.on('team-advanced', () => {
loadGame();
});
}
async function sendChat() {
if (!chatMessage.value.trim() || !socket) return;
socket.emit('chat-message', {
gameId: gameId.value,
message: chatMessage.value,
userId: authStore.user?.id,
userName: authStore.user?.name
});
chatMessage.value = '';
}
async function advanceTeam(teamId: string) {
try {
await teamService.advance(teamId);
await loadGame();
socket?.emit('team-advanced', { gameId: gameId.value, teamId });
} catch (err) {
alert('Failed to advance team');
}
}
async function deductTime(teamId: string) {
const seconds = prompt('Enter deduction in seconds:', '60');
if (!seconds) return;
try {
await teamService.deduct(teamId, parseInt(seconds));
await loadGame();
} catch (err) {
alert('Failed to deduct time');
}
}
async function disqualifyTeam(teamId: string) {
if (!confirm('Are you sure you want to disqualify this team?')) return;
try {
await teamService.disqualify(teamId);
await loadGame();
} catch (err) {
alert('Failed to disqualify team');
}
}
function selectTeam(team: Team) {
selectedTeam.value = team;
}
onMounted(() => {
loadGame().then(() => {
setTimeout(initMap, 100);
connectSocket();
});
});
onUnmounted(() => {
if (socket) socket.disconnect();
if (map) map.remove();
});
</script>
<template>
<div class="live-dashboard">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="game">
<header class="dashboard-header">
<h1>{{ game.name }} - Live Dashboard</h1>
<span class="live-badge">LIVE</span>
</header>
<div class="dashboard-content">
<div class="map-section">
<div id="map" class="map-container"></div>
</div>
<div class="teams-section">
<h2>Teams ({{ teams.length }})</h2>
<div class="teams-list">
<div
v-for="team in teams"
:key="team.id"
class="team-card"
:class="{ selected: selectedTeam?.id === team.id }"
@click="selectTeam(team)"
>
<div class="team-header">
<span class="team-name">{{ team.name }}</span>
<span :class="['status', team.status.toLowerCase()]">{{ team.status }}</span>
</div>
<div class="team-info">
<span>{{ team.members?.length || 0 }} members</span>
<span>Leg {{ game.legs?.findIndex(l => l.id === team.currentLegId) + 1 || 0 }} / {{ game.legs?.length || 0 }}</span>
<span v-if="team.totalTimeDeduction">-{{ team.totalTimeDeduction }}s</span>
</div>
<div v-if="selectedTeam?.id === team.id" class="team-actions">
<button
@click.stop="advanceTeam(team.id)"
class="btn btn-sm btn-success"
:disabled="team.status !== 'ACTIVE'"
>
Advance
</button>
<button
@click.stop="deductTime(team.id)"
class="btn btn-sm btn-warning"
:disabled="team.status !== 'ACTIVE'"
>
Deduct Time
</button>
<button
@click.stop="disqualifyTeam(team.id)"
class="btn btn-sm btn-danger"
:disabled="team.status !== 'ACTIVE'"
>
Disqualify
</button>
</div>
</div>
</div>
</div>
<div class="chat-section">
<h2>Chat</h2>
<div class="chat-messages">
<div v-for="msg in chatMessages" :key="msg.id" class="chat-message">
<strong>{{ msg.userName }}:</strong> {{ msg.message }}
</div>
<div v-if="!chatMessages.length" class="empty-chat">
No messages yet
</div>
</div>
<form @submit.prevent="sendChat" class="chat-input">
<input v-model="chatMessage" placeholder="Type a message..." />
<button type="submit" class="btn btn-sm btn-primary">Send</button>
</form>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.live-dashboard {
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem;
}
.dashboard-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.live-badge {
background: #dc3545;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-weight: bold;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.dashboard-content {
display: grid;
grid-template-columns: 1fr 300px 300px;
gap: 1rem;
flex: 1;
min-height: 0;
}
.map-section {
display: flex;
flex-direction: column;
}
.map-container {
flex: 1;
border-radius: 8px;
}
.teams-section, .chat-section {
background: white;
border-radius: 8px;
padding: 1rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.teams-section h2, .chat-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
}
.teams-list {
overflow-y: auto;
flex: 1;
}
.team-card {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
margin-bottom: 0.5rem;
cursor: pointer;
}
.team-card.selected {
border-color: #667eea;
background: #f0f4ff;
}
.team-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.team-name {
font-weight: 500;
}
.team-info {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #666;
}
.status {
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.625rem;
}
.status.active { background: #d4edda; color: #155724; }
.status.disqualified { background: #f8d7da; color: #721c24; }
.status.finished { background: #cce5ff; color: #004085; }
.team-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.chat-messages {
flex: 1;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.chat-message {
padding: 0.5rem;
border-bottom: 1px solid #eee;
font-size: 0.875rem;
}
.chat-input {
display: flex;
gap: 0.5rem;
}
.chat-input input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
border: none;
font-size: 0.75rem;
}
.btn-sm { padding: 0.25rem 0.5rem; }
.btn-primary { background: #667eea; color: white; }
.btn-success { background: #28a745; color: white; }
.btn-warning { background: #ffc107; color: #333; }
.btn-danger { background: #dc3545; color: white; }
.empty-chat {
text-align: center;
color: #999;
padding: 2rem;
}
</style>

View file

@ -0,0 +1,321 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import type { Game } from '../types';
import { gameService } from '../services/api';
const route = useRoute();
const authStore = useAuthStore();
const game = ref<Game | null>(null);
const loading = ref(true);
const error = ref('');
const gameId = computed(() => route.params.id as string);
const isGameMaster = computed(() => game.value?.gameMasterId === authStore.user?.id);
async function loadGame() {
loading.value = true;
try {
const response = await gameService.get(gameId.value);
game.value = response.data;
} catch (err) {
error.value = 'Failed to load game';
console.error(err);
} finally {
loading.value = false;
}
}
async function publishGame() {
if (!confirm('Are you sure you want to publish this game?')) return;
try {
await gameService.publish(gameId.value);
await loadGame();
} catch (err) {
alert('Failed to publish game');
}
}
async function endGame() {
if (!confirm('Are you sure you want to end this game?')) return;
try {
await gameService.end(gameId.value);
await loadGame();
} catch (err) {
alert('Failed to end game');
}
}
async function copyInviteLink() {
try {
const response = await gameService.getInvite(gameId.value);
const link = `${window.location.origin}/invite/${response.data.inviteCode}`;
await navigator.clipboard.writeText(link);
alert('Invite link copied to clipboard!');
} catch (err) {
alert('Failed to get invite link');
}
}
onMounted(() => {
loadGame();
});
</script>
<template>
<div class="game-page">
<div v-if="loading" class="loading">Loading game...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<template v-else-if="game">
<header class="game-header">
<div>
<h1>{{ game.name }}</h1>
<div class="game-meta">
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
<span>{{ game.visibility }}</span>
<span v-if="game.startDate">Starts: {{ new Date(game.startDate).toLocaleString() }}</span>
</div>
</div>
<div v-if="isGameMaster" class="gm-actions">
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" class="btn btn-secondary">
Edit Game
</RouterLink>
<button v-if="game.status === 'DRAFT'" @click="publishGame" class="btn btn-primary">
Publish Game
</button>
<RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/live`" class="btn btn-primary">
Live Dashboard
</RouterLink>
<button v-if="game.status === 'LIVE'" @click="endGame" class="btn btn-danger">
End Game
</button>
<button @click="copyInviteLink" class="btn btn-secondary">
Copy Invite Link
</button>
</div>
<div v-else class="player-actions">
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/join`" class="btn btn-primary">
Join Game
</RouterLink>
<RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/join`" class="btn btn-primary">
Join Game
</RouterLink>
<RouterLink v-if="game.status !== 'DRAFT'" :to="`/games/${game.id}/spectate`" class="btn btn-secondary">
Spectate
</RouterLink>
</div>
</header>
<div class="game-content">
<section v-if="game.description" class="game-section">
<h2>Description</h2>
<p>{{ game.description }}</p>
</section>
<section v-if="game.prizeDetails" class="game-section">
<h2>Prize</h2>
<p>{{ game.prizeDetails }}</p>
</section>
<section class="game-section">
<h2>Game Details</h2>
<dl>
<dt>Location</dt>
<dd v-if="game.locationLat && game.locationLng">
{{ game.locationLat.toFixed(4) }}, {{ game.locationLng.toFixed(4) }}
</dd>
<dd v-else>Not set</dd>
<dt>Search Radius</dt>
<dd>{{ game.searchRadius || 500 }} meters</dd>
<dt>Time Limit per Leg</dt>
<dd>{{ game.timeLimitPerLeg || 30 }} minutes</dd>
<dt>Time Deduction Penalty</dt>
<dd>{{ game.timeDeductionPenalty || 60 }} seconds</dd>
</dl>
</section>
<section v-if="game.legs?.length" class="game-section">
<h2>Legs ({{ game.legs.length }})</h2>
<div class="legs-list">
<div v-for="leg in game.legs" :key="leg.id" class="leg-item">
<div class="leg-number">{{ leg.sequenceNumber }}</div>
<div class="leg-content">
<p>{{ leg.description }}</p>
<div class="leg-meta">
<span>Type: {{ leg.conditionType }}</span>
<span v-if="leg.timeLimit">Time: {{ leg.timeLimit }} min</span>
</div>
</div>
</div>
</div>
</section>
<section v-if="game.teams?.length" class="game-section">
<h2>Teams ({{ game.teams.length }})</h2>
<div class="teams-list">
<div v-for="team in game.teams" :key="team.id" class="team-item">
<span class="team-name">{{ team.name }}</span>
<span :class="['team-status', team.status.toLowerCase()]">{{ team.status }}</span>
<span class="team-members">{{ team.members?.length || 0 }} members</span>
</div>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.game-page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.game-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.game-header h1 {
margin: 0 0 0.5rem;
}
.game-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: #666;
}
.status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status.draft { background: #fff3cd; color: #856404; }
.status.live { background: #d4edda; color: #155724; }
.status.ended { background: #e2e3e5; color: #383d41; }
.gm-actions, .player-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.875rem;
cursor: pointer;
border: none;
}
.btn-primary { background: #667eea; color: white; }
.btn-secondary { background: #e0e0e0; color: #333; }
.btn-danger { background: #dc3545; color: white; }
.game-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.game-section h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
.game-section dl {
display: grid;
grid-template-columns: 150px 1fr;
gap: 0.5rem;
}
.game-section dt {
font-weight: 500;
color: #666;
}
.legs-list, .teams-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.leg-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 6px;
}
.leg-number {
width: 32px;
height: 32px;
background: #667eea;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.leg-content p {
margin: 0 0 0.5rem;
}
.leg-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: #666;
}
.team-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: #f9f9f9;
border-radius: 6px;
}
.team-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.team-status.active { background: #d4edda; color: #155724; }
.team-status.disqualified { background: #f8d7da; color: #721c24; }
.team-status.finished { background: #cce5ff; color: #004085; }
.loading, .error {
text-align: center;
padding: 3rem;
color: #666;
}
</style>

View file

@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import type { Game } from '../types';
import { gameService } from '../services/api';
const games = ref<Game[]>([]);
const search = ref('');
const loading = ref(false);
async function loadGames() {
loading.value = true;
try {
const response = await gameService.list({ status: 'LIVE', search: search.value });
games.value = response.data;
} catch (error) {
console.error('Failed to load games:', error);
} finally {
loading.value = false;
}
}
onMounted(() => {
loadGames();
});
</script>
<template>
<div class="home">
<header class="hero">
<h1>Treasure Trails</h1>
<p>Online scavenger hunt adventure</p>
<div class="hero-actions">
<RouterLink to="/register" class="btn btn-primary">Get Started</RouterLink>
<RouterLink to="/login" class="btn btn-secondary">Login</RouterLink>
</div>
</header>
<section class="games-section">
<h2>Active Public Games</h2>
<div class="search-bar">
<input
v-model="search"
type="text"
placeholder="Search games..."
@keyup.enter="loadGames"
/>
<button @click="loadGames" class="btn">Search</button>
</div>
<div v-if="loading" class="loading">Loading games...</div>
<div v-else-if="games.length === 0" class="empty">
No active games found. Be the first to create one!
</div>
<div v-else class="games-grid">
<RouterLink
v-for="game in games"
:key="game.id"
:to="`/games/${game.id}`"
class="game-card"
>
<h3>{{ game.name }}</h3>
<p v-if="game.description">{{ game.description.slice(0, 100) }}...</p>
<div class="game-meta">
<span>{{ game._count?.legs || 0 }} legs</span>
<span>{{ game._count?.teams || 0 }} teams</span>
<span v-if="game.startDate">{{ new Date(game.startDate).toLocaleDateString() }}</span>
</div>
</RouterLink>
</div>
</section>
</div>
</template>
<style scoped>
.home {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.hero {
text-align: center;
padding: 4rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
margin-bottom: 3rem;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.hero p {
font-size: 1.25rem;
margin-bottom: 2rem;
}
.hero-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: white;
color: #667eea;
}
.btn-secondary {
background: transparent;
color: white;
border: 2px solid white;
}
.games-section h2 {
margin-bottom: 1.5rem;
}
.search-bar {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.search-bar input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.game-card {
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: transform 0.2s, box-shadow 0.2s;
}
.game-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.game-card h3 {
margin: 0 0 0.5rem;
}
.game-meta {
display: flex;
gap: 1rem;
margin-top: 1rem;
font-size: 0.875rem;
color: #666;
}
.loading, .empty {
text-align: center;
padding: 3rem;
color: #666;
}
</style>

View file

@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { Game } from '../types';
import { gameService } from '../services/api';
const route = useRoute();
const router = useRouter();
const game = ref<Game | null>(null);
const loading = ref(true);
const error = ref('');
const inviteCode = computed(() => route.params.code as string);
import { computed } from 'vue';
async function loadGame() {
loading.value = true;
error.value = '';
try {
const response = await gameService.getByInvite(inviteCode.value);
game.value = response.data;
} catch (err) {
error.value = 'Game not found or invite link is invalid';
} finally {
loading.value = false;
}
}
function goToGame() {
if (game.value) {
router.push(`/games/${game.value.id}/join`);
}
}
onMounted(() => {
loadGame();
});
</script>
<template>
<div class="invite-page">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<template v-else-if="game">
<div class="invite-card">
<h1>You're Invited!</h1>
<p class="game-name">{{ game.name }}</p>
<div v-if="game.description" class="description">
{{ game.description }}
</div>
<div class="game-info">
<div class="info-row">
<span>Game Master:</span>
<span>{{ game.gameMaster?.name }}</span>
</div>
<div v-if="game.startDate" class="info-row">
<span>Start Date:</span>
<span>{{ new Date(game.startDate).toLocaleString() }}</span>
</div>
<div class="info-row">
<span>Status:</span>
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
</div>
</div>
<button @click="goToGame" class="btn btn-primary">
Join Game
</button>
</div>
</template>
</div>
</template>
<style scoped>
.invite-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.invite-card {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
text-align: center;
max-width: 400px;
width: 100%;
}
.invite-card h1 {
margin-bottom: 0.5rem;
}
.game-name {
font-size: 1.5rem;
font-weight: 600;
color: #667eea;
margin-bottom: 1rem;
}
.description {
color: #666;
margin-bottom: 1.5rem;
}
.game-info {
margin-bottom: 1.5rem;
text-align: left;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.status.draft { background: #fff3cd; color: #856404; }
.status.live { background: #d4edda; color: #155724; }
.status.ended { background: #e2e3e5; color: #383d41; }
.btn {
width: 100%;
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
border: none;
font-size: 1rem;
}
.btn-primary {
background: #667eea;
color: white;
}
.loading, .error {
text-align: center;
color: white;
}
</style>

View file

@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import type { Game, Team } from '../types';
import { gameService, teamService } from '../services/api';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const game = ref<Game | null>(null);
const teams = ref<Team[]>([]);
const loading = ref(true);
const creating = ref(false);
const joining = ref(false);
const gameId = computed(() => route.params.id as string);
const userTeam = ref<Team | null>(null);
const newTeamName = ref('');
async function loadGame() {
loading.value = true;
try {
const [gameRes, teamsRes] = await Promise.all([
gameService.get(gameId.value),
teamService.getByGame(gameId.value)
]);
game.value = gameRes.data;
teams.value = teamsRes.data;
if (authStore.user) {
userTeam.value = teams.value.find(t =>
t.members?.some(m => m.userId === authStore.user?.id)
) || null;
}
} catch (err) {
console.error('Failed to load game:', err);
} finally {
loading.value = false;
}
}
async function createTeam() {
if (!newTeamName.value.trim()) {
alert('Please enter a team name');
return;
}
creating.value = true;
try {
const response = await teamService.create(gameId.value, { name: newTeamName.value });
userTeam.value = response.data;
await loadGame();
router.push(`/games/${gameId.value}/play`);
} catch (err) {
alert('Failed to create team');
} finally {
creating.value = false;
}
}
async function joinTeam(teamId: string) {
joining.value = true;
try {
await teamService.join(teamId);
await loadGame();
router.push(`/games/${gameId.value}/play`);
} catch (err) {
alert('Failed to join team');
} finally {
joining.value = false;
}
}
function startPlaying() {
router.push(`/games/${gameId.value}/play`);
}
onMounted(() => {
loadGame();
});
</script>
<template>
<div class="join-game">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="game">
<header class="page-header">
<h1>Join: {{ game.name }}</h1>
</header>
<div v-if="userTeam" class="already-joined">
<p>You are on team: <strong>{{ userTeam.name }}</strong></p>
<button @click="startPlaying" class="btn btn-primary">
{{ game.status === 'LIVE' ? 'Start Playing' : 'View Team' }}
</button>
</div>
<div v-else class="join-content">
<section class="create-team">
<h2>Create a Team</h2>
<form @submit.prevent="createTeam">
<input
v-model="newTeamName"
type="text"
placeholder="Enter team name"
required
/>
<button type="submit" class="btn btn-primary" :disabled="creating">
{{ creating ? 'Creating...' : 'Create Team (3-5 members)' }}
</button>
</form>
</section>
<section class="join-team">
<h2>Or Join an Existing Team</h2>
<div class="teams-list">
<div v-for="team in teams" :key="team.id" class="team-item">
<div class="team-info">
<span class="team-name">{{ team.name }}</span>
<span class="team-members">{{ team.members?.length || 0 }}/5 members</span>
</div>
<button
@click="joinTeam(team.id)"
class="btn btn-secondary"
:disabled="(team.members?.length || 0) >= 5 || joining"
>
Join
</button>
</div>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.join-game {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.already-joined {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
}
.already-joined p {
margin-bottom: 1rem;
}
.join-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.create-team, .join-team {
background: white;
padding: 1.5rem;
border-radius: 8px;
}
.create-team h2, .join-team h2 {
margin-bottom: 1rem;
}
.create-team form {
display: flex;
gap: 1rem;
}
.create-team input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
}
.teams-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.team-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 6px;
}
.team-name {
font-weight: 500;
}
.team-members {
font-size: 0.875rem;
color: #666;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
border: none;
}
.btn-primary { background: #667eea; color: white; }
.btn-secondary { background: #e0e0e0; color: #333; }
.btn:disabled { opacity: 0.5; }
.loading {
text-align: center;
padding: 3rem;
}
</style>

View file

@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const email = ref('');
const password = ref('');
const error = ref('');
async function handleSubmit() {
error.value = '';
if (!email.value || !password.value) {
error.value = 'Please fill in all fields';
return;
}
const success = await authStore.login(email.value, password.value);
if (success) {
router.push('/dashboard');
} else {
error.value = 'Invalid email or password';
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-card">
<h1>Login</h1>
<form @submit.prevent="handleSubmit">
<div v-if="error" class="error">{{ error }}</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="your@email.com"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="Your password"
required
/>
</div>
<button type="submit" class="btn btn-primary" :disabled="authStore.loading">
{{ authStore.loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<p class="switch-auth">
Don't have an account? <RouterLink to="/register">Register</RouterLink>
</p>
</div>
</div>
</template>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: #f5f5f5;
}
.auth-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.auth-card h1 {
margin-bottom: 1.5rem;
text-align: center;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.btn {
width: 100%;
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.switch-auth {
text-align: center;
margin-top: 1rem;
}
.switch-auth a {
color: #667eea;
}
</style>

View file

@ -0,0 +1,453 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { io, Socket } from 'socket.io-client';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useAuthStore } from '../stores/auth';
import type { Game, Team, Leg, ChatMessage } from '../types';
import { teamService, uploadService } from '../services/api';
const route = useRoute();
const authStore = useAuthStore();
const game = ref<Game | null>(null);
const team = ref<Team | null>(null);
const loading = ref(true);
const gameId = computed(() => route.params.id as string);
let socket: Socket | null = null;
let map: L.Map | null = null;
let teamMarker: L.Marker | null = null;
let otherTeamMarkers: { [key: string]: L.Marker } = {};
const chatMessages = ref<ChatMessage[]>([]);
const chatMessage = ref('');
const showPhotoUpload = ref(false);
const photoFile = ref<File | null>(null);
const uploading = ref(false);
const currentLegIndex = computed(() => {
if (!team.value?.currentLegId || !game.value?.legs) return -1;
return game.value.legs.findIndex(l => l.id === team.value!.currentLegId);
});
const currentLeg = computed(() => {
if (currentLegIndex.value < 0 || !game.value?.legs) return null;
return game.value.legs[currentLegIndex.value];
});
async function loadTeam() {
loading.value = true;
try {
const response = await teamService.get(gameId.value);
team.value = response.data;
game.value = response.data.game;
if (team.value?.lat && team.value?.lng && map) {
teamMarker = L.marker([team.value.lat, team.value.lng]).addTo(map);
}
} catch (err) {
console.error('Failed to load team:', err);
} finally {
loading.value = false;
}
}
function initMap() {
if (!game.value?.locationLat || !game.value?.locationLng) return;
map = L.map('map').setView([game.value.locationLat, game.value.locationLng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
if (game.value.locationLat && game.value.locationLng) {
L.circle([game.value.locationLat, game.value.locationLng], {
radius: game.value.searchRadius || 500,
color: '#667eea',
fillColor: '#667eea',
fillOpacity: 0.1
}).addTo(map);
}
if (team.value?.lat && team.value?.lng) {
teamMarker = L.marker([team.value.lat, team.value.lng], {
icon: L.divIcon({
className: 'team-marker',
html: '📍',
iconSize: [32, 32]
})
}).addTo(map).bindPopup('Your Team');
}
}
function connectSocket() {
socket = io('http://localhost:3001');
socket.on('connect', () => {
socket?.emit('join-game', gameId.value);
});
socket.on('team-location', (data: { teamId: string; lat: number; lng: number }) => {
if (data.teamId === team.value?.id) return;
if (otherTeamMarkers[data.teamId]) {
otherTeamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
} else if (map) {
const marker = L.marker([data.lat, data.lng])
.addTo(map)
.bindPopup('Other Team');
otherTeamMarkers[data.teamId] = marker;
}
});
socket.on('chat-message', (data: ChatMessage) => {
chatMessages.value.push(data);
});
}
function updateLocation() {
if (!navigator.geolocation || !team.value || !socket) return;
navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude } = position.coords;
teamService.updateLocation(team.value!.id, latitude, longitude);
socket?.emit('team-location', {
gameId: gameId.value,
teamId: team.value!.id,
lat: latitude,
lng: longitude
});
if (map) {
if (!teamMarker) {
teamMarker = L.marker([latitude, longitude]).addTo(map);
} else {
teamMarker.setLatLng([latitude, longitude]);
}
}
},
(err) => console.error('Geolocation error:', err),
{ enableHighAccuracy: true, maximumAge: 10000 }
);
}
async function sendChat() {
if (!chatMessage.value.trim() || !socket) return;
socket.emit('chat-message', {
gameId: gameId.value,
teamId: team.value?.id,
message: chatMessage.value,
userId: authStore.user?.id,
userName: authStore.user?.name
});
chatMessage.value = '';
}
function handlePhotoSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files?.[0]) {
photoFile.value = input.files[0];
}
}
async function submitPhoto() {
if (!photoFile.value || !currentLeg.value || !team.value) {
alert('Please select a photo');
return;
}
uploading.value = true;
try {
const uploadRes = await uploadService.upload(photoFile.value);
await fetch(`http://localhost:3001/api/legs/${currentLeg.value.id}/photo`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify({
teamId: team.value.id,
photoUrl: uploadRes.data.url
})
});
alert('Photo submitted! Wait for Game Master approval.');
showPhotoUpload.value = false;
photoFile.value = null;
} catch (err) {
alert('Failed to submit photo');
} finally {
uploading.value = false;
}
}
function handleVisibilityChange() {
if (document.hidden) {
alert('Warning: Leaving the game tab may result in time penalties!');
if (game.value?.timeDeductionPenalty && socket) {
socket.emit('chat-message', {
gameId: gameId.value,
message: `Warning: ${authStore.user?.name} navigated away from the game`,
userId: authStore.user?.id || '',
userName: authStore.user?.name || ''
});
}
}
}
onMounted(() => {
loadTeam().then(() => {
setTimeout(initMap, 100);
connectSocket();
updateLocation();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
});
onUnmounted(() => {
if (socket) socket.disconnect();
if (map) map.remove();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<template>
<div class="play-game">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="game && team">
<header class="game-header">
<h1>{{ game.name }}</h1>
<span class="team-name">Team: {{ team.name }}</span>
</header>
<div class="game-content">
<div class="clue-section">
<div v-if="currentLeg" class="current-clue">
<h2>Current Clue</h2>
<p class="clue-text">{{ currentLeg.description }}</p>
<div class="clue-meta">
<span>Type: {{ currentLeg.conditionType }}</span>
<span v-if="currentLeg.timeLimit">Time limit: {{ currentLeg.timeLimit }} min</span>
</div>
<button
v-if="currentLeg.conditionType === 'photo'"
@click="showPhotoUpload = true"
class="btn btn-primary"
>
📷 Submit Photo Proof
</button>
</div>
<div v-else class="no-clue">
<p v-if="team.status === 'FINISHED'">🎉 Congratulations! You've completed the hunt!</p>
<p v-else>Waiting for the game to start...</p>
</div>
<div class="progress">
<h3>Progress</h3>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${((currentLegIndex + 1) / (game.legs?.length || 1)) * 100}%` }"
></div>
</div>
<p>Leg {{ currentLegIndex + 1 }} of {{ game.legs?.length || 0 }}</p>
<p v-if="team.totalTimeDeduction" class="penalty">
Time penalty: {{ team.totalTimeDeduction }} seconds
</p>
</div>
</div>
<div class="map-section">
<div id="map" class="map-container"></div>
</div>
<div class="chat-section">
<h3>Chat</h3>
<div class="chat-messages">
<div v-for="msg in chatMessages" :key="msg.id" class="chat-message">
<strong>{{ msg.userName }}:</strong> {{ msg.message }}
</div>
</div>
<form @submit.prevent="sendChat" class="chat-input">
<input v-model="chatMessage" placeholder="Type..." />
<button type="submit" class="btn btn-sm">Send</button>
</form>
</div>
</div>
<div v-if="showPhotoUpload" class="modal-overlay" @click.self="showPhotoUpload = false">
<div class="modal">
<h3>Submit Photo Proof</h3>
<input type="file" accept="image/*" @change="handlePhotoSelect" />
<div class="modal-actions">
<button @click="submitPhoto" class="btn btn-primary" :disabled="uploading">
{{ uploading ? 'Uploading...' : 'Submit' }}
</button>
<button @click="showPhotoUpload = false" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.play-game {
height: 100vh;
display: flex;
flex-direction: column;
}
.game-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #667eea;
color: white;
}
.team-name {
font-weight: 500;
}
.game-content {
display: grid;
grid-template-columns: 350px 1fr 280px;
flex: 1;
min-height: 0;
}
.clue-section, .chat-section {
background: white;
padding: 1rem;
overflow-y: auto;
}
.current-clue {
margin-bottom: 1rem;
}
.clue-text {
font-size: 1.125rem;
margin: 1rem 0;
}
.clue-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: #666;
margin-bottom: 1rem;
}
.progress {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.progress-bar {
height: 8px;
background: #eee;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: #667eea;
transition: width 0.3s;
}
.penalty {
color: #dc3545;
font-weight: 500;
}
.map-container {
height: 100%;
}
.chat-messages {
height: calc(100% - 60px);
overflow-y: auto;
}
.chat-message {
padding: 0.5rem;
border-bottom: 1px solid #eee;
font-size: 0.875rem;
}
.chat-input {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.chat-input input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
border: none;
}
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
.btn-primary { background: #667eea; color: white; }
.btn-secondary { background: #e0e0e0; color: #333; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
padding: 1.5rem;
border-radius: 8px;
width: 90%;
max-width: 400px;
}
.modal h3 {
margin-bottom: 1rem;
}
.modal input[type="file"] {
margin-bottom: 1rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
}
.loading {
text-align: center;
padding: 3rem;
}
</style>

View file

@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const name = ref('');
const email = ref('');
const password = ref('');
const error = ref('');
async function handleSubmit() {
error.value = '';
if (!name.value || !email.value || !password.value) {
error.value = 'Please fill in all fields';
return;
}
const success = await authStore.register(email.value, password.value, name.value);
if (success) {
router.push('/dashboard');
} else {
error.value = 'Registration failed. Email may already be in use.';
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-card">
<h1>Register</h1>
<form @submit.prevent="handleSubmit">
<div v-if="error" class="error">{{ error }}</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="name"
type="text"
placeholder="Your name"
required
/>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="your@email.com"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="Create a password"
required
/>
</div>
<button type="submit" class="btn btn-primary" :disabled="authStore.loading">
{{ authStore.loading ? 'Creating account...' : 'Register' }}
</button>
</form>
<p class="switch-auth">
Already have an account? <RouterLink to="/login">Login</RouterLink>
</p>
</div>
</div>
</template>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: #f5f5f5;
}
.auth-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.auth-card h1 {
margin-bottom: 1.5rem;
text-align: center;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.btn {
width: 100%;
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.switch-auth {
text-align: center;
margin-top: 1rem;
}
.switch-auth a {
color: #667eea;
}
</style>

View file

@ -0,0 +1,300 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { io, Socket } from 'socket.io-client';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { Game, Team } from '../types';
import { gameService, teamService } from '../services/api';
const route = useRoute();
const game = ref<Game | null>(null);
const teams = ref<Team[]>([]);
const loading = ref(true);
const gameId = computed(() => route.params.id as string);
let socket: Socket | null = null;
let map: L.Map | null = null;
let teamMarkers: { [key: string]: L.Marker } = {};
const leaderboard = computed(() => {
return [...teams.value]
.filter(t => t.status === 'ACTIVE' || t.status === 'FINISHED')
.sort((a, b) => {
const aLegIndex = game.value?.legs?.findIndex(l => l.id === a.currentLegId) || 0;
const bLegIndex = game.value?.legs?.findIndex(l => l.id === b.currentLegId) || 0;
if (aLegIndex !== bLegIndex) return bLegIndex - aLegIndex;
return a.totalTimeDeduction - b.totalTimeDeduction;
});
});
async function loadGame() {
loading.value = true;
try {
const [gameRes, teamsRes] = await Promise.all([
gameService.get(gameId.value),
teamService.getByGame(gameId.value)
]);
game.value = gameRes.data;
teams.value = teamsRes.data;
} catch (err) {
console.error('Failed to load game:', err);
} finally {
loading.value = false;
}
}
function initMap() {
if (!game.value?.locationLat || !game.value?.locationLng) return;
map = L.map('map').setView([game.value.locationLat, game.value.locationLng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
L.circle([game.value.locationLat, game.value.locationLng], {
radius: game.value.searchRadius || 500,
color: '#667eea',
fillColor: '#667eea',
fillOpacity: 0.1
}).addTo(map);
teams.value.forEach(team => {
if (team.lat && team.lng) {
const marker = L.marker([team.lat, team.lng])
.addTo(map!)
.bindPopup(team.name);
teamMarkers[team.id] = marker;
}
});
}
function connectSocket() {
socket = io('http://localhost:3001');
socket.on('connect', () => {
socket?.emit('join-game', gameId.value);
});
socket.on('team-location', (data: { teamId: string; lat: number; lng: number }) => {
const team = teams.value.find(t => t.id === data.teamId);
if (team) {
team.lat = data.lat;
team.lng = data.lng;
if (teamMarkers[data.teamId]) {
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
} else if (map) {
const marker = L.marker([data.lat, data.lng])
.addTo(map)
.bindPopup(team.name);
teamMarkers[data.teamId] = marker;
}
}
});
socket.on('team-advanced', () => {
loadGame();
});
}
onMounted(() => {
loadGame().then(() => {
setTimeout(initMap, 100);
connectSocket();
});
});
onUnmounted(() => {
if (socket) socket.disconnect();
if (map) map.remove();
});
</script>
<template>
<div class="spectate-game">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="game">
<header class="spectate-header">
<h1>{{ game.name }} - Spectator View</h1>
<span class="live-badge">LIVE</span>
</header>
<div class="spectate-content">
<div class="map-section">
<div id="map" class="map-container"></div>
</div>
<div class="sidebar">
<section class="leaderboard">
<h2>Leaderboard</h2>
<div class="team-list">
<div
v-for="(team, index) in leaderboard"
:key="team.id"
class="team-row"
:class="{ finished: team.status === 'FINISHED' }"
>
<span class="rank">#{{ index + 1 }}</span>
<span class="name">{{ team.name }}</span>
<span class="leg">
{{ game.legs?.findIndex(l => l.id === team.currentLegId) + 1 || 0 }}/{{ game.legs?.length || 0 }}
</span>
<span v-if="team.totalTimeDeduction" class="penalty">
-{{ team.totalTimeDeduction }}s
</span>
<span :class="['status', team.status.toLowerCase()]">{{ team.status }}</span>
</div>
</div>
</section>
<section class="teams-overview">
<h2>Teams</h2>
<div class="team-cards">
<div v-for="team in teams" :key="team.id" class="team-card">
<span class="team-name">{{ team.name }}</span>
<span :class="['status', team.status.toLowerCase()]">{{ team.status }}</span>
<div class="team-members">{{ team.members?.length || 0 }} members</div>
</div>
</div>
</section>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.spectate-game {
height: 100vh;
display: flex;
flex-direction: column;
}
.spectate-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #667eea;
color: white;
}
.live-badge {
background: #dc3545;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-weight: bold;
}
.spectate-content {
display: flex;
flex: 1;
min-height: 0;
}
.map-section {
flex: 1;
}
.map-container {
height: 100%;
}
.sidebar {
width: 320px;
background: white;
overflow-y: auto;
padding: 1rem;
}
.leaderboard, .teams-overview {
margin-bottom: 1.5rem;
}
.leaderboard h2, .teams-overview h2 {
font-size: 1rem;
margin-bottom: 1rem;
}
.team-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.team-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f9f9f9;
border-radius: 6px;
}
.team-row.finished {
background: #d4edda;
}
.rank {
font-weight: bold;
color: #667eea;
width: 30px;
}
.name {
flex: 1;
font-weight: 500;
}
.leg {
font-size: 0.875rem;
color: #666;
}
.penalty {
font-size: 0.75rem;
color: #dc3545;
}
.status {
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.625rem;
}
.status.active { background: #d4edda; color: #155724; }
.status.disqualified { background: #f8d7da; color: #721c24; }
.status.finished { background: #cce5ff; color: #004085; }
.team-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.team-card {
padding: 0.75rem;
background: #f9f9f9;
border-radius: 6px;
}
.team-name {
font-weight: 500;
}
.team-members {
font-size: 0.75rem;
color: #666;
margin-top: 0.25rem;
}
.loading {
text-align: center;
padding: 3rem;
}
</style>

View file

@ -0,0 +1,85 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('../pages/HomePage.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('../pages/LoginPage.vue'),
},
{
path: '/register',
name: 'register',
component: () => import('../pages/RegisterPage.vue'),
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../pages/DashboardPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/games/new',
name: 'create-game',
component: () => import('../pages/CreateGamePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/games/:id',
name: 'game',
component: () => import('../pages/GamePage.vue'),
},
{
path: '/games/:id/edit',
name: 'edit-game',
component: () => import('../pages/EditGamePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/games/:id/live',
name: 'game-live',
component: () => import('../pages/GameLivePage.vue'),
},
{
path: '/games/:id/join',
name: 'join-game',
component: () => import('../pages/JoinGamePage.vue'),
},
{
path: '/games/:id/play',
name: 'play-game',
component: () => import('../pages/PlayGamePage.vue'),
},
{
path: '/games/:id/spectate',
name: 'spectate-game',
component: () => import('../pages/SpectateGamePage.vue'),
},
{
path: '/invite/:code',
name: 'invite',
component: () => import('../pages/InvitePage.vue'),
},
],
});
router.beforeEach(async (to) => {
const authStore = useAuthStore();
if (!authStore.user) {
await authStore.init();
}
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
});
export default router;

View file

@ -0,0 +1,70 @@
import axios from 'axios';
import type { Game, Leg, Team, User, AuthResponse } from '../types';
const api = axios.create({
baseURL: 'http://localhost:3001/api',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export const authService = {
register: (data: { email: string; password: string; name: string }) =>
api.post<AuthResponse>('/auth/register', data),
login: (data: { email: string; password: string }) =>
api.post<AuthResponse>('/auth/login', data),
me: () => api.get<User>('/auth/me'),
};
export const gameService = {
list: (params?: { search?: string; status?: string }) =>
api.get<Game[]>('/games', { params }),
myGames: () => api.get<Game[]>('/games/my-games'),
get: (id: string) => api.get<Game>(`/games/${id}`),
getByInvite: (code: string) => api.get<Game>(`/games/invite/${code}`),
create: (data: Partial<Game>) => api.post<Game>('/games', data),
update: (id: string, data: Partial<Game>) => api.put<Game>(`/games/${id}`, data),
delete: (id: string) => api.delete(`/games/${id}`),
publish: (id: string) => api.post<Game>(`/${id}/publish`),
end: (id: string) => api.post<Game>(`/${id}/end`),
getInvite: (id: string) => api.get<{ inviteCode: string }>(`/games/${id}/invite`),
};
export const legService = {
getByGame: (gameId: string) => api.get<Leg[]>(`/legs/game/${gameId}`),
create: (gameId: string, data: Partial<Leg>) => api.post<Leg>(`/legs/game/${gameId}`, data),
update: (legId: string, data: Partial<Leg>) => api.put<Leg>(`/legs/${legId}`, data),
delete: (legId: string) => api.delete(`/legs/${legId}`),
submitPhoto: (legId: string, data: { teamId: string; photoUrl: string }) =>
api.post(`/legs/${legId}/photo`, data),
};
export const teamService = {
getByGame: (gameId: string) => api.get<Team[]>(`/teams/game/${gameId}`),
create: (gameId: string, data: { name: string }) => api.post<Team>(`/teams/game/${gameId}`, data),
get: (teamId: string) => api.get<Team>(`/teams/${teamId}`),
join: (teamId: string) => api.post(`/teams/${teamId}/join`),
leave: (teamId: string) => api.post(`/teams/${teamId}/leave`),
advance: (teamId: string) => api.post<Team>(`/teams/${teamId}/advance`),
deduct: (teamId: string, seconds: number) => api.post<Team>(`/teams/${teamId}/deduct`, { seconds }),
disqualify: (teamId: string) => api.post<Team>(`/teams/${teamId}/disqualify`),
updateLocation: (teamId: string, lat: number, lng: number) =>
api.post(`/teams/${teamId}/location`, { lat, lng }),
};
export const uploadService = {
upload: (file: File) => {
const formData = new FormData();
formData.append('photo', file);
return api.post<{ url: string }>('/upload/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
export default api;

View file

@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '../types';
import { authService } from '../services/api';
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string | null>(localStorage.getItem('token'));
const loading = ref(false);
const isLoggedIn = computed(() => !!token.value && !!user.value);
async function init() {
if (!token.value) return;
try {
const response = await authService.me();
user.value = response.data;
} catch {
logout();
}
}
async function login(email: string, password: string) {
loading.value = true;
try {
const response = await authService.login({ email, password });
token.value = response.data.token;
user.value = response.data.user;
localStorage.setItem('token', response.data.token);
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
} finally {
loading.value = false;
}
}
async function register(email: string, password: string, name: string) {
loading.value = true;
try {
const response = await authService.register({ email, password, name });
token.value = response.data.token;
user.value = response.data.user;
localStorage.setItem('token', response.data.token);
return true;
} catch (error) {
console.error('Register failed:', error);
return false;
} finally {
loading.value = false;
}
}
function logout() {
token.value = null;
user.value = null;
localStorage.removeItem('token');
}
return { user, token, loading, isLoggedIn, init, login, register, logout };
});

296
frontend/src/style.css Normal file
View file

@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View file

@ -0,0 +1,90 @@
export interface User {
id: string;
email: string;
name: string;
createdAt?: string;
}
export interface Game {
id: string;
name: string;
description?: string;
prizeDetails?: string;
visibility: 'PUBLIC' | 'PRIVATE';
startDate?: string;
locationLat?: number;
locationLng?: number;
searchRadius?: number;
timeLimitPerLeg?: number;
timeDeductionPenalty?: number;
status: 'DRAFT' | 'LIVE' | 'ENDED';
inviteCode?: string;
createdAt: string;
updatedAt: string;
gameMasterId: string;
gameMaster?: User;
legs?: Leg[];
teams?: Team[];
_count?: { teams: number; legs: number };
}
export interface Leg {
id: string;
gameId: string;
sequenceNumber: number;
description: string;
conditionType: string;
conditionDetails?: string;
locationLat?: number;
locationLng?: number;
timeLimit?: number;
}
export interface Team {
id: string;
gameId: string;
name: string;
captainId?: string;
captain?: User;
currentLegId?: string;
currentLeg?: Leg;
status: 'ACTIVE' | 'DISQUALIFIED' | 'FINISHED';
totalTimeDeduction: number;
lat?: number;
lng?: number;
rank?: number;
createdAt: string;
members?: TeamMember[];
}
export interface TeamMember {
id: string;
teamId: string;
userId: string;
user: User;
joinedAt: string;
}
export interface PhotoSubmission {
id: string;
teamId: string;
legId: string;
photoUrl: string;
approved: boolean;
submittedAt: string;
}
export interface ChatMessage {
id: string;
gameId: string;
teamId?: string;
userId: string;
userName: string;
message: string;
sentAt: string;
}
export interface AuthResponse {
token: string;
user: User;
}

View file

@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})

1
node_modules/.bin/acorn generated vendored Symbolic link
View file

@ -0,0 +1 @@
../acorn/bin/acorn

1
node_modules/.bin/bcrypt generated vendored Symbolic link
View file

@ -0,0 +1 @@
../bcryptjs/bin/bcrypt

1
node_modules/.bin/giget generated vendored Symbolic link
View file

@ -0,0 +1 @@
../giget/dist/cli.mjs

1
node_modules/.bin/jiti generated vendored Symbolic link
View file

@ -0,0 +1 @@
../jiti/lib/jiti-cli.mjs

1
node_modules/.bin/node-which generated vendored Symbolic link
View file

@ -0,0 +1 @@
../which/bin/node-which

1
node_modules/.bin/nodemon generated vendored Symbolic link
View file

@ -0,0 +1 @@
../nodemon/bin/nodemon.js

1
node_modules/.bin/nodetouch generated vendored Symbolic link
View file

@ -0,0 +1 @@
../touch/bin/nodetouch.js

1
node_modules/.bin/nypm generated vendored Symbolic link
View file

@ -0,0 +1 @@
../nypm/dist/cli.mjs

1
node_modules/.bin/pglite-server generated vendored Symbolic link
View file

@ -0,0 +1 @@
../@electric-sql/pglite-socket/dist/scripts/server.js

1
node_modules/.bin/prisma generated vendored Symbolic link
View file

@ -0,0 +1 @@
../prisma/build/index.js

1
node_modules/.bin/semver generated vendored Symbolic link
View file

@ -0,0 +1 @@
../semver/bin/semver.js

1
node_modules/.bin/ts-node generated vendored Symbolic link
View file

@ -0,0 +1 @@
../ts-node/dist/bin.js

1
node_modules/.bin/ts-node-cwd generated vendored Symbolic link
View file

@ -0,0 +1 @@
../ts-node/dist/bin-cwd.js

1
node_modules/.bin/ts-node-esm generated vendored Symbolic link
View file

@ -0,0 +1 @@
../ts-node/dist/bin-esm.js

1
node_modules/.bin/ts-node-script generated vendored Symbolic link
View file

@ -0,0 +1 @@
../ts-node/dist/bin-script.js

1
node_modules/.bin/ts-node-transpile-only generated vendored Symbolic link
View file

@ -0,0 +1 @@
../ts-node/dist/bin-transpile.js

1
node_modules/.bin/ts-script generated vendored Symbolic link
View file

@ -0,0 +1 @@
../ts-node/dist/bin-script-deprecated.js

1
node_modules/.bin/tsc generated vendored Symbolic link
View file

@ -0,0 +1 @@
../typescript/bin/tsc

1
node_modules/.bin/tsserver generated vendored Symbolic link
View file

@ -0,0 +1 @@
../typescript/bin/tsserver

1
node_modules/.bin/uuid generated vendored Symbolic link
View file

@ -0,0 +1 @@
../uuid/dist-node/bin/uuid

3213
node_modules/.package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load diff

202
node_modules/@chevrotain/cst-dts-gen/LICENSE.txt generated vendored Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,2 @@
import { Rule, GenerateDtsOptions } from "@chevrotain/types";
export declare function generateCstDts(productions: Record<string, Rule>, options?: GenerateDtsOptions): string;

27
node_modules/@chevrotain/cst-dts-gen/lib/src/api.js generated vendored Normal file
View file

@ -0,0 +1,27 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateCstDts = void 0;
var model_1 = require("./model");
var generate_1 = require("./generate");
var defaultOptions = {
includeVisitorInterface: true,
visitorInterfaceName: "ICstNodeVisitor"
};
function generateCstDts(productions, options) {
var effectiveOptions = __assign(__assign({}, defaultOptions), options);
var model = (0, model_1.buildModel)(productions);
return (0, generate_1.genDts)(model, effectiveOptions);
}
exports.generateCstDts = generateCstDts;
//# sourceMappingURL=api.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/api.ts"],"names":[],"mappings":";;;;;;;;;;;;;;AACA,iCAAoC;AACpC,uCAAmC;AAEnC,IAAM,cAAc,GAAiC;IACnD,uBAAuB,EAAE,IAAI;IAC7B,oBAAoB,EAAE,iBAAiB;CACxC,CAAA;AAED,SAAgB,cAAc,CAC5B,WAAiC,EACjC,OAA4B;IAE5B,IAAM,gBAAgB,yBACjB,cAAc,GACd,OAAO,CACX,CAAA;IAED,IAAM,KAAK,GAAG,IAAA,kBAAU,EAAC,WAAW,CAAC,CAAA;IAErC,OAAO,IAAA,iBAAM,EAAC,KAAK,EAAE,gBAAgB,CAAC,CAAA;AACxC,CAAC;AAZD,wCAYC"}

View file

@ -0,0 +1,3 @@
import { GenerateDtsOptions } from "@chevrotain/types";
import { CstNodeTypeDefinition } from "./model";
export declare function genDts(model: CstNodeTypeDefinition[], options: Required<GenerateDtsOptions>): string;

View file

@ -0,0 +1,70 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.genDts = void 0;
var flatten_1 = __importDefault(require("lodash/flatten"));
var isArray_1 = __importDefault(require("lodash/isArray"));
var map_1 = __importDefault(require("lodash/map"));
var reduce_1 = __importDefault(require("lodash/reduce"));
var uniq_1 = __importDefault(require("lodash/uniq"));
var upperFirst_1 = __importDefault(require("lodash/upperFirst"));
function genDts(model, options) {
var contentParts = [];
contentParts = contentParts.concat("import type { CstNode, ICstVisitor, IToken } from \"chevrotain\";");
contentParts = contentParts.concat((0, flatten_1.default)((0, map_1.default)(model, function (node) { return genCstNodeTypes(node); })));
if (options.includeVisitorInterface) {
contentParts = contentParts.concat(genVisitor(options.visitorInterfaceName, model));
}
return contentParts.join("\n\n") + "\n";
}
exports.genDts = genDts;
function genCstNodeTypes(node) {
var nodeCstInterface = genNodeInterface(node);
var nodeChildrenInterface = genNodeChildrenType(node);
return [nodeCstInterface, nodeChildrenInterface];
}
function genNodeInterface(node) {
var nodeInterfaceName = getNodeInterfaceName(node.name);
var childrenTypeName = getNodeChildrenTypeName(node.name);
return "export interface ".concat(nodeInterfaceName, " extends CstNode {\n name: \"").concat(node.name, "\";\n children: ").concat(childrenTypeName, ";\n}");
}
function genNodeChildrenType(node) {
var typeName = getNodeChildrenTypeName(node.name);
return "export type ".concat(typeName, " = {\n ").concat((0, map_1.default)(node.properties, function (property) { return genChildProperty(property); }).join("\n "), "\n};");
}
function genChildProperty(prop) {
var typeName = buildTypeString(prop.type);
return "".concat(prop.name).concat(prop.optional ? "?" : "", ": ").concat(typeName, "[];");
}
function genVisitor(name, nodes) {
return "export interface ".concat(name, "<IN, OUT> extends ICstVisitor<IN, OUT> {\n ").concat((0, map_1.default)(nodes, function (node) { return genVisitorFunction(node); }).join("\n "), "\n}");
}
function genVisitorFunction(node) {
var childrenTypeName = getNodeChildrenTypeName(node.name);
return "".concat(node.name, "(children: ").concat(childrenTypeName, ", param?: IN): OUT;");
}
function buildTypeString(type) {
if ((0, isArray_1.default)(type)) {
var typeNames = (0, uniq_1.default)((0, map_1.default)(type, function (t) { return getTypeString(t); }));
var typeString = (0, reduce_1.default)(typeNames, function (sum, t) { return sum + " | " + t; });
return "(" + typeString + ")";
}
else {
return getTypeString(type);
}
}
function getTypeString(type) {
if (type.kind === "token") {
return "IToken";
}
return getNodeInterfaceName(type.name);
}
function getNodeInterfaceName(ruleName) {
return (0, upperFirst_1.default)(ruleName) + "CstNode";
}
function getNodeChildrenTypeName(ruleName) {
return (0, upperFirst_1.default)(ruleName) + "CstChildren";
}
//# sourceMappingURL=generate.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"generate.js","sourceRoot":"","sources":["../../src/generate.ts"],"names":[],"mappings":";;;;;;AAAA,2DAAoC;AACpC,2DAAoC;AACpC,mDAA4B;AAC5B,yDAAkC;AAClC,qDAA8B;AAC9B,iEAA0C;AAU1C,SAAgB,MAAM,CACpB,KAA8B,EAC9B,OAAqC;IAErC,IAAI,YAAY,GAAa,EAAE,CAAA;IAE/B,YAAY,GAAG,YAAY,CAAC,MAAM,CAChC,mEAAiE,CAClE,CAAA;IAED,YAAY,GAAG,YAAY,CAAC,MAAM,CAChC,IAAA,iBAAO,EAAC,IAAA,aAAG,EAAC,KAAK,EAAE,UAAC,IAAI,IAAK,OAAA,eAAe,CAAC,IAAI,CAAC,EAArB,CAAqB,CAAC,CAAC,CACrD,CAAA;IAED,IAAI,OAAO,CAAC,uBAAuB,EAAE;QACnC,YAAY,GAAG,YAAY,CAAC,MAAM,CAChC,UAAU,CAAC,OAAO,CAAC,oBAAoB,EAAE,KAAK,CAAC,CAChD,CAAA;KACF;IAED,OAAO,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,IAAI,CAAA;AACzC,CAAC;AArBD,wBAqBC;AAED,SAAS,eAAe,CAAC,IAA2B;IAClD,IAAM,gBAAgB,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAA;IAC/C,IAAM,qBAAqB,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAA;IAEvD,OAAO,CAAC,gBAAgB,EAAE,qBAAqB,CAAC,CAAA;AAClD,CAAC;AAED,SAAS,gBAAgB,CAAC,IAA2B;IACnD,IAAM,iBAAiB,GAAG,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACzD,IAAM,gBAAgB,GAAG,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAE3D,OAAO,2BAAoB,iBAAiB,2CACnC,IAAI,CAAC,IAAI,8BACN,gBAAgB,SAC5B,CAAA;AACF,CAAC;AAED,SAAS,mBAAmB,CAAC,IAA2B;IACtD,IAAM,QAAQ,GAAG,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAEnD,OAAO,sBAAe,QAAQ,qBAC5B,IAAA,aAAG,EAAC,IAAI,CAAC,UAAU,EAAE,UAAC,QAAQ,IAAK,OAAA,gBAAgB,CAAC,QAAQ,CAAC,EAA1B,CAA0B,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,SAC5E,CAAA;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,IAA4B;IACpD,IAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC3C,OAAO,UAAG,IAAI,CAAC,IAAI,SAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,eAAK,QAAQ,QAAK,CAAA;AAClE,CAAC;AAED,SAAS,UAAU,CAAC,IAAY,EAAE,KAA8B;IAC9D,OAAO,2BAAoB,IAAI,yDAC7B,IAAA,aAAG,EAAC,KAAK,EAAE,UAAC,IAAI,IAAK,OAAA,kBAAkB,CAAC,IAAI,CAAC,EAAxB,CAAwB,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAC7D,CAAA;AACF,CAAC;AAED,SAAS,kBAAkB,CAAC,IAA2B;IACrD,IAAM,gBAAgB,GAAG,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC3D,OAAO,UAAG,IAAI,CAAC,IAAI,wBAAc,gBAAgB,wBAAqB,CAAA;AACxE,CAAC;AAED,SAAS,eAAe,CAAC,IAAuB;IAC9C,IAAI,IAAA,iBAAO,EAAC,IAAI,CAAC,EAAE;QACjB,IAAM,SAAS,GAAG,IAAA,cAAI,EAAC,IAAA,aAAG,EAAC,IAAI,EAAE,UAAC,CAAC,IAAK,OAAA,aAAa,CAAC,CAAC,CAAC,EAAhB,CAAgB,CAAC,CAAC,CAAA;QAC1D,IAAM,UAAU,GAAG,IAAA,gBAAM,EAAC,SAAS,EAAE,UAAC,GAAG,EAAE,CAAC,IAAK,OAAA,GAAG,GAAG,KAAK,GAAG,CAAC,EAAf,CAAe,CAAC,CAAA;QACjE,OAAO,GAAG,GAAG,UAAU,GAAG,GAAG,CAAA;KAC9B;SAAM;QACL,OAAO,aAAa,CAAC,IAAI,CAAC,CAAA;KAC3B;AACH,CAAC;AAED,SAAS,aAAa,CAAC,IAAoC;IACzD,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,EAAE;QACzB,OAAO,QAAQ,CAAA;KAChB;IACD,OAAO,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACxC,CAAC;AAED,SAAS,oBAAoB,CAAC,QAAgB;IAC5C,OAAO,IAAA,oBAAU,EAAC,QAAQ,CAAC,GAAG,SAAS,CAAA;AACzC,CAAC;AAED,SAAS,uBAAuB,CAAC,QAAgB;IAC/C,OAAO,IAAA,oBAAU,EAAC,QAAQ,CAAC,GAAG,aAAa,CAAA;AAC7C,CAAC"}

View file

@ -0,0 +1,19 @@
import type { Rule } from "@chevrotain/types";
export declare function buildModel(productions: Record<string, Rule>): CstNodeTypeDefinition[];
export type CstNodeTypeDefinition = {
name: string;
properties: PropertyTypeDefinition[];
};
export type PropertyTypeDefinition = {
name: string;
type: PropertyArrayType;
optional: boolean;
};
export type PropertyArrayType = TokenArrayType | RuleArrayType | (TokenArrayType | RuleArrayType)[];
export type TokenArrayType = {
kind: "token";
};
export type RuleArrayType = {
kind: "rule";
name: string;
};

Some files were not shown because too many files have changed in this diff Show more