Updating navigation and logged in checks.
This commit is contained in:
parent
9acde36f50
commit
59e15cfde8
11 changed files with 316 additions and 43 deletions
|
|
@ -14,4 +14,4 @@ COPY . .
|
|||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["sh", "-c", "npx prisma db push && npm run dev"]
|
||||
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && npm run dev"]
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ model Game {
|
|||
locationLat Float?
|
||||
locationLng Float?
|
||||
searchRadius Float?
|
||||
timeLimitPerLeg Int?
|
||||
timeDeductionPenalty Int?
|
||||
rules String?
|
||||
status GameStatus @default(DRAFT)
|
||||
inviteCode String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
|
|
|||
|
|
@ -82,7 +82,12 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
res.json(game);
|
||||
const result = {
|
||||
...game,
|
||||
rules: game.rules ? JSON.parse(game.rules) : []
|
||||
};
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Get game error:', error);
|
||||
res.status(500).json({ error: 'Failed to get game' });
|
||||
|
|
@ -93,7 +98,7 @@ router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
try {
|
||||
const {
|
||||
name, description, prizeDetails, visibility, startDate,
|
||||
locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty
|
||||
locationLat, locationLng, searchRadius, rules
|
||||
} = req.body;
|
||||
|
||||
if (!name) {
|
||||
|
|
@ -116,8 +121,7 @@ router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
locationLat,
|
||||
locationLng,
|
||||
searchRadius,
|
||||
timeLimitPerLeg,
|
||||
timeDeductionPenalty,
|
||||
rules: rules ? JSON.stringify(rules) : null,
|
||||
gameMasterId: req.user!.id,
|
||||
inviteCode
|
||||
}
|
||||
|
|
@ -135,7 +139,7 @@ router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
const id = req.params.id as string;
|
||||
const {
|
||||
name, description, prizeDetails, visibility, startDate,
|
||||
locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty, status
|
||||
locationLat, locationLng, searchRadius, rules, status
|
||||
} = req.body;
|
||||
|
||||
const game = await prisma.game.findUnique({ where: { id } });
|
||||
|
|
@ -158,8 +162,7 @@ router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
locationLat,
|
||||
locationLng,
|
||||
searchRadius,
|
||||
timeLimitPerLeg,
|
||||
timeDeductionPenalty,
|
||||
rules: rules ? JSON.stringify(rules) : undefined,
|
||||
status
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavBar />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
|
|
|||
199
frontend/src/components/NavBar.vue
Normal file
199
frontend/src/components/NavBar.vue
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isOpen = ref(false);
|
||||
|
||||
function toggleNav() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<nav>
|
||||
<button class="menu-toggle" @click="toggleNav" aria-label="Toggle navigation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<RouterLink :to="authStore.isLoggedIn ? '/dashboard' : '/'" class="brand" @click="closeNav">
|
||||
<strong>Treasure Trails</strong>
|
||||
</RouterLink>
|
||||
|
||||
<ul v-if="authStore.isLoggedIn">
|
||||
<li><RouterLink to="/dashboard" @click="closeNav">Dashboard</RouterLink></li>
|
||||
</ul>
|
||||
|
||||
<ul class="nav-actions">
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<li><span class="user-name">{{ authStore.user?.name }}</span></li>
|
||||
<li><button @click="authStore.logout(); closeNav()" class="secondary">Logout</button></li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li><RouterLink to="/login" @click="closeNav">Login</RouterLink></li>
|
||||
<li><RouterLink to="/register" @click="closeNav" role="button">Register</RouterLink></li>
|
||||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div v-if="isOpen" class="nav-overlay" @click="closeNav"></div>
|
||||
<aside v-if="isOpen" class="nav-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<strong>Menu</strong>
|
||||
<button @click="closeNav" class="close-btn" aria-label="Close menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul>
|
||||
<li><RouterLink to="/" @click="closeNav">Home</RouterLink></li>
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<li><RouterLink to="/dashboard" @click="closeNav">Dashboard</RouterLink></li>
|
||||
<li><button @click="authStore.logout(); closeNav()" class="secondary">Logout</button></li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li><RouterLink to="/login" @click="closeNav">Login</RouterLink></li>
|
||||
<li><RouterLink to="/register" @click="closeNav" role="button">Register</RouterLink></li>
|
||||
</template>
|
||||
</ul>
|
||||
</aside>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
.brand {
|
||||
text-decoration: none;
|
||||
color: var(--pico-color);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.nav-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
.nav-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--pico-background-color);
|
||||
border-right: 1px solid var(--pico-muted-border-color);
|
||||
z-index: 999;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
.nav-sidebar ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-sidebar li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-sidebar a,
|
||||
.nav-sidebar button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
|
||||
.nav-sidebar a:hover,
|
||||
.nav-sidebar button:hover {
|
||||
background: var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
nav ul:not(.nav-actions) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,11 +2,16 @@ import { createApp } from 'vue'
|
|||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
const authStore = useAuthStore()
|
||||
authStore.init().then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ const startDate = ref('');
|
|||
const locationLat = ref<number | null>(null);
|
||||
const locationLng = ref<number | null>(null);
|
||||
const searchRadius = ref(500);
|
||||
const timeLimitPerLeg = ref(30);
|
||||
const timeDeductionPenalty = ref(60);
|
||||
const rules = ref<string[]>(['']);
|
||||
|
||||
const mapContainer = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
|
|
@ -46,6 +45,14 @@ function initMap() {
|
|||
});
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
rules.value.push('');
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
rules.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = '';
|
||||
|
||||
|
|
@ -59,6 +66,8 @@ async function handleSubmit() {
|
|||
return;
|
||||
}
|
||||
|
||||
const filteredRules = rules.value.filter(r => r.trim() !== '');
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
|
|
@ -71,8 +80,7 @@ async function handleSubmit() {
|
|||
locationLat: locationLat.value || undefined,
|
||||
locationLng: locationLng.value || undefined,
|
||||
searchRadius: searchRadius.value,
|
||||
timeLimitPerLeg: timeLimitPerLeg.value,
|
||||
timeDeductionPenalty: timeDeductionPenalty.value
|
||||
rules: filteredRules.length > 0 ? filteredRules : undefined
|
||||
});
|
||||
|
||||
router.push(`/games/${response.data.id}/edit`);
|
||||
|
|
@ -144,14 +152,34 @@ onUnmounted(() => {
|
|||
</div>
|
||||
|
||||
<h2>Game Rules</h2>
|
||||
<p><small>Add rules for participants to follow during the game</small></p>
|
||||
|
||||
<label for="timeLimitPerLeg">Time Limit per Leg (minutes)</label>
|
||||
<input id="timeLimitPerLeg" v-model.number="timeLimitPerLeg" type="number" min="1" />
|
||||
<div v-for="(rule, index) in rules" :key="index" class="grid" style="grid-template-columns: 1fr auto;">
|
||||
<label :for="`rule-${index}`">
|
||||
Rule {{ index + 1 }}
|
||||
<input
|
||||
:id="`rule-${index}`"
|
||||
v-model="rules[index]"
|
||||
type="text"
|
||||
placeholder="Enter a game rule..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeRule(index)"
|
||||
class="contrast"
|
||||
style="margin-top: 1.8rem; width: 3rem;"
|
||||
v-if="rules.length > 1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label for="timeDeductionPenalty">Navigation Warning Penalty (seconds)</label>
|
||||
<input id="timeDeductionPenalty" v-model.number="timeDeductionPenalty" type="number" min="0" />
|
||||
<button type="button" @click="addRule" class="secondary">
|
||||
+ Add Another Rule
|
||||
</button>
|
||||
|
||||
<div class="grid">
|
||||
<div class="grid" style="margin-top: 2rem;">
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ loading ? 'Creating...' : 'Create Game' }}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,28 @@ async function loadGames() {
|
|||
}
|
||||
}
|
||||
|
||||
async function archiveGame(gameId: string) {
|
||||
if (!confirm('Are you sure you want to archive this game?')) return;
|
||||
|
||||
try {
|
||||
await gameService.archive(gameId);
|
||||
await loadGames();
|
||||
} catch (err) {
|
||||
alert('Failed to archive game');
|
||||
}
|
||||
}
|
||||
|
||||
async function unarchiveGame(gameId: string) {
|
||||
if (!confirm('Are you sure you want to unarchive this game?')) return;
|
||||
|
||||
try {
|
||||
await gameService.unarchive(gameId);
|
||||
await loadGames();
|
||||
} catch (err) {
|
||||
alert('Failed to unarchive game');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGames();
|
||||
});
|
||||
|
|
@ -39,15 +61,6 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<main class="container">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ul>
|
||||
<li><strong>Welcome, {{ authStore.user?.name }}</strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><button @click="authStore.logout()" class="secondary">Logout</button></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<section>
|
||||
<div class="grid">
|
||||
<h1>My Games</h1>
|
||||
|
|
@ -79,9 +92,13 @@ onMounted(() => {
|
|||
· {{ game._count?.teams || 0 }} teams
|
||||
</small>
|
||||
</footer>
|
||||
<RouterLink :to="`/games/${game.id}`" role="button">View</RouterLink>
|
||||
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" role="button" class="secondary">Edit</RouterLink>
|
||||
<RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/live`" role="button">Live Dashboard</RouterLink>
|
||||
<div class="card-actions">
|
||||
<RouterLink :to="`/games/${game.id}`" role="button">View</RouterLink>
|
||||
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" role="button" class="secondary">Edit</RouterLink>
|
||||
<RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/live`" role="button">Live</RouterLink>
|
||||
<button v-if="game.status === 'ENDED'" @click="archiveGame(game.id)" class="secondary">Archive</button>
|
||||
<button v-if="game.status === 'ARCHIVED'" @click="unarchiveGame(game.id)" class="secondary">Unarchive</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -99,4 +116,11 @@ onMounted(() => {
|
|||
.status.live { background: var(--pico-ins-background-color); }
|
||||
.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); }
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -196,7 +196,10 @@ onUnmounted(() => {
|
|||
<p><small>Total route distance: {{ getTotalDistance().toFixed(2) }} km</small></p>
|
||||
|
||||
<article v-if="legs.length" v-for="(leg, index) in legs" :key="leg.id">
|
||||
<h3>Leg {{ index + 1 }}</h3>
|
||||
<div class="leg-header">
|
||||
<h3>Leg {{ index + 1 }}</h3>
|
||||
<button @click="deleteLeg(leg.id)" class="secondary">Delete</button>
|
||||
</div>
|
||||
<p>{{ leg.description }}</p>
|
||||
<footer>
|
||||
<small>
|
||||
|
|
@ -207,7 +210,6 @@ onUnmounted(() => {
|
|||
</span>
|
||||
</small>
|
||||
</footer>
|
||||
<button @click="deleteLeg(leg.id)" class="secondary">Delete</button>
|
||||
</article>
|
||||
<p v-else>No legs added yet</p>
|
||||
</article>
|
||||
|
|
@ -265,4 +267,15 @@ onUnmounted(() => {
|
|||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.leg-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.leg-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -145,15 +145,16 @@ onMounted(() => {
|
|||
|
||||
<dt>Search Radius</dt>
|
||||
<dd>{{ game.searchRadius || 500 }} meters</dd>
|
||||
|
||||
<dt>Time Limit per Leg</dt>
|
||||
<dd>{{ game.timeLimitPerLeg || 30 }} minutes</dd>
|
||||
|
||||
<dt>Time Deduction Penalty</dt>
|
||||
<dd>{{ game.timeDeductionPenalty || 60 }} seconds</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="game.rules?.length">
|
||||
<h2>Rules</h2>
|
||||
<ul>
|
||||
<li v-for="(rule, index) in game.rules" :key="index">{{ rule }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="game.legs?.length">
|
||||
<h2>Legs ({{ game.legs.length }})</h2>
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ export interface Game {
|
|||
locationLat?: number;
|
||||
locationLng?: number;
|
||||
searchRadius?: number;
|
||||
timeLimitPerLeg?: number;
|
||||
timeDeductionPenalty?: number;
|
||||
rules?: string[];
|
||||
status: 'DRAFT' | 'LIVE' | 'ENDED' | 'ARCHIVED';
|
||||
inviteCode?: string;
|
||||
createdAt: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue