Initial commit
This commit is contained in:
commit
b3a51a4115
10336 changed files with 2381973 additions and 0 deletions
222
README.md
Normal file
222
README.md
Normal 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
171
application_requirements.md
Normal 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 OpenStreetMap’s 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
4
backend/.env.example
Normal 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
5
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal 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
2
backend/dist/index.d.ts
vendored
Normal 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
46
backend/dist/index.js
vendored
Normal 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
10
backend/dist/middleware/auth.d.ts
vendored
Normal 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
54
backend/dist/middleware/auth.js
vendored
Normal 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
2
backend/dist/routes/auth.d.ts
vendored
Normal 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
83
backend/dist/routes/auth.js
vendored
Normal 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
2
backend/dist/routes/games.d.ts
vendored
Normal 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
248
backend/dist/routes/games.js
vendored
Normal 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
2
backend/dist/routes/legs.d.ts
vendored
Normal 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
148
backend/dist/routes/legs.js
vendored
Normal 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
2
backend/dist/routes/teams.d.ts
vendored
Normal 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
290
backend/dist/routes/teams.js
vendored
Normal 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
2
backend/dist/routes/upload.d.ts
vendored
Normal 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
47
backend/dist/routes/upload.js
vendored
Normal 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
2
backend/dist/socket/index.d.ts
vendored
Normal 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
54
backend/dist/socket/index.js
vendored
Normal 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
2335
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
42
backend/package.json
Normal file
42
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
128
backend/prisma/schema.prisma
Normal file
128
backend/prisma/schema.prisma
Normal 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
49
backend/src/index.ts
Normal 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();
|
||||||
|
});
|
||||||
63
backend/src/middleware/auth.ts
Normal file
63
backend/src/middleware/auth.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
94
backend/src/routes/auth.ts
Normal file
94
backend/src/routes/auth.ts
Normal 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
294
backend/src/routes/games.ts
Normal 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
175
backend/src/routes/legs.ts
Normal 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
341
backend/src/routes/teams.ts
Normal 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;
|
||||||
47
backend/src/routes/upload.ts
Normal file
47
backend/src/routes/upload.ts
Normal 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;
|
||||||
61
backend/src/socket/index.ts
Normal file
61
backend/src/socket/index.ts
Normal 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
17
backend/tsconfig.json
Normal 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
47
docker-compose.yml
Normal 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
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal 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
5
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
28
frontend/nginx.conf
Normal 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
1823
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
7
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
93
frontend/src/components/HelloWorld.vue
Normal file
93
frontend/src/components/HelloWorld.vue
Normal 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
12
frontend/src/main.ts
Normal 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')
|
||||||
286
frontend/src/pages/CreateGamePage.vue
Normal file
286
frontend/src/pages/CreateGamePage.vue
Normal 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: '© <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>
|
||||||
201
frontend/src/pages/DashboardPage.vue
Normal file
201
frontend/src/pages/DashboardPage.vue
Normal 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>
|
||||||
401
frontend/src/pages/EditGamePage.vue
Normal file
401
frontend/src/pages/EditGamePage.vue
Normal 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: '© 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">← 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>
|
||||||
403
frontend/src/pages/GameLivePage.vue
Normal file
403
frontend/src/pages/GameLivePage.vue
Normal 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: '© 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>
|
||||||
321
frontend/src/pages/GamePage.vue
Normal file
321
frontend/src/pages/GamePage.vue
Normal 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>
|
||||||
185
frontend/src/pages/HomePage.vue
Normal file
185
frontend/src/pages/HomePage.vue
Normal 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>
|
||||||
156
frontend/src/pages/InvitePage.vue
Normal file
156
frontend/src/pages/InvitePage.vue
Normal 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>
|
||||||
230
frontend/src/pages/JoinGamePage.vue
Normal file
230
frontend/src/pages/JoinGamePage.vue
Normal 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>
|
||||||
147
frontend/src/pages/LoginPage.vue
Normal file
147
frontend/src/pages/LoginPage.vue
Normal 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>
|
||||||
453
frontend/src/pages/PlayGamePage.vue
Normal file
453
frontend/src/pages/PlayGamePage.vue
Normal 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: '© 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>
|
||||||
159
frontend/src/pages/RegisterPage.vue
Normal file
159
frontend/src/pages/RegisterPage.vue
Normal 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>
|
||||||
300
frontend/src/pages/SpectateGamePage.vue
Normal file
300
frontend/src/pages/SpectateGamePage.vue
Normal 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: '© 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>
|
||||||
85
frontend/src/router/index.ts
Normal file
85
frontend/src/router/index.ts
Normal 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;
|
||||||
70
frontend/src/services/api.ts
Normal file
70
frontend/src/services/api.ts
Normal 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;
|
||||||
62
frontend/src/stores/auth.ts
Normal file
62
frontend/src/stores/auth.ts
Normal 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
296
frontend/src/style.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
frontend/src/types/index.ts
Normal file
90
frontend/src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal 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
1
node_modules/.bin/acorn
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../acorn/bin/acorn
|
||||||
1
node_modules/.bin/bcrypt
generated
vendored
Symbolic link
1
node_modules/.bin/bcrypt
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../bcryptjs/bin/bcrypt
|
||||||
1
node_modules/.bin/giget
generated
vendored
Symbolic link
1
node_modules/.bin/giget
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../giget/dist/cli.mjs
|
||||||
1
node_modules/.bin/jiti
generated
vendored
Symbolic link
1
node_modules/.bin/jiti
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../jiti/lib/jiti-cli.mjs
|
||||||
1
node_modules/.bin/node-which
generated
vendored
Symbolic link
1
node_modules/.bin/node-which
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../which/bin/node-which
|
||||||
1
node_modules/.bin/nodemon
generated
vendored
Symbolic link
1
node_modules/.bin/nodemon
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../nodemon/bin/nodemon.js
|
||||||
1
node_modules/.bin/nodetouch
generated
vendored
Symbolic link
1
node_modules/.bin/nodetouch
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../touch/bin/nodetouch.js
|
||||||
1
node_modules/.bin/nypm
generated
vendored
Symbolic link
1
node_modules/.bin/nypm
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../nypm/dist/cli.mjs
|
||||||
1
node_modules/.bin/pglite-server
generated
vendored
Symbolic link
1
node_modules/.bin/pglite-server
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../@electric-sql/pglite-socket/dist/scripts/server.js
|
||||||
1
node_modules/.bin/prisma
generated
vendored
Symbolic link
1
node_modules/.bin/prisma
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../prisma/build/index.js
|
||||||
1
node_modules/.bin/semver
generated
vendored
Symbolic link
1
node_modules/.bin/semver
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../semver/bin/semver.js
|
||||||
1
node_modules/.bin/ts-node
generated
vendored
Symbolic link
1
node_modules/.bin/ts-node
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../ts-node/dist/bin.js
|
||||||
1
node_modules/.bin/ts-node-cwd
generated
vendored
Symbolic link
1
node_modules/.bin/ts-node-cwd
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../ts-node/dist/bin-cwd.js
|
||||||
1
node_modules/.bin/ts-node-esm
generated
vendored
Symbolic link
1
node_modules/.bin/ts-node-esm
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../ts-node/dist/bin-esm.js
|
||||||
1
node_modules/.bin/ts-node-script
generated
vendored
Symbolic link
1
node_modules/.bin/ts-node-script
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../ts-node/dist/bin-script.js
|
||||||
1
node_modules/.bin/ts-node-transpile-only
generated
vendored
Symbolic link
1
node_modules/.bin/ts-node-transpile-only
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../ts-node/dist/bin-transpile.js
|
||||||
1
node_modules/.bin/ts-script
generated
vendored
Symbolic link
1
node_modules/.bin/ts-script
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../ts-node/dist/bin-script-deprecated.js
|
||||||
1
node_modules/.bin/tsc
generated
vendored
Symbolic link
1
node_modules/.bin/tsc
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../typescript/bin/tsc
|
||||||
1
node_modules/.bin/tsserver
generated
vendored
Symbolic link
1
node_modules/.bin/tsserver
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../typescript/bin/tsserver
|
||||||
1
node_modules/.bin/uuid
generated
vendored
Symbolic link
1
node_modules/.bin/uuid
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../uuid/dist-node/bin/uuid
|
||||||
3213
node_modules/.package-lock.json
generated
vendored
Normal file
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
202
node_modules/@chevrotain/cst-dts-gen/LICENSE.txt
generated
vendored
Normal 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.
|
||||||
2
node_modules/@chevrotain/cst-dts-gen/lib/src/api.d.ts
generated
vendored
Normal file
2
node_modules/@chevrotain/cst-dts-gen/lib/src/api.d.ts
generated
vendored
Normal 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
27
node_modules/@chevrotain/cst-dts-gen/lib/src/api.js
generated
vendored
Normal 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
|
||||||
1
node_modules/@chevrotain/cst-dts-gen/lib/src/api.js.map
generated
vendored
Normal file
1
node_modules/@chevrotain/cst-dts-gen/lib/src/api.js.map
generated
vendored
Normal 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"}
|
||||||
3
node_modules/@chevrotain/cst-dts-gen/lib/src/generate.d.ts
generated
vendored
Normal file
3
node_modules/@chevrotain/cst-dts-gen/lib/src/generate.d.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { GenerateDtsOptions } from "@chevrotain/types";
|
||||||
|
import { CstNodeTypeDefinition } from "./model";
|
||||||
|
export declare function genDts(model: CstNodeTypeDefinition[], options: Required<GenerateDtsOptions>): string;
|
||||||
70
node_modules/@chevrotain/cst-dts-gen/lib/src/generate.js
generated
vendored
Normal file
70
node_modules/@chevrotain/cst-dts-gen/lib/src/generate.js
generated
vendored
Normal 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
|
||||||
1
node_modules/@chevrotain/cst-dts-gen/lib/src/generate.js.map
generated
vendored
Normal file
1
node_modules/@chevrotain/cst-dts-gen/lib/src/generate.js.map
generated
vendored
Normal 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"}
|
||||||
19
node_modules/@chevrotain/cst-dts-gen/lib/src/model.d.ts
generated
vendored
Normal file
19
node_modules/@chevrotain/cst-dts-gen/lib/src/model.d.ts
generated
vendored
Normal 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
Loading…
Add table
Add a link
Reference in a new issue