Updating navigation and logged in checks.

This commit is contained in:
Brian McGonagill 2026-03-19 13:59:30 -05:00
parent 9acde36f50
commit 59e15cfde8
11 changed files with 316 additions and 43 deletions

View file

@ -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"]

View file

@ -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())

View file

@ -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
}
});

View file

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

View 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>

View file

@ -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')
})

View file

@ -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"
>
&times;
</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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;