Updated the styles to use Pico-css
This commit is contained in:
parent
d32233a20a
commit
9acde36f50
18 changed files with 691 additions and 2126 deletions
|
|
@ -119,6 +119,7 @@ enum GameStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
LIVE
|
LIVE
|
||||||
ENDED
|
ENDED
|
||||||
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TeamStatus {
|
enum TeamStatus {
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,56 @@ router.post('/:id/end', authenticate, async (req: AuthRequest, res: Response) =>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/:id/archive', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
|
||||||
|
const game = await prisma.game.findUnique({ where: { id } });
|
||||||
|
if (!game) {
|
||||||
|
return res.status(404).json({ error: 'Game not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.gameMasterId !== req.user!.id) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.game.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'ARCHIVED' }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Archive game error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to archive game' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/unarchive', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
|
||||||
|
const game = await prisma.game.findUnique({ where: { id } });
|
||||||
|
if (!game) {
|
||||||
|
return res.status(404).json({ error: 'Game not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.gameMasterId !== req.user!.id) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.game.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'ENDED' }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unarchive game error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to unarchive game' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:id/invite', async (req: AuthRequest, res: Response) => {
|
router.get('/:id/invite', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<title>Treasure Trails</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -96,191 +96,80 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="create-game">
|
<main class="container">
|
||||||
<header class="page-header">
|
<article>
|
||||||
<h1>Create New Game</h1>
|
<h1>Create New Game</h1>
|
||||||
</header>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="game-form">
|
<form @submit.prevent="handleSubmit">
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>Basic Information</h2>
|
<h2>Basic Information</h2>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="name">Game Name *</label>
|
<label for="name">Game Name *</label>
|
||||||
<input id="name" v-model="name" type="text" required placeholder="Enter game name" />
|
<input id="name" v-model="name" type="text" required placeholder="Enter game name" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="description">Description</label>
|
<label for="description">Description</label>
|
||||||
<textarea id="description" v-model="description" rows="3" placeholder="Describe your scavenger hunt"></textarea>
|
<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>
|
<label for="prizeDetails">Prize Details</label>
|
||||||
<input id="prizeDetails" v-model="prizeDetails" type="text" placeholder="What's the prize?" />
|
<input id="prizeDetails" v-model="prizeDetails" type="text" placeholder="What's the prize?" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="visibility">Visibility</label>
|
<label for="visibility">Visibility</label>
|
||||||
<select id="visibility" v-model="visibility">
|
<select id="visibility" v-model="visibility">
|
||||||
<option value="PUBLIC">Public</option>
|
<option value="PUBLIC">Public</option>
|
||||||
<option value="PRIVATE">Private (Invite Only)</option>
|
<option value="PRIVATE">Private (Invite Only)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="startDate">Start Date & Time</label>
|
<label for="startDate">Start Date & Time</label>
|
||||||
<input id="startDate" v-model="startDate" type="datetime-local" />
|
<input id="startDate" v-model="startDate" type="datetime-local" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>Location</h2>
|
<h2>Location</h2>
|
||||||
<p class="hint">Click on the map to set the treasure location</p>
|
<p><small>Click on the map to set the treasure location</small></p>
|
||||||
|
|
||||||
<div ref="mapContainer" class="map-container"></div>
|
<div ref="mapContainer" class="map-container"></div>
|
||||||
|
|
||||||
<div class="location-inputs">
|
<div class="grid">
|
||||||
<div class="form-group">
|
<label>
|
||||||
<label>Latitude</label>
|
Latitude
|
||||||
<input v-model.number="locationLat" type="number" step="any" readonly />
|
<input v-model.number="locationLat" type="number" step="any" readonly />
|
||||||
</div>
|
</label>
|
||||||
<div class="form-group">
|
<label>
|
||||||
<label>Longitude</label>
|
Longitude
|
||||||
<input v-model.number="locationLng" type="number" step="any" readonly />
|
<input v-model.number="locationLng" type="number" step="any" readonly />
|
||||||
</div>
|
</label>
|
||||||
<div class="form-group">
|
<label for="searchRadius">
|
||||||
<label for="searchRadius">Search Radius (meters)</label>
|
Search Radius (meters)
|
||||||
<input id="searchRadius" v-model.number="searchRadius" type="number" min="100" />
|
<input id="searchRadius" v-model.number="searchRadius" type="number" min="100" />
|
||||||
</div>
|
</label>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>Game Rules</h2>
|
<h2>Game Rules</h2>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeLimitPerLeg">Time Limit per Leg (minutes)</label>
|
<label for="timeLimitPerLeg">Time Limit per Leg (minutes)</label>
|
||||||
<input id="timeLimitPerLeg" v-model.number="timeLimitPerLeg" type="number" min="1" />
|
<input id="timeLimitPerLeg" v-model.number="timeLimitPerLeg" type="number" min="1" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timeDeductionPenalty">Navigation Warning Penalty (seconds)</label>
|
<label for="timeDeductionPenalty">Navigation Warning Penalty (seconds)</label>
|
||||||
<input id="timeDeductionPenalty" v-model.number="timeDeductionPenalty" type="number" min="0" />
|
<input id="timeDeductionPenalty" v-model.number="timeDeductionPenalty" type="number" min="0" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="grid">
|
||||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
<button type="submit" :disabled="loading">
|
||||||
{{ loading ? 'Creating...' : 'Create Game' }}
|
{{ loading ? 'Creating...' : 'Create Game' }}
|
||||||
</button>
|
</button>
|
||||||
<RouterLink to="/dashboard" class="btn btn-secondary">Cancel</RouterLink>
|
<RouterLink to="/dashboard" role="button" class="secondary">Cancel</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</article>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.map-container {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
border-radius: 8px;
|
border-radius: var(--pico-border-radius);
|
||||||
margin-bottom: 1rem;
|
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 {
|
.error {
|
||||||
background: #fee;
|
color: var(--pico-del-color);
|
||||||
color: #c00;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import type { Game } from '../types';
|
import type { Game } from '../types';
|
||||||
|
|
@ -8,6 +8,17 @@ import { gameService } from '../services/api';
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const games = ref<Game[]>([]);
|
const games = ref<Game[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const showArchived = ref(false);
|
||||||
|
|
||||||
|
const filteredGames = computed(() => {
|
||||||
|
if (showArchived.value) {
|
||||||
|
return games.value.filter(g => g.status === 'ARCHIVED');
|
||||||
|
}
|
||||||
|
return games.value.filter(g => g.status !== 'ARCHIVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCount = computed(() => games.value.filter(g => g.status !== 'ARCHIVED').length);
|
||||||
|
const archivedCount = computed(() => games.value.filter(g => g.status === 'ARCHIVED').length);
|
||||||
|
|
||||||
async function loadGames() {
|
async function loadGames() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
@ -27,175 +38,65 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<main class="container">
|
||||||
<header class="dashboard-header">
|
<nav aria-label="breadcrumb">
|
||||||
<h1>My Dashboard</h1>
|
<ul>
|
||||||
<div class="user-info">
|
<li><strong>Welcome, {{ authStore.user?.name }}</strong></li>
|
||||||
<span>Welcome, {{ authStore.user?.name }}</span>
|
</ul>
|
||||||
<button @click="authStore.logout()" class="btn btn-logout">Logout</button>
|
<ul>
|
||||||
</div>
|
<li><button @click="authStore.logout()" class="secondary">Logout</button></li>
|
||||||
</header>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<section class="games-section">
|
<section>
|
||||||
<div class="section-header">
|
<div class="grid">
|
||||||
<h2>My Games</h2>
|
<h1>My Games</h1>
|
||||||
<RouterLink to="/games/new" class="btn btn-primary">Create New Game</RouterLink>
|
<RouterLink to="/games/new" role="button">Create New Game</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading">Loading games...</div>
|
<button
|
||||||
|
v-if="archivedCount > 0"
|
||||||
|
@click="showArchived = !showArchived"
|
||||||
|
class="secondary"
|
||||||
|
>
|
||||||
|
{{ showArchived ? `Show Active (${activeCount})` : `Archived (${archivedCount})` }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div v-else-if="games.length === 0" class="empty">
|
<article v-if="loading">Loading games...</article>
|
||||||
<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">
|
<article v-else-if="filteredGames.length === 0">
|
||||||
<div v-for="game in games" :key="game.id" class="game-item">
|
<p>{{ showArchived ? 'No archived games.' : 'You haven\'t created any games yet.' }}</p>
|
||||||
<div class="game-info">
|
<RouterLink v-if="!showArchived" to="/games/new" role="button">Create Your First Game</RouterLink>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div v-else class="grid">
|
||||||
|
<article v-for="game in filteredGames" :key="game.id">
|
||||||
<h3>{{ game.name }}</h3>
|
<h3>{{ game.name }}</h3>
|
||||||
<div class="game-meta">
|
<footer>
|
||||||
|
<small>
|
||||||
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
|
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
|
||||||
<span>{{ game._count?.legs || 0 }} legs</span>
|
· {{ game._count?.legs || 0 }} legs
|
||||||
<span>{{ game._count?.teams || 0 }} teams</span>
|
· {{ game._count?.teams || 0 }} teams
|
||||||
</div>
|
</small>
|
||||||
</div>
|
</footer>
|
||||||
<div class="game-actions">
|
<RouterLink :to="`/games/${game.id}`" role="button">View</RouterLink>
|
||||||
<RouterLink :to="`/games/${game.id}`" class="btn btn-secondary">View</RouterLink>
|
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" role="button" class="secondary">Edit</RouterLink>
|
||||||
<RouterLink
|
<RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/live`" role="button">Live Dashboard</RouterLink>
|
||||||
v-if="game.status === 'DRAFT'"
|
</article>
|
||||||
: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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.status {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.draft {
|
.status.draft { background: var(--pico-mark-background-color); }
|
||||||
background: #fff3cd;
|
.status.live { background: var(--pico-ins-background-color); }
|
||||||
color: #856404;
|
.status.ended { background: var(--pico-muted-color); color: var(--pico-muted-border-color); }
|
||||||
}
|
.status.archived { background: var(--pico-muted-color); color: var(--pico-muted-border-color); }
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -178,224 +178,91 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="edit-game">
|
<main class="container">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<article v-if="loading">Loading...</article>
|
||||||
|
|
||||||
<template v-else-if="game">
|
<template v-else-if="game">
|
||||||
<header class="page-header">
|
<nav aria-label="breadcrumb">
|
||||||
<div>
|
<ul>
|
||||||
<RouterLink :to="`/games/${game.id}`" class="back-link">← Back to Game</RouterLink>
|
<li><RouterLink :to="`/games/${game.id}`">← Back to Game</RouterLink></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<h1>Edit: {{ game.name }}</h1>
|
<h1>Edit: {{ game.name }}</h1>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="edit-content">
|
<div class="grid">
|
||||||
<section class="legs-section">
|
<article>
|
||||||
<h2>Legs ({{ legs.length }})</h2>
|
<h2>Legs ({{ legs.length }})</h2>
|
||||||
<p class="hint">Total route distance: {{ getTotalDistance().toFixed(2) }} km</p>
|
<p><small>Total route distance: {{ getTotalDistance().toFixed(2) }} km</small></p>
|
||||||
|
|
||||||
<div v-if="legs.length" class="legs-list">
|
<article v-if="legs.length" v-for="(leg, index) in legs" :key="leg.id">
|
||||||
<div v-for="(leg, index) in legs" :key="leg.id" class="leg-item">
|
<h3>Leg {{ index + 1 }}</h3>
|
||||||
<div class="leg-number">{{ index + 1 }}</div>
|
|
||||||
<div class="leg-info">
|
|
||||||
<p>{{ leg.description }}</p>
|
<p>{{ leg.description }}</p>
|
||||||
<div class="leg-meta">
|
<footer>
|
||||||
<span>Type: {{ leg.conditionType }}</span>
|
<small>
|
||||||
<span v-if="leg.timeLimit">{{ leg.timeLimit }} min</span>
|
Type: {{ leg.conditionType }}
|
||||||
|
<span v-if="leg.timeLimit"> · {{ leg.timeLimit }} min</span>
|
||||||
<span v-if="leg.locationLat && leg.locationLng">
|
<span v-if="leg.locationLat && leg.locationLng">
|
||||||
{{ leg.locationLat.toFixed(4) }}, {{ leg.locationLng.toFixed(4) }}
|
· {{ leg.locationLat.toFixed(4) }}, {{ leg.locationLng.toFixed(4) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</small>
|
||||||
</div>
|
</footer>
|
||||||
<button @click="deleteLeg(leg.id)" class="btn btn-danger">Delete</button>
|
<button @click="deleteLeg(leg.id)" class="secondary">Delete</button>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
<p v-else>No legs added yet</p>
|
||||||
<div v-else class="empty">No legs added yet</div>
|
</article>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="add-leg-section">
|
<article>
|
||||||
<h2>Add New Leg</h2>
|
<h2>Add New Leg</h2>
|
||||||
|
|
||||||
<div ref="mapContainer" class="map-container"></div>
|
<div ref="mapContainer" class="map-container"></div>
|
||||||
<p class="hint">Click on map to set location</p>
|
<p><small>Click on map to set location</small></p>
|
||||||
|
|
||||||
<form @submit.prevent="addLeg" class="leg-form">
|
<form @submit.prevent="addLeg">
|
||||||
<div class="form-group">
|
|
||||||
<label>Description / Clue</label>
|
<label>Description / Clue</label>
|
||||||
<textarea v-model="newLeg.description" rows="2" required></textarea>
|
<textarea v-model="newLeg.description" rows="2" required></textarea>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="grid">
|
||||||
<div class="form-group">
|
<label>
|
||||||
<label>Condition Type</label>
|
Condition Type
|
||||||
<select v-model="newLeg.conditionType">
|
<select v-model="newLeg.conditionType">
|
||||||
<option value="photo">Photo Proof</option>
|
<option value="photo">Photo Proof</option>
|
||||||
<option value="purchase">Purchase</option>
|
<option value="purchase">Purchase</option>
|
||||||
<option value="text">Text Answer</option>
|
<option value="text">Text Answer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div class="form-group">
|
<label>
|
||||||
<label>Time Limit (minutes)</label>
|
Time Limit (minutes)
|
||||||
<input v-model.number="newLeg.timeLimit" type="number" min="1" />
|
<input v-model.number="newLeg.timeLimit" type="number" min="1" />
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="grid">
|
||||||
<div class="form-group">
|
<label>
|
||||||
<label>Latitude</label>
|
Latitude
|
||||||
<input v-model.number="newLeg.locationLat" type="number" step="any" readonly />
|
<input v-model.number="newLeg.locationLat" type="number" step="any" readonly />
|
||||||
</div>
|
</label>
|
||||||
<div class="form-group">
|
<label>
|
||||||
<label>Longitude</label>
|
Longitude
|
||||||
<input v-model.number="newLeg.locationLng" type="number" step="any" readonly />
|
<input v-model.number="newLeg.locationLng" type="number" step="any" readonly />
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
<button type="submit" :disabled="saving">
|
||||||
{{ saving ? 'Adding...' : 'Add Leg' }}
|
{{ saving ? 'Adding...' : 'Add Leg' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.map-container {
|
||||||
height: 250px;
|
height: 250px;
|
||||||
border-radius: 8px;
|
border-radius: var(--pico-border-radius);
|
||||||
margin-bottom: 0.5rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -164,240 +164,114 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="live-dashboard">
|
<main class="live-dashboard">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<article v-if="loading">Loading...</article>
|
||||||
|
|
||||||
<template v-else-if="game">
|
<template v-else-if="game">
|
||||||
<header class="dashboard-header">
|
<nav aria-label="breadcrumb">
|
||||||
<h1>{{ game.name }} - Live Dashboard</h1>
|
<ul>
|
||||||
<span class="live-badge">LIVE</span>
|
<li><strong>{{ game.name }}</strong></li>
|
||||||
</header>
|
<li><mark>LIVE</mark></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="dashboard-content">
|
<div class="grid" style="grid-template-columns: 1fr 300px 300px;">
|
||||||
<div class="map-section">
|
<section>
|
||||||
<div id="map" class="map-container"></div>
|
<div id="map" class="map-container"></div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="teams-section">
|
<section>
|
||||||
<h2>Teams ({{ teams.length }})</h2>
|
<h2>Teams ({{ teams.length }})</h2>
|
||||||
<div class="teams-list">
|
<article
|
||||||
<div
|
|
||||||
v-for="team in teams"
|
v-for="team in teams"
|
||||||
:key="team.id"
|
:key="team.id"
|
||||||
class="team-card"
|
|
||||||
:class="{ selected: selectedTeam?.id === team.id }"
|
:class="{ selected: selectedTeam?.id === team.id }"
|
||||||
|
style="margin-bottom: 0.5rem; cursor: pointer;"
|
||||||
@click="selectTeam(team)"
|
@click="selectTeam(team)"
|
||||||
>
|
>
|
||||||
<div class="team-header">
|
<h3 style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span class="team-name">{{ team.name }}</span>
|
{{ team.name }}
|
||||||
<span :class="['status', team.status.toLowerCase()]">{{ team.status }}</span>
|
<span :class="['status', team.status.toLowerCase()]">{{ team.status }}</span>
|
||||||
</div>
|
</h3>
|
||||||
<div class="team-info">
|
<footer>
|
||||||
<span>{{ team.members?.length || 0 }} members</span>
|
<small>
|
||||||
<span>Leg {{ game.legs?.findIndex(l => l.id === team.currentLegId) + 1 || 0 }} / {{ game.legs?.length || 0 }}</span>
|
{{ team.members?.length || 0 }} members ·
|
||||||
<span v-if="team.totalTimeDeduction">-{{ team.totalTimeDeduction }}s</span>
|
Leg {{ game.legs?.findIndex(l => l.id === team.currentLegId) + 1 || 0 }} / {{ game.legs?.length || 0 }}
|
||||||
</div>
|
<span v-if="team.totalTimeDeduction"> · -{{ team.totalTimeDeduction }}s</span>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<div v-if="selectedTeam?.id === team.id" class="team-actions">
|
<div v-if="selectedTeam?.id === team.id" style="margin-top: 1rem;">
|
||||||
<button
|
<button
|
||||||
@click.stop="advanceTeam(team.id)"
|
@click.stop="advanceTeam(team.id)"
|
||||||
class="btn btn-sm btn-success"
|
|
||||||
:disabled="team.status !== 'ACTIVE'"
|
:disabled="team.status !== 'ACTIVE'"
|
||||||
>
|
>
|
||||||
Advance
|
Advance
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click.stop="deductTime(team.id)"
|
@click.stop="deductTime(team.id)"
|
||||||
class="btn btn-sm btn-warning"
|
class="secondary"
|
||||||
:disabled="team.status !== 'ACTIVE'"
|
:disabled="team.status !== 'ACTIVE'"
|
||||||
>
|
>
|
||||||
Deduct Time
|
Deduct Time
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click.stop="disqualifyTeam(team.id)"
|
@click.stop="disqualifyTeam(team.id)"
|
||||||
class="btn btn-sm btn-danger"
|
class="contrast"
|
||||||
:disabled="team.status !== 'ACTIVE'"
|
:disabled="team.status !== 'ACTIVE'"
|
||||||
>
|
>
|
||||||
Disqualify
|
Disqualify
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-section">
|
<section>
|
||||||
<h2>Chat</h2>
|
<h2>Chat</h2>
|
||||||
<div class="chat-messages">
|
<div class="chat-messages">
|
||||||
<div v-for="msg in chatMessages" :key="msg.id" class="chat-message">
|
<article v-for="msg in chatMessages" :key="msg.id" style="margin: 0.5rem 0; padding: 0.5rem;">
|
||||||
<strong>{{ msg.userName }}:</strong> {{ msg.message }}
|
<strong>{{ msg.userName }}:</strong> {{ msg.message }}
|
||||||
</div>
|
</article>
|
||||||
<div v-if="!chatMessages.length" class="empty-chat">
|
<article v-if="!chatMessages.length" style="text-align: center; color: var(--pico-muted-color);">
|
||||||
No messages yet
|
No messages yet
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<form @submit.prevent="sendChat" class="grid">
|
||||||
<form @submit.prevent="sendChat" class="chat-input">
|
|
||||||
<input v-model="chatMessage" placeholder="Type a message..." />
|
<input v-model="chatMessage" placeholder="Type a message..." />
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.live-dashboard {
|
.live-dashboard {
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1rem;
|
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 {
|
.map-container {
|
||||||
flex: 1;
|
height: calc(100vh - 200px);
|
||||||
border-radius: 8px;
|
border-radius: var(--pico-border-radius);
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.chat-messages {
|
||||||
flex: 1;
|
height: calc(100% - 60px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message {
|
.selected {
|
||||||
padding: 0.5rem;
|
border: 2px solid var(--pico-primary-focus);
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.status {
|
||||||
display: flex;
|
padding: 0.2rem 0.5rem;
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm { padding: 0.25rem 0.5rem; }
|
.status.active { background: var(--pico-ins-background-color); }
|
||||||
.btn-primary { background: #667eea; color: white; }
|
.status.disqualified { background: var(--pico-del-background-color); }
|
||||||
.btn-success { background: #28a745; color: white; }
|
.status.finished { background: var(--pico-primary-background-color); color: var(--pico-primary-color); }
|
||||||
.btn-warning { background: #ffc107; color: #333; }
|
|
||||||
.btn-danger { background: #dc3545; color: white; }
|
|
||||||
|
|
||||||
.empty-chat {
|
|
||||||
text-align: center;
|
|
||||||
color: #999;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,28 @@ async function publishGame() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function archiveGame() {
|
||||||
|
if (!confirm('Are you sure you want to archive this game?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gameService.archive(gameId.value);
|
||||||
|
await loadGame();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to archive game');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unarchiveGame() {
|
||||||
|
if (!confirm('Are you sure you want to unarchive this game?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gameService.unarchive(gameId.value);
|
||||||
|
await loadGame();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to unarchive game');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function endGame() {
|
async function endGame() {
|
||||||
if (!confirm('Are you sure you want to end this game?')) return;
|
if (!confirm('Are you sure you want to end this game?')) return;
|
||||||
|
|
||||||
|
|
@ -67,64 +89,52 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="game-page">
|
<main class="container">
|
||||||
<div v-if="loading" class="loading">Loading game...</div>
|
<article v-if="loading">Loading game...</article>
|
||||||
<div v-else-if="error" class="error">{{ error }}</div>
|
<article v-else-if="error">{{ error }}</article>
|
||||||
|
|
||||||
<template v-else-if="game">
|
<template v-else-if="game">
|
||||||
<header class="game-header">
|
<nav aria-label="breadcrumb">
|
||||||
<div>
|
<ul>
|
||||||
|
<li><RouterLink to="/dashboard">Dashboard</RouterLink></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<hgroup>
|
||||||
<h1>{{ game.name }}</h1>
|
<h1>{{ game.name }}</h1>
|
||||||
<div class="game-meta">
|
<p>
|
||||||
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
|
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
|
||||||
<span>{{ game.visibility }}</span>
|
· {{ game.visibility }}
|
||||||
<span v-if="game.startDate">Starts: {{ new Date(game.startDate).toLocaleString() }}</span>
|
<span v-if="game.startDate">· Starts: {{ new Date(game.startDate).toLocaleString() }}</span>
|
||||||
</div>
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<div v-if="isGameMaster" class="grid">
|
||||||
|
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" role="button" class="secondary">Edit Game</RouterLink>
|
||||||
|
<button v-if="game.status === 'DRAFT'" @click="publishGame">Publish Game</button>
|
||||||
|
<RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/live`" role="button">Live Dashboard</RouterLink>
|
||||||
|
<button v-if="game.status === 'LIVE'" @click="endGame" class="contrast">End Game</button>
|
||||||
|
<button v-if="game.status === 'ENDED'" @click="archiveGame" class="secondary">Archive Game</button>
|
||||||
|
<button v-if="game.status === 'ARCHIVED'" @click="unarchiveGame" class="secondary">Unarchive Game</button>
|
||||||
|
<button @click="copyInviteLink" class="secondary outline">Copy Invite Link</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isGameMaster" class="gm-actions">
|
<div v-else class="grid">
|
||||||
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" class="btn btn-secondary">
|
<RouterLink v-if="game.status === 'DRAFT' || game.status === 'LIVE'" :to="`/games/${game.id}/join`" role="button">Join Game</RouterLink>
|
||||||
Edit Game
|
<RouterLink v-if="game.status !== 'DRAFT'" :to="`/games/${game.id}/spectate`" role="button" class="secondary">Spectate</RouterLink>
|
||||||
</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>
|
||||||
|
|
||||||
<div v-else class="player-actions">
|
<section v-if="game.description">
|
||||||
<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>
|
<h2>Description</h2>
|
||||||
<p>{{ game.description }}</p>
|
<p>{{ game.description }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="game.prizeDetails" class="game-section">
|
<section v-if="game.prizeDetails">
|
||||||
<h2>Prize</h2>
|
<h2>Prize</h2>
|
||||||
<p>{{ game.prizeDetails }}</p>
|
<p>{{ game.prizeDetails }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="game-section">
|
<section>
|
||||||
<h2>Game Details</h2>
|
<h2>Game Details</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Location</dt>
|
<dt>Location</dt>
|
||||||
|
|
@ -144,178 +154,67 @@ onMounted(() => {
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="game.legs?.length" class="game-section">
|
<section v-if="game.legs?.length">
|
||||||
<h2>Legs ({{ game.legs.length }})</h2>
|
<h2>Legs ({{ game.legs.length }})</h2>
|
||||||
<div class="legs-list">
|
<table>
|
||||||
<div v-for="leg in game.legs" :key="leg.id" class="leg-item">
|
<thead>
|
||||||
<div class="leg-number">{{ leg.sequenceNumber }}</div>
|
<tr>
|
||||||
<div class="leg-content">
|
<th>#</th>
|
||||||
<p>{{ leg.description }}</p>
|
<th>Description</th>
|
||||||
<div class="leg-meta">
|
<th>Type</th>
|
||||||
<span>Type: {{ leg.conditionType }}</span>
|
<th>Time</th>
|
||||||
<span v-if="leg.timeLimit">Time: {{ leg.timeLimit }} min</span>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
</div>
|
<tbody>
|
||||||
</div>
|
<tr v-for="leg in game.legs" :key="leg.id">
|
||||||
</div>
|
<td>{{ leg.sequenceNumber }}</td>
|
||||||
|
<td>{{ leg.description }}</td>
|
||||||
|
<td>{{ leg.conditionType }}</td>
|
||||||
|
<td>{{ leg.timeLimit ? `${leg.timeLimit} min` : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="game.teams?.length" class="game-section">
|
<section v-if="game.teams?.length">
|
||||||
<h2>Teams ({{ game.teams.length }})</h2>
|
<h2>Teams ({{ game.teams.length }})</h2>
|
||||||
<div class="teams-list">
|
<table>
|
||||||
<div v-for="team in game.teams" :key="team.id" class="team-item">
|
<thead>
|
||||||
<span class="team-name">{{ team.name }}</span>
|
<tr>
|
||||||
<span :class="['team-status', team.status.toLowerCase()]">{{ team.status }}</span>
|
<th>Name</th>
|
||||||
<span class="team-members">{{ team.members?.length || 0 }} members</span>
|
<th>Status</th>
|
||||||
</div>
|
<th>Members</th>
|
||||||
</div>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="team in game.teams" :key="team.id">
|
||||||
|
<td>{{ team.name }}</td>
|
||||||
|
<td><span :class="['status', team.status.toLowerCase()]">{{ team.status }}</span></td>
|
||||||
|
<td>{{ team.members?.length || 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.status {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.draft { background: #fff3cd; color: #856404; }
|
.status.draft { background: var(--pico-mark-background-color); }
|
||||||
.status.live { background: #d4edda; color: #155724; }
|
.status.live { background: var(--pico-ins-background-color); }
|
||||||
.status.ended { background: #e2e3e5; color: #383d41; }
|
.status.ended { background: var(--pico-muted-color); color: var(--pico-muted-border-color); }
|
||||||
|
.status.archived { background: var(--pico-muted-color); color: var(--pico-muted-border-color); }
|
||||||
|
.status.active { background: var(--pico-ins-background-color); }
|
||||||
|
.status.disqualified { background: var(--pico-del-background-color); }
|
||||||
|
.status.finished { background: var(--pico-primary-background-color); color: var(--pico-primary-background-color); }
|
||||||
|
|
||||||
.gm-actions, .player-actions {
|
dt {
|
||||||
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;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -26,160 +26,80 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<main class="container">
|
||||||
<header class="hero">
|
<section aria-label="Hero">
|
||||||
|
<hgroup>
|
||||||
<h1>Treasure Trails</h1>
|
<h1>Treasure Trails</h1>
|
||||||
<p>Online scavenger hunt adventure</p>
|
<p>Online scavenger hunt adventure</p>
|
||||||
<div class="hero-actions">
|
</hgroup>
|
||||||
<RouterLink to="/register" class="btn btn-primary">Get Started</RouterLink>
|
<div class="grid">
|
||||||
<RouterLink to="/login" class="btn btn-secondary">Login</RouterLink>
|
<RouterLink to="/register" role="button">Get Started</RouterLink>
|
||||||
|
<RouterLink to="/login" role="button" class="secondary">Login</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</section>
|
||||||
|
|
||||||
<section class="games-section">
|
<section aria-label="Active Games">
|
||||||
<h2>Active Public Games</h2>
|
<h2>Active Public Games</h2>
|
||||||
|
|
||||||
<div class="search-bar">
|
<form @submit.prevent="loadGames">
|
||||||
|
<div class="grid">
|
||||||
<input
|
<input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search games..."
|
placeholder="Search games..."
|
||||||
@keyup.enter="loadGames"
|
|
||||||
/>
|
/>
|
||||||
<button @click="loadGames" class="btn">Search</button>
|
<button type="submit">Search</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div v-if="loading" class="loading">Loading games...</div>
|
<article v-if="loading">Loading games...</article>
|
||||||
|
|
||||||
<div v-else-if="games.length === 0" class="empty">
|
<article v-else-if="games.length === 0">
|
||||||
No active games found. Be the first to create one!
|
<p>No active games found. Be the first to create one!</p>
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
<div v-else class="games-grid">
|
<div v-else class="grid">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="game in games"
|
v-for="game in games"
|
||||||
:key="game.id"
|
:key="game.id"
|
||||||
:to="`/games/${game.id}`"
|
:to="`/games/${game.id}`"
|
||||||
class="game-card"
|
class="card"
|
||||||
>
|
>
|
||||||
<h3>{{ game.name }}</h3>
|
<h3>{{ game.name }}</h3>
|
||||||
<p v-if="game.description">{{ game.description.slice(0, 100) }}...</p>
|
<p v-if="game.description">{{ game.description.slice(0, 100) }}...</p>
|
||||||
<div class="game-meta">
|
<footer>
|
||||||
<span>{{ game._count?.legs || 0 }} legs</span>
|
<small>{{ game._count?.legs || 0 }} legs · {{ game._count?.teams || 0 }} teams</small>
|
||||||
<span>{{ game._count?.teams || 0 }} teams</span>
|
<small v-if="game.startDate">{{ new Date(game.startDate).toLocaleDateString() }}</small>
|
||||||
<span v-if="game.startDate">{{ new Date(game.startDate).toLocaleDateString() }}</span>
|
</footer>
|
||||||
</div>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home {
|
.card {
|
||||||
max-width: 1200px;
|
display: block;
|
||||||
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;
|
padding: 1.5rem;
|
||||||
border: 1px solid #ddd;
|
border: var(--pico-border-width) solid var(--pico-muted-border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--pico-border-radius);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-card:hover {
|
.card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
box-shadow: var(--pico-box-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-card h3 {
|
.card h3 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-meta {
|
.card footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
justify-content: space-between;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading, .empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import type { Game } from '../types';
|
import type { Game } from '../types';
|
||||||
import { gameService } from '../services/api';
|
import { gameService } from '../services/api';
|
||||||
|
|
@ -13,8 +13,6 @@ const error = ref('');
|
||||||
|
|
||||||
const inviteCode = computed(() => route.params.code as string);
|
const inviteCode = computed(() => route.params.code as string);
|
||||||
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
|
@ -41,116 +39,48 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="invite-page">
|
<main class="container">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<article v-if="loading">Loading...</article>
|
||||||
<div v-else-if="error" class="error">{{ error }}</div>
|
<article v-else-if="error" class="error">{{ error }}</article>
|
||||||
|
|
||||||
<template v-else-if="game">
|
<template v-else-if="game">
|
||||||
<div class="invite-card">
|
|
||||||
<h1>You're Invited!</h1>
|
<h1>You're Invited!</h1>
|
||||||
<p class="game-name">{{ game.name }}</p>
|
<h2>{{ game.name }}</h2>
|
||||||
|
|
||||||
<div v-if="game.description" class="description">
|
<p v-if="game.description">{{ game.description }}</p>
|
||||||
{{ game.description }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="game-info">
|
<dl>
|
||||||
<div class="info-row">
|
<dt>Game Master</dt>
|
||||||
<span>Game Master:</span>
|
<dd>{{ game.gameMaster?.name }}</dd>
|
||||||
<span>{{ game.gameMaster?.name }}</span>
|
|
||||||
</div>
|
<dt v-if="game.startDate">Start Date</dt>
|
||||||
<div v-if="game.startDate" class="info-row">
|
<dd v-if="game.startDate">{{ new Date(game.startDate).toLocaleString() }}</dd>
|
||||||
<span>Start Date:</span>
|
|
||||||
<span>{{ new Date(game.startDate).toLocaleString() }}</span>
|
<dt>Status</dt>
|
||||||
</div>
|
<dd>
|
||||||
<div class="info-row">
|
|
||||||
<span>Status:</span>
|
|
||||||
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
|
<span :class="['status', game.status.toLowerCase()]">{{ game.status }}</span>
|
||||||
</div>
|
</dd>
|
||||||
</div>
|
</dl>
|
||||||
|
|
||||||
<button @click="goToGame" class="btn btn-primary">
|
<button @click="goToGame">
|
||||||
Join Game
|
Join Game
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.invite-page {
|
.error {
|
||||||
min-height: 100vh;
|
color: var(--pico-del-color);
|
||||||
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 {
|
.status {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.draft { background: #fff3cd; color: #856404; }
|
.status.draft { background: var(--pico-mark-background-color); }
|
||||||
.status.live { background: #d4edda; color: #155724; }
|
.status.live { background: var(--pico-ins-background-color); }
|
||||||
.status.ended { background: #e2e3e5; color: #383d41; }
|
.status.ended { background: var(--pico-muted-color); color: var(--pico-muted-border-color); }
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -84,147 +84,52 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="join-game">
|
<main class="container">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<article v-if="loading">Loading...</article>
|
||||||
|
|
||||||
<template v-else-if="game">
|
<template v-else-if="game">
|
||||||
<header class="page-header">
|
|
||||||
<h1>Join: {{ game.name }}</h1>
|
<h1>Join: {{ game.name }}</h1>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="userTeam" class="already-joined">
|
<article v-if="userTeam">
|
||||||
<p>You are on team: <strong>{{ userTeam.name }}</strong></p>
|
<p>You are on team: <strong>{{ userTeam.name }}</strong></p>
|
||||||
<button @click="startPlaying" class="btn btn-primary">
|
<button @click="startPlaying">
|
||||||
{{ game.status === 'LIVE' ? 'Start Playing' : 'View Team' }}
|
{{ game.status === 'LIVE' ? 'Start Playing' : 'View Team' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
<div v-else class="join-content">
|
<template v-else>
|
||||||
<section class="create-team">
|
<article>
|
||||||
<h2>Create a Team</h2>
|
<h2>Create a Team</h2>
|
||||||
<form @submit.prevent="createTeam">
|
<form @submit.prevent="createTeam">
|
||||||
|
<div class="grid">
|
||||||
<input
|
<input
|
||||||
v-model="newTeamName"
|
v-model="newTeamName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter team name"
|
placeholder="Enter team name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="creating">
|
<button type="submit" :disabled="creating">
|
||||||
{{ creating ? 'Creating...' : 'Create Team (3-5 members)' }}
|
{{ creating ? 'Creating...' : 'Create Team' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
</form>
|
||||||
|
<p><small>Teams can have 3-5 members</small></p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<h2>Or Join an Existing Team</h2>
|
||||||
|
<article v-for="team in teams" :key="team.id">
|
||||||
|
<h3>{{ team.name }}</h3>
|
||||||
|
<footer>
|
||||||
|
<small>{{ team.members?.length || 0 }}/5 members</small>
|
||||||
|
</footer>
|
||||||
<button
|
<button
|
||||||
@click="joinTeam(team.id)"
|
@click="joinTeam(team.id)"
|
||||||
class="btn btn-secondary"
|
|
||||||
:disabled="(team.members?.length || 0) >= 5 || joining"
|
:disabled="(team.members?.length || 0) >= 5 || joining"
|
||||||
>
|
>
|
||||||
Join
|
Join
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</template>
|
||||||
|
</main>
|
||||||
</template>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,11 @@ async function handleSubmit() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="auth-page">
|
<main class="container">
|
||||||
<div class="auth-card">
|
<article>
|
||||||
<h1>Login</h1>
|
<h1>Login</h1>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
|
@ -45,9 +42,7 @@ async function handleSubmit() {
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
|
|
@ -56,92 +51,21 @@ async function handleSubmit() {
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="authStore.loading">
|
<button type="submit" :disabled="authStore.loading">
|
||||||
{{ authStore.loading ? 'Logging in...' : 'Login' }}
|
{{ authStore.loading ? 'Logging in...' : 'Login' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="switch-auth">
|
<p>Don't have an account? <RouterLink to="/register">Register</RouterLink></p>
|
||||||
Don't have an account? <RouterLink to="/register">Register</RouterLink>
|
</article>
|
||||||
</p>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.error {
|
||||||
background: #fee;
|
color: var(--pico-del-color);
|
||||||
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { io, Socket } from 'socket.io-client';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import type { Game, Team, Leg, ChatMessage } from '../types';
|
import type { Game, Team, ChatMessage } from '../types';
|
||||||
import { teamService, uploadService } from '../services/api';
|
import { teamService, uploadService } from '../services/api';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
@ -219,165 +219,99 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="play-game">
|
<main class="play-game">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<article v-if="loading">Loading...</article>
|
||||||
|
|
||||||
<template v-else-if="game && team">
|
<template v-else-if="game && team">
|
||||||
<header class="game-header">
|
<nav aria-label="breadcrumb">
|
||||||
<h1>{{ game.name }}</h1>
|
<ul>
|
||||||
<span class="team-name">Team: {{ team.name }}</span>
|
<li><strong>{{ game.name }}</strong></li>
|
||||||
</header>
|
<li>Team: {{ team.name }}</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="game-content">
|
<div class="grid" style="grid-template-columns: 350px 1fr 280px;">
|
||||||
<div class="clue-section">
|
<section>
|
||||||
<div v-if="currentLeg" class="current-clue">
|
<article v-if="currentLeg">
|
||||||
<h2>Current Clue</h2>
|
<h2>Current Clue</h2>
|
||||||
<p class="clue-text">{{ currentLeg.description }}</p>
|
<p>{{ currentLeg.description }}</p>
|
||||||
<div class="clue-meta">
|
<footer>
|
||||||
<span>Type: {{ currentLeg.conditionType }}</span>
|
<small>
|
||||||
<span v-if="currentLeg.timeLimit">Time limit: {{ currentLeg.timeLimit }} min</span>
|
Type: {{ currentLeg.conditionType }}
|
||||||
</div>
|
<span v-if="currentLeg.timeLimit"> · Time limit: {{ currentLeg.timeLimit }} min</span>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
<button
|
<button
|
||||||
v-if="currentLeg.conditionType === 'photo'"
|
v-if="currentLeg.conditionType === 'photo'"
|
||||||
@click="showPhotoUpload = true"
|
@click="showPhotoUpload = true"
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
>
|
||||||
📷 Submit Photo Proof
|
📷 Submit Photo Proof
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
<div v-else class="no-clue">
|
<article v-else>
|
||||||
<p v-if="team.status === 'FINISHED'">🎉 Congratulations! You've completed the hunt!</p>
|
<p v-if="team.status === 'FINISHED'">🎉 Congratulations! You've completed the hunt!</p>
|
||||||
<p v-else>Waiting for the game to start...</p>
|
<p v-else>Waiting for the game to start...</p>
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
<div class="progress">
|
<article>
|
||||||
<h3>Progress</h3>
|
<h3>Progress</h3>
|
||||||
<div class="progress-bar">
|
<progress
|
||||||
<div
|
:value="currentLegIndex + 1"
|
||||||
class="progress-fill"
|
:max="game.legs?.length || 1"
|
||||||
:style="{ width: `${((currentLegIndex + 1) / (game.legs?.length || 1)) * 100}%` }"
|
></progress>
|
||||||
></div>
|
<p><small>Leg {{ currentLegIndex + 1 }} of {{ game.legs?.length || 0 }}</small></p>
|
||||||
</div>
|
<p v-if="team.totalTimeDeduction" class="error">
|
||||||
<p>Leg {{ currentLegIndex + 1 }} of {{ game.legs?.length || 0 }}</p>
|
<small>Time penalty: {{ team.totalTimeDeduction }} seconds</small>
|
||||||
<p v-if="team.totalTimeDeduction" class="penalty">
|
|
||||||
Time penalty: {{ team.totalTimeDeduction }} seconds
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="map-section">
|
<section>
|
||||||
<div id="map" class="map-container"></div>
|
<div id="map" class="map-container"></div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="chat-section">
|
<section>
|
||||||
<h3>Chat</h3>
|
<h3>Chat</h3>
|
||||||
<div class="chat-messages">
|
<div class="chat-messages">
|
||||||
<div v-for="msg in chatMessages" :key="msg.id" class="chat-message">
|
<article v-for="msg in chatMessages" :key="msg.id" style="margin: 0.5rem 0; padding: 0.5rem;">
|
||||||
<strong>{{ msg.userName }}:</strong> {{ msg.message }}
|
<strong>{{ msg.userName }}:</strong> {{ msg.message }}
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<form @submit.prevent="sendChat" class="grid">
|
||||||
<form @submit.prevent="sendChat" class="chat-input">
|
|
||||||
<input v-model="chatMessage" placeholder="Type..." />
|
<input v-model="chatMessage" placeholder="Type..." />
|
||||||
<button type="submit" class="btn btn-sm">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showPhotoUpload" class="modal-overlay" @click.self="showPhotoUpload = false">
|
<dialog :open="showPhotoUpload">
|
||||||
<div class="modal">
|
<article>
|
||||||
<h3>Submit Photo Proof</h3>
|
<h3>Submit Photo Proof</h3>
|
||||||
|
<label>
|
||||||
|
Select Photo
|
||||||
<input type="file" accept="image/*" @change="handlePhotoSelect" />
|
<input type="file" accept="image/*" @change="handlePhotoSelect" />
|
||||||
<div class="modal-actions">
|
</label>
|
||||||
<button @click="submitPhoto" class="btn btn-primary" :disabled="uploading">
|
<footer class="grid">
|
||||||
|
<button @click="submitPhoto" :disabled="uploading">
|
||||||
{{ uploading ? 'Uploading...' : 'Submit' }}
|
{{ uploading ? 'Uploading...' : 'Submit' }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="showPhotoUpload = false" class="btn btn-secondary">Cancel</button>
|
<button @click="showPhotoUpload = false" class="secondary">Cancel</button>
|
||||||
</div>
|
</footer>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.play-game {
|
.play-game {
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
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 {
|
.map-container {
|
||||||
height: 100%;
|
height: calc(100vh - 200px);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
|
|
@ -385,69 +319,7 @@ onUnmounted(() => {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message {
|
.error {
|
||||||
padding: 0.5rem;
|
color: var(--pico-del-color);
|
||||||
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,11 @@ async function handleSubmit() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="auth-page">
|
<main class="container">
|
||||||
<div class="auth-card">
|
<article>
|
||||||
<h1>Register</h1>
|
<h1>Register</h1>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
|
|
@ -46,9 +43,7 @@ async function handleSubmit() {
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
|
@ -57,9 +52,7 @@ async function handleSubmit() {
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
|
|
@ -68,92 +61,21 @@ async function handleSubmit() {
|
||||||
placeholder="Create a password"
|
placeholder="Create a password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="authStore.loading">
|
<button type="submit" :disabled="authStore.loading">
|
||||||
{{ authStore.loading ? 'Creating account...' : 'Register' }}
|
{{ authStore.loading ? 'Creating account...' : 'Register' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="switch-auth">
|
<p>Already have an account? <RouterLink to="/login">Login</RouterLink></p>
|
||||||
Already have an account? <RouterLink to="/login">Login</RouterLink>
|
</article>
|
||||||
</p>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.error {
|
||||||
background: #fee;
|
color: var(--pico-del-color);
|
||||||
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -115,186 +115,83 @@ onUnmounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="spectate-game">
|
<main class="spectate-game">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<article v-if="loading">Loading...</article>
|
||||||
|
|
||||||
<template v-else-if="game">
|
<template v-else-if="game">
|
||||||
<header class="spectate-header">
|
<nav aria-label="breadcrumb">
|
||||||
<h1>{{ game.name }} - Spectator View</h1>
|
<ul>
|
||||||
<span class="live-badge">LIVE</span>
|
<li><strong>{{ game.name }}</strong></li>
|
||||||
</header>
|
<li><mark>SPECTATING</mark></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="spectate-content">
|
<div class="grid" style="grid-template-columns: 1fr 320px;">
|
||||||
<div class="map-section">
|
<section>
|
||||||
<div id="map" class="map-container"></div>
|
<div id="map" class="map-container"></div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="sidebar">
|
<aside>
|
||||||
<section class="leaderboard">
|
|
||||||
<h2>Leaderboard</h2>
|
<h2>Leaderboard</h2>
|
||||||
<div class="team-list">
|
<article
|
||||||
<div
|
|
||||||
v-for="(team, index) in leaderboard"
|
v-for="(team, index) in leaderboard"
|
||||||
:key="team.id"
|
:key="team.id"
|
||||||
class="team-row"
|
:class="{ 'finished': team.status === 'FINISHED' }"
|
||||||
:class="{ finished: team.status === 'FINISHED' }"
|
style="margin-bottom: 0.5rem;"
|
||||||
>
|
>
|
||||||
<span class="rank">#{{ index + 1 }}</span>
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span class="name">{{ team.name }}</span>
|
<strong>#{{ index + 1 }} {{ team.name }}</strong>
|
||||||
<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>
|
<span :class="['status', team.status.toLowerCase()]">{{ team.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<footer>
|
||||||
</section>
|
<small>
|
||||||
|
Leg {{ game.legs?.findIndex(l => l.id === team.currentLegId) + 1 || 0 }}/{{ game.legs?.length || 0 }}
|
||||||
|
<span v-if="team.totalTimeDeduction" class="error"> · -{{ team.totalTimeDeduction }}s</span>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
<section class="teams-overview">
|
|
||||||
<h2>Teams</h2>
|
<h2>Teams</h2>
|
||||||
<div class="team-cards">
|
<article v-for="team in teams" :key="team.id" style="margin-bottom: 0.5rem;">
|
||||||
<div v-for="team in teams" :key="team.id" class="team-card">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span class="team-name">{{ team.name }}</span>
|
<strong>{{ team.name }}</strong>
|
||||||
<span :class="['status', team.status.toLowerCase()]">{{ team.status }}</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>
|
||||||
|
<footer>
|
||||||
|
<small>{{ team.members?.length || 0 }} members</small>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.spectate-game {
|
.spectate-game {
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spectate-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 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 {
|
.map-container {
|
||||||
height: 100%;
|
height: calc(100vh - 200px);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.finished {
|
||||||
width: 320px;
|
background: var(--pico-ins-background-color) !important;
|
||||||
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 {
|
.status {
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 4px;
|
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;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.status.active { background: var(--pico-ins-background-color); }
|
||||||
text-align: center;
|
.status.disqualified { background: var(--pico-del-background-color); }
|
||||||
padding: 3rem;
|
.status.finished { background: var(--pico-primary-background-color); color: var(--pico-primary-color); }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--pico-del-color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,10 @@ export const gameService = {
|
||||||
create: (data: Partial<Game>) => api.post<Game>('/games', data),
|
create: (data: Partial<Game>) => api.post<Game>('/games', data),
|
||||||
update: (id: string, data: Partial<Game>) => api.put<Game>(`/games/${id}`, data),
|
update: (id: string, data: Partial<Game>) => api.put<Game>(`/games/${id}`, data),
|
||||||
delete: (id: string) => api.delete(`/games/${id}`),
|
delete: (id: string) => api.delete(`/games/${id}`),
|
||||||
publish: (id: string) => api.post<Game>(`/${id}/publish`),
|
publish: (id: string) => api.post<Game>(`/games/${id}/publish`),
|
||||||
end: (id: string) => api.post<Game>(`/${id}/end`),
|
end: (id: string) => api.post<Game>(`/games/${id}/end`),
|
||||||
|
archive: (id: string) => api.post<Game>(`/games/${id}/archive`),
|
||||||
|
unarchive: (id: string) => api.post<Game>(`/games/${id}/unarchive`),
|
||||||
getInvite: (id: string) => api.get<{ inviteCode: string }>(`/games/${id}/invite`),
|
getInvite: (id: string) => api.get<{ inviteCode: string }>(`/games/${id}/invite`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,296 +1,7 @@
|
||||||
: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 {
|
body {
|
||||||
margin: 0;
|
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 {
|
#app {
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface Game {
|
||||||
searchRadius?: number;
|
searchRadius?: number;
|
||||||
timeLimitPerLeg?: number;
|
timeLimitPerLeg?: number;
|
||||||
timeDeductionPenalty?: number;
|
timeDeductionPenalty?: number;
|
||||||
status: 'DRAFT' | 'LIVE' | 'ENDED';
|
status: 'DRAFT' | 'LIVE' | 'ENDED' | 'ARCHIVED';
|
||||||
inviteCode?: string;
|
inviteCode?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue