Initial commit
This commit is contained in:
commit
b3a51a4115
10336 changed files with 2381973 additions and 0 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue