Initial commit

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

24
frontend/.gitignore vendored Normal file
View file

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

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

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

19
frontend/Dockerfile Normal file
View file

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

5
frontend/README.md Normal file
View file

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

13
frontend/index.html Normal file
View file

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

28
frontend/nginx.conf Normal file
View file

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

1823
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
frontend/package.json Normal file
View file

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

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

After

Width:  |  Height:  |  Size: 496 B

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

7
frontend/tsconfig.json Normal file
View file

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

View file

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

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

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