Compare commits
2 commits
f49490042a
...
e2a95252bb
| Author | SHA1 | Date | |
|---|---|---|---|
| e2a95252bb | |||
| 87059a62e2 |
17 changed files with 445 additions and 78 deletions
66
README.md
66
README.md
|
|
@ -2,13 +2,63 @@
|
|||
|
||||
An online scavenger hunt application where Game Masters create and manage hunts, Teams compete to complete clues, and Spectators can follow along in real-time.
|
||||
|
||||
## Inspration
|
||||
Scavenger Hunts, of course, but also a Movie from my childhood called "Midnight Madness". A game master creates teams from people he knows at his University. The Jocks, The Nerds, The awkward girls, and the average guy / girl mix. Yep, as cliche as you can get. BTW - Michael J Fox's first movie role as a young teen.
|
||||
|
||||
## Game Play
|
||||
### Game Master
|
||||
Users can create a game, making them the Game Master. Give your game a name, and if you'd like a prize for the winner(s). Set the radius of the map the game legs and objectives should remain within. Set game rules for all to read and abide by. Set default leg time-outs, and time based penalty periods for taking too long, or breaking rules. And get on with creating routes.
|
||||
|
||||
You can create 1 route for all teams to use, or you can create multiple routes, perhaps 1 per team. The system sows distance totlas for each route, so you can make sure they are approximately equal if needed.
|
||||
|
||||
You can invite players who will make teams, and keep the game private, or you can make a public game, which still allows invites, but you can also allow useers to simply join a game and make their team.
|
||||
|
||||
Game masters devise routes which can each have multiple legs and a clue for each leg. The game allows for taking a photo to prove the completion of the objective of a leg, or perhaps you want to encourage patronage at local businesses so you make it a purchase leg (where they still send a photo fo their purchase and receipt). It's all in good fun.
|
||||
|
||||
### Teams and Team Members
|
||||
Once the teams are formed, and the routes, legs, and clues set, the game master will start the game.
|
||||
|
||||
The temas will attempt to solve a clue (if needed), and get to their first objective in the allotted time for the leg. Provide the required proof, and the game master will approve or deny their ability to go to the next leg.
|
||||
|
||||
The game master will control the game from the game master dashboard, where the game master see's all temas' locations on the map, which leg they are on, and can chat with teams as needed.
|
||||
|
||||
The temas have the mobile version which runs right in your mobile device browser. Recommeneded to just have the team captain's device for the game. Don't leave the browser window during the game, or take a chance on a time based deduction, or removal from the game. Allow location tracking, and your team is set.
|
||||
|
||||
Use the map, built in photo taking options, and chat as needed to get clues, and complete objectives. And be the first team to the end.
|
||||
|
||||
### Spectators
|
||||
Part of "Midnight Madness" was how the Game Master's neighbors all got into the game and made it as exciting from the control room as it was on the streets.
|
||||
|
||||
With that in mind, public games will have a spectator mode, they'll be able to follow the team progression throughout the race.
|
||||
|
||||
Could there be something where a spectator could assist the Game Master in some way? I need to ponder this a bit.
|
||||
|
||||
## Who would ever use this?
|
||||
Well, me... I have an annual family reunion, and I think my family would have a blast with something like this.
|
||||
|
||||
Also, perhaps as a way to earn money for charity, or just a sa way to have local activities that people can join in on. It's a great way to get out of the house.
|
||||
|
||||
What about the Game Master? Don't forget, in the movie the game master moved to the final objective to meet the teams and be there to personally congratulate the winners. His neighbors all went with him as well. A laptop, and mobile hotspot, or tablet would likely be ideal for such mobility in this day and age!
|
||||
|
||||
In the end, this was a great way for me to work with an AI system that was doing the heavy lifting. It let me learn a lot about how AI operates when building applications, and how best to interact with the AI in the process.
|
||||
|
||||
## Features
|
||||
|
||||
- **Game Master Dashboard**: Create scavenger hunts with legs/clues, manage live games, view team progress
|
||||
- **Team Interface**: Mobile-friendly gameplay with clues, map navigation, photo submissions, and real-time chat
|
||||
- **Spectator View**: Public dashboard showing team positions and leaderboard
|
||||
- **Real-time Updates**: Live tracking via WebSockets (Socket.io)
|
||||
- **OpenStreetMap Integration**: All maps use OpenStreetMap data
|
||||
- **Game Master Dashboard**:
|
||||
- Create scavenger hunts with routes which can each have mutliple legs and clues,
|
||||
- Manage live games,
|
||||
- View team progress
|
||||
- **Team Interface**:
|
||||
- Mobile-friendly gameplay with clues
|
||||
- Map navigation,
|
||||
- Photo submissions, and
|
||||
- Real-time chat
|
||||
- **Spectator View**:
|
||||
- Public dashboard showing team positions and leaderboard
|
||||
- **Real-time Updates**:
|
||||
- Live tracking via WebSockets (Socket.io)
|
||||
- **OpenStreetMap Integration**:
|
||||
- All maps use OpenStreetMap data
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
|
@ -27,6 +77,10 @@ An online scavenger hunt application where Game Masters create and manage hunts,
|
|||
|
||||
## Quick Start with Docker
|
||||
|
||||
### Install Docker and Docker Compose
|
||||
|
||||
`curl https://get.docker.com | sh`
|
||||
|
||||
The easiest way to run the application:
|
||||
|
||||
```bash
|
||||
|
|
@ -110,7 +164,7 @@ The frontend will run on http://localhost:5173
|
|||
2. Go to Dashboard and click "Create New Game"
|
||||
3. Fill in game details and click on the map to set the treasure location
|
||||
4. Click "Create Game" to save as draft
|
||||
5. Click "Edit Game" to add legs/clues
|
||||
5. Click "Edit Game" to add routes / legs / clues
|
||||
6. Once legs are added, click "Publish Game" to make it live
|
||||
7. Use the "Live Dashboard" to monitor teams
|
||||
|
||||
|
|
|
|||
|
|
@ -8,21 +8,27 @@ datasource db {
|
|||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
screenName String?
|
||||
avatarUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
games Game[] @relation("GameMaster")
|
||||
teams TeamMember[]
|
||||
captainOf Team? @relation("TeamCaptain")
|
||||
chatMessages ChatMessage[]
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
screenName String?
|
||||
avatarUrl String?
|
||||
unitPreference UnitPreference @default(METRIC)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
games Game[] @relation("GameMaster")
|
||||
teams TeamMember[]
|
||||
captainOf Team? @relation("TeamCaptain")
|
||||
chatMessages ChatMessage[]
|
||||
locationHistory LocationHistory[]
|
||||
}
|
||||
|
||||
enum UnitPreference {
|
||||
METRIC
|
||||
IMPERIAL
|
||||
}
|
||||
|
||||
model Game {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
name: true,
|
||||
screenName: true,
|
||||
avatarUrl: true,
|
||||
unitPreference: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
|
@ -31,14 +32,15 @@ router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
|
||||
router.put('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, screenName, avatarUrl } = req.body;
|
||||
const { name, screenName, avatarUrl, unitPreference } = req.body;
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: {
|
||||
name: name || undefined,
|
||||
screenName: screenName !== undefined ? screenName || null : undefined,
|
||||
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined
|
||||
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined,
|
||||
unitPreference: unitPreference || undefined
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
|
@ -46,6 +48,7 @@ router.put('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
name: true,
|
||||
screenName: true,
|
||||
avatarUrl: true,
|
||||
unitPreference: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
import Modal from './components/Modal.vue'
|
||||
import { state, key, handleConfirm, handleCancel } from './composables/useModal'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavBar />
|
||||
<RouterView />
|
||||
<Modal
|
||||
:key="key"
|
||||
:open="state.open"
|
||||
:title="state.title"
|
||||
:message="state.message"
|
||||
:type="state.type"
|
||||
:confirm-text="state.confirmText"
|
||||
:cancel-text="state.cancelText"
|
||||
:danger="state.danger"
|
||||
@update:open="state.open = $event"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
112
frontend/src/components/Modal.vue
Normal file
112
frontend/src/components/Modal.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
open: boolean
|
||||
title?: string
|
||||
message?: string
|
||||
type?: 'alert' | 'confirm'
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
type: 'alert',
|
||||
confirmText: 'OK',
|
||||
cancelText: 'Cancel',
|
||||
danger: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog :open="open" @keydown="handleKeydown" @click.self="type === 'confirm' ? handleCancel() : close()">
|
||||
<article>
|
||||
<header v-if="title">
|
||||
<h3>{{ title }}</h3>
|
||||
</header>
|
||||
<p v-if="message">{{ message }}</p>
|
||||
<slot></slot>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
v-if="type === 'confirm'"
|
||||
@click="handleCancel"
|
||||
class="secondary"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleConfirm"
|
||||
:class="danger ? 'secondary' : ''"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
width: 90vw;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
dialog article {
|
||||
margin: 0;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
dialog article p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
footer button {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
66
frontend/src/composables/useModal.ts
Normal file
66
frontend/src/composables/useModal.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { ref, reactive } from 'vue'
|
||||
|
||||
interface ModalState {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
type: 'alert' | 'confirm'
|
||||
confirmText: string
|
||||
cancelText: string
|
||||
danger: boolean
|
||||
resolve: ((value: boolean) => void) | null
|
||||
}
|
||||
|
||||
const state = reactive<ModalState>({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
confirmText: 'OK',
|
||||
cancelText: 'Cancel',
|
||||
danger: false,
|
||||
resolve: null
|
||||
})
|
||||
|
||||
const key = ref(0)
|
||||
|
||||
function show(options: {
|
||||
message: string
|
||||
title?: string
|
||||
type?: 'alert' | 'confirm'
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
}): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
state.open = true
|
||||
state.title = options.title || ''
|
||||
state.message = options.message
|
||||
state.type = options.type || 'alert'
|
||||
state.confirmText = options.confirmText || 'OK'
|
||||
state.cancelText = options.cancelText || 'Cancel'
|
||||
state.danger = options.danger || false
|
||||
state.resolve = resolve
|
||||
key.value++
|
||||
})
|
||||
}
|
||||
|
||||
export function alert(message: string, title?: string): Promise<void> {
|
||||
return show({ message, title, type: 'alert' }).then(() => {})
|
||||
}
|
||||
|
||||
export function confirm(message: string, title?: string, danger = false): Promise<boolean> {
|
||||
return show({ message, title, type: 'confirm', danger })
|
||||
}
|
||||
|
||||
export function handleConfirm() {
|
||||
if (state.resolve) state.resolve(true)
|
||||
state.open = false
|
||||
}
|
||||
|
||||
export function handleCancel() {
|
||||
if (state.resolve) state.resolve(false)
|
||||
state.open = false
|
||||
}
|
||||
|
||||
export { state, key }
|
||||
|
|
@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue';
|
|||
import { RouterLink } from 'vue-router';
|
||||
import type { Game } from '../types';
|
||||
import { gameService } from '../services/api';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
|
||||
const games = ref<Game[]>([]);
|
||||
const loading = ref(false);
|
||||
|
|
@ -31,24 +32,24 @@ async function loadGames() {
|
|||
}
|
||||
|
||||
async function archiveGame(gameId: string) {
|
||||
if (!confirm('Are you sure you want to archive this game?')) return;
|
||||
if (!await 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');
|
||||
await alert('Failed to archive game');
|
||||
}
|
||||
}
|
||||
|
||||
async function unarchiveGame(gameId: string) {
|
||||
if (!confirm('Are you sure you want to unarchive this game?')) return;
|
||||
if (!await 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');
|
||||
await alert('Failed to unarchive game');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ import L from 'leaflet';
|
|||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Game, Route } from '../types';
|
||||
import { gameService, routeService } from '../services/api';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { formatDistance } from '../utils/units';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const game = ref<Game | null>(null);
|
||||
const routes = ref<Route[]>([]);
|
||||
|
|
@ -20,6 +24,8 @@ const selectedRoute = computed(() =>
|
|||
routes.value.find(r => r.id === selectedRouteId.value) || null
|
||||
);
|
||||
|
||||
const unitPreference = computed(() => authStore.user?.unitPreference || 'METRIC');
|
||||
|
||||
const mapContainer = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let routeMarkers: { [routeId: string]: L.Marker[] } = {};
|
||||
|
|
@ -76,7 +82,7 @@ async function saveRoute() {
|
|||
|
||||
updateMapMarkers();
|
||||
} catch (err) {
|
||||
alert('Failed to update route');
|
||||
await alert('Failed to update route');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
|
@ -162,7 +168,7 @@ function updateMapMarkers() {
|
|||
|
||||
async function createRoute() {
|
||||
if (!newRoute.value.name.trim()) {
|
||||
alert('Please enter a route name');
|
||||
await alert('Please enter a route name');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +187,7 @@ async function createRoute() {
|
|||
newRoute.value = { name: '', description: '', color: '#3498db' };
|
||||
updateMapMarkers();
|
||||
} catch (err) {
|
||||
alert('Failed to create route');
|
||||
await alert('Failed to create route');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
|
@ -194,12 +200,12 @@ async function copyRoute(routeId: string) {
|
|||
selectedRouteId.value = response.data.id;
|
||||
updateMapMarkers();
|
||||
} catch (err) {
|
||||
alert('Failed to copy route');
|
||||
await alert('Failed to copy route');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRoute(routeId: string) {
|
||||
if (!confirm('Are you sure you want to delete this route and all its legs?')) return;
|
||||
if (!await confirm('Are you sure you want to delete this route and all its legs?')) return;
|
||||
|
||||
try {
|
||||
await routeService.delete(routeId);
|
||||
|
|
@ -211,13 +217,13 @@ async function deleteRoute(routeId: string) {
|
|||
|
||||
updateMapMarkers();
|
||||
} catch (err) {
|
||||
alert('Failed to delete route');
|
||||
await alert('Failed to delete route');
|
||||
}
|
||||
}
|
||||
|
||||
async function addLeg() {
|
||||
if (!selectedRouteId.value || !newLeg.value.description) {
|
||||
alert('Please enter a description');
|
||||
await alert('Please enter a description');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -249,14 +255,14 @@ async function addLeg() {
|
|||
|
||||
updateMapMarkers();
|
||||
} catch (err) {
|
||||
alert('Failed to add leg');
|
||||
await alert('Failed to add leg');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLeg(legId: string) {
|
||||
if (!confirm('Are you sure you want to delete this leg?')) return;
|
||||
if (!await confirm('Are you sure you want to delete this leg?')) return;
|
||||
|
||||
try {
|
||||
await routeService.deleteLeg(selectedRouteId.value!, legId);
|
||||
|
|
@ -268,7 +274,7 @@ async function deleteLeg(legId: string) {
|
|||
|
||||
updateMapMarkers();
|
||||
} catch (err) {
|
||||
alert('Failed to delete leg');
|
||||
await alert('Failed to delete leg');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,7 +380,7 @@ onUnmounted(() => {
|
|||
<footer>
|
||||
<small>
|
||||
{{ routeItem.routeLegs?.length || 0 }} legs
|
||||
· {{ getRouteDistance(routeItem).toFixed(2) }} km
|
||||
· {{ formatDistance(getRouteDistance(routeItem), unitPreference) }}
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'leaflet/dist/leaflet.css';
|
|||
import { useAuthStore } from '../stores/auth';
|
||||
import type { Game, Team, ChatMessage, Route } from '../types';
|
||||
import { gameService, teamService, routeService } from '../services/api';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
|
|
@ -109,10 +110,22 @@ function connectSocket() {
|
|||
team.lat = data.lat;
|
||||
team.lng = data.lng;
|
||||
|
||||
const teamRoute = team.teamRoutes?.[0];
|
||||
const teamRouteData = routes.value.find(r => r.id === teamRoute?.routeId);
|
||||
const color = teamRouteData?.color || '#666';
|
||||
|
||||
if (teamMarkers[data.teamId]) {
|
||||
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
|
||||
} else if (map) {
|
||||
const marker = L.marker([data.lat, data.lng])
|
||||
teamMarkers[data.teamId].remove();
|
||||
}
|
||||
|
||||
if (map) {
|
||||
const teamIcon = L.divIcon({
|
||||
className: 'team-marker',
|
||||
html: `<div style="background:${color}; width:24px; height:24px; border-radius:50%; border:2px solid white; box-shadow:0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
const marker = L.marker([data.lat, data.lng], { icon: teamIcon })
|
||||
.addTo(map)
|
||||
.bindPopup(team.name);
|
||||
teamMarkers[data.teamId] = marker;
|
||||
|
|
@ -148,30 +161,30 @@ async function advanceTeam(teamId: string) {
|
|||
await loadGame();
|
||||
socket?.emit('team-advanced', { gameId: gameId.value, teamId });
|
||||
} catch (err) {
|
||||
alert('Failed to advance team');
|
||||
await alert('Failed to advance team');
|
||||
}
|
||||
}
|
||||
|
||||
async function deductTime(teamId: string) {
|
||||
const seconds = prompt('Enter deduction in seconds:', '60');
|
||||
if (!seconds) return;
|
||||
const secondsStr = window.prompt('Enter deduction in seconds:', '60');
|
||||
if (!secondsStr) return;
|
||||
|
||||
try {
|
||||
await teamService.deduct(teamId, parseInt(seconds));
|
||||
await teamService.deduct(teamId, parseInt(secondsStr));
|
||||
await loadGame();
|
||||
} catch (err) {
|
||||
alert('Failed to deduct time');
|
||||
await alert('Failed to deduct time');
|
||||
}
|
||||
}
|
||||
|
||||
async function disqualifyTeam(teamId: string) {
|
||||
if (!confirm('Are you sure you want to disqualify this team?')) return;
|
||||
if (!await confirm('Are you sure you want to disqualify this team?')) return;
|
||||
|
||||
try {
|
||||
await teamService.disqualify(teamId);
|
||||
await loadGame();
|
||||
} catch (err) {
|
||||
alert('Failed to disqualify team');
|
||||
await alert('Failed to disqualify team');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useRoute, RouterLink } from 'vue-router';
|
|||
import { useAuthStore } from '../stores/auth';
|
||||
import type { Game } from '../types';
|
||||
import { gameService } from '../services/api';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
import { formatRadius } from '../utils/units';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
|
|
@ -14,6 +16,7 @@ const error = ref('');
|
|||
|
||||
const gameId = computed(() => route.params.id as string);
|
||||
const isGameMaster = computed(() => game.value?.gameMasterId === authStore.user?.id);
|
||||
const unitPreference = computed(() => authStore.user?.unitPreference || 'METRIC');
|
||||
|
||||
async function loadGame() {
|
||||
loading.value = true;
|
||||
|
|
@ -29,46 +32,46 @@ async function loadGame() {
|
|||
}
|
||||
|
||||
async function publishGame() {
|
||||
if (!confirm('Are you sure you want to publish this game?')) return;
|
||||
if (!await confirm('Are you sure you want to publish this game?')) return;
|
||||
|
||||
try {
|
||||
await gameService.publish(gameId.value);
|
||||
await loadGame();
|
||||
} catch (err) {
|
||||
alert('Failed to publish game');
|
||||
await alert('Failed to publish game');
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveGame() {
|
||||
if (!confirm('Are you sure you want to archive this game?')) return;
|
||||
if (!await 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');
|
||||
await alert('Failed to archive game');
|
||||
}
|
||||
}
|
||||
|
||||
async function unarchiveGame() {
|
||||
if (!confirm('Are you sure you want to unarchive this game?')) return;
|
||||
if (!await 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');
|
||||
await alert('Failed to unarchive game');
|
||||
}
|
||||
}
|
||||
|
||||
async function endGame() {
|
||||
if (!confirm('Are you sure you want to end this game?')) return;
|
||||
if (!await confirm('Are you sure you want to end this game?')) return;
|
||||
|
||||
try {
|
||||
await gameService.end(gameId.value);
|
||||
await loadGame();
|
||||
} catch (err) {
|
||||
alert('Failed to end game');
|
||||
await alert('Failed to end game');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,9 +80,9 @@ async function copyInviteLink() {
|
|||
const response = await gameService.getInvite(gameId.value);
|
||||
const link = `${window.location.origin}/invite/${response.data.inviteCode}`;
|
||||
await navigator.clipboard.writeText(link);
|
||||
alert('Invite link copied to clipboard!');
|
||||
await alert('Invite link copied to clipboard!');
|
||||
} catch (err) {
|
||||
alert('Failed to get invite link');
|
||||
await alert('Failed to get invite link');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +147,7 @@ onMounted(() => {
|
|||
<dd v-else>Not set</dd>
|
||||
|
||||
<dt>Search Radius</dt>
|
||||
<dd>{{ game.searchRadius || 500 }} meters</dd>
|
||||
<dd>{{ formatRadius(game.searchRadius || 500, unitPreference) }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||
import { useAuthStore } from '../stores/auth';
|
||||
import type { Game, Team } from '../types';
|
||||
import { gameService, teamService } from '../services/api';
|
||||
import { alert } from '../composables/useModal';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
|
@ -44,7 +45,7 @@ async function loadGame() {
|
|||
|
||||
async function createTeam() {
|
||||
if (!newTeamName.value.trim()) {
|
||||
alert('Please enter a team name');
|
||||
await alert('Please enter a team name');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +56,7 @@ async function createTeam() {
|
|||
await loadGame();
|
||||
router.push(`/games/${gameId.value}/play`);
|
||||
} catch (err) {
|
||||
alert('Failed to create team');
|
||||
await alert('Failed to create team');
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
|
|
@ -68,7 +69,7 @@ async function joinTeam(teamId: string) {
|
|||
await loadGame();
|
||||
router.push(`/games/${gameId.value}/play`);
|
||||
} catch (err) {
|
||||
alert('Failed to join team');
|
||||
await alert('Failed to join team');
|
||||
} finally {
|
||||
joining.value = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'leaflet/dist/leaflet.css';
|
|||
import { useAuthStore } from '../stores/auth';
|
||||
import type { Game, Team, ChatMessage, Route, RouteLeg } from '../types';
|
||||
import { teamService, uploadService } from '../services/api';
|
||||
import { alert } from '../composables/useModal';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
|
|
@ -164,7 +165,7 @@ function handlePhotoSelect(event: Event) {
|
|||
|
||||
async function submitPhoto() {
|
||||
if (!photoFile.value || !currentLeg.value || !team.value) {
|
||||
alert('Please select a photo');
|
||||
await alert('Please select a photo');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -183,11 +184,11 @@ async function submitPhoto() {
|
|||
})
|
||||
});
|
||||
|
||||
alert('Photo submitted! Wait for Game Master approval.');
|
||||
await alert('Photo submitted! Wait for Game Master approval.');
|
||||
showPhotoUpload.value = false;
|
||||
photoFile.value = null;
|
||||
} catch (err) {
|
||||
alert('Failed to submit photo');
|
||||
await alert('Failed to submit photo');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { userService, uploadService } from '../services/api';
|
||||
import type { User, UserGameHistory, LocationHistory } from '../types';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
import { formatDistance } from '../utils/units';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
|
@ -22,6 +24,10 @@ const uploadingAvatar = ref(false);
|
|||
const locationHistory = ref<{ totalLocations: number; byGame: { game: { id: string; name: string }; locations: LocationHistory[]; locationCount: number }[] }>({ totalLocations: 0, byGame: [] });
|
||||
const gamesHistory = ref<UserGameHistory[]>([]);
|
||||
|
||||
const unitPreference = ref<'METRIC' | 'IMPERIAL'>('METRIC');
|
||||
|
||||
const displayUnitPreference = computed(() => unitPreference.value);
|
||||
|
||||
async function loadProfile() {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
|
@ -31,6 +37,7 @@ async function loadProfile() {
|
|||
screenName.value = response.data.screenName || '';
|
||||
avatarUrl.value = response.data.avatarUrl || '';
|
||||
avatarPreview.value = response.data.avatarUrl || '';
|
||||
unitPreference.value = response.data.unitPreference || 'METRIC';
|
||||
} catch (err) {
|
||||
console.error('Failed to load profile:', err);
|
||||
} finally {
|
||||
|
|
@ -65,7 +72,7 @@ async function handleAvatarSelect(event: Event) {
|
|||
avatarUrl.value = uploadRes.data.url;
|
||||
avatarPreview.value = uploadRes.data.url;
|
||||
} catch (err) {
|
||||
alert('Failed to upload avatar');
|
||||
await alert('Failed to upload avatar');
|
||||
} finally {
|
||||
uploadingAvatar.value = false;
|
||||
}
|
||||
|
|
@ -78,40 +85,41 @@ async function saveProfile() {
|
|||
const response = await userService.updateProfile({
|
||||
name: name.value,
|
||||
screenName: screenName.value || undefined,
|
||||
avatarUrl: avatarUrl.value || undefined
|
||||
avatarUrl: avatarUrl.value || undefined,
|
||||
unitPreference: unitPreference.value
|
||||
});
|
||||
user.value = response.data;
|
||||
authStore.user = response.data;
|
||||
alert('Profile updated successfully!');
|
||||
await alert('Profile updated successfully!');
|
||||
} catch (err) {
|
||||
alert('Failed to update profile');
|
||||
await alert('Failed to update profile');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLocationData() {
|
||||
if (!confirm('Are you sure you want to delete all your location data? This cannot be undone.')) return;
|
||||
if (!await confirm('Are you sure you want to delete all your location data? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await userService.deleteLocationData();
|
||||
alert('Location data deleted.');
|
||||
await alert('Location data deleted.');
|
||||
loadLocationHistory();
|
||||
} catch (err) {
|
||||
alert('Failed to delete location data');
|
||||
await alert('Failed to delete location data');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
if (!confirm('Are you sure you want to delete your account? This will remove all your data and cannot be undone.')) return;
|
||||
if (!confirm('This is your final warning. Type "DELETE" to confirm.')) return;
|
||||
if (!await confirm('Are you sure you want to delete your account? This will remove all your data and cannot be undone.')) return;
|
||||
if (!await confirm('This is your final warning. This action cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await userService.deleteAccount();
|
||||
authStore.logout();
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
alert('Failed to delete account');
|
||||
await alert('Failed to delete account');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +186,14 @@ onMounted(() => {
|
|||
<input v-model="screenName" type="text" placeholder="Optional alias" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Distance Units
|
||||
<select v-model="unitPreference">
|
||||
<option value="METRIC">Metric (km, m)</option>
|
||||
<option value="IMPERIAL">Imperial (miles, feet)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button @click="saveProfile" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Profile' }}
|
||||
</button>
|
||||
|
|
@ -274,7 +290,7 @@ onMounted(() => {
|
|||
<small>Legs</small>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{{ game.totalDistance }} km</strong>
|
||||
<strong>{{ formatDistance(game.totalDistance, displayUnitPreference) }}</strong>
|
||||
<small>Distance</small>
|
||||
</div>
|
||||
<div class="stat">
|
||||
|
|
|
|||
|
|
@ -114,10 +114,22 @@ function connectSocket() {
|
|||
team.lat = data.lat;
|
||||
team.lng = data.lng;
|
||||
|
||||
const teamRoute = team.teamRoutes?.[0];
|
||||
const teamRouteData = routes.value.find(r => r.id === teamRoute?.routeId);
|
||||
const color = teamRouteData?.color || '#666';
|
||||
|
||||
if (teamMarkers[data.teamId]) {
|
||||
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
|
||||
} else if (map) {
|
||||
const marker = L.marker([data.lat, data.lng])
|
||||
teamMarkers[data.teamId].remove();
|
||||
}
|
||||
|
||||
if (map) {
|
||||
const teamIcon = L.divIcon({
|
||||
className: 'team-marker',
|
||||
html: `<div style="background:${color}; width:16px; height:16px; border-radius:50%; border:2px solid white; box-shadow:0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
});
|
||||
const marker = L.marker([data.lat, data.lng], { icon: teamIcon })
|
||||
.addTo(map)
|
||||
.bindPopup(team.name);
|
||||
teamMarkers[data.teamId] = marker;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export const uploadService = {
|
|||
|
||||
export const userService = {
|
||||
getProfile: () => api.get<User>('/users/me'),
|
||||
updateProfile: (data: { name?: string; screenName?: string; avatarUrl?: string }) =>
|
||||
updateProfile: (data: { name?: string; screenName?: string; avatarUrl?: string; unitPreference?: 'METRIC' | 'IMPERIAL' }) =>
|
||||
api.put<User>('/users/me', data),
|
||||
getLocationHistory: () => api.get<{ totalLocations: number; byGame: { game: { id: string; name: string }; locations: LocationHistory[]; locationCount: number }[] }>('/users/me/location-history'),
|
||||
getGamesHistory: () => api.get<UserGameHistory[]>('/users/me/games'),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export interface User {
|
|||
name: string;
|
||||
screenName?: string;
|
||||
avatarUrl?: string;
|
||||
unitPreference?: 'METRIC' | 'IMPERIAL';
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
57
frontend/src/utils/units.ts
Normal file
57
frontend/src/utils/units.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
export type UnitPreference = 'METRIC' | 'IMPERIAL'
|
||||
|
||||
const KM_TO_MILES = 0.621371
|
||||
const METERS_TO_FEET = 3.28084
|
||||
|
||||
export function formatDistance(km: number, unit: UnitPreference): string {
|
||||
if (unit === 'IMPERIAL') {
|
||||
if (km < 0.1) {
|
||||
const feet = km * 1000 * METERS_TO_FEET
|
||||
return `${Math.round(feet)} ft`
|
||||
}
|
||||
const miles = km * KM_TO_MILES
|
||||
return `${miles.toFixed(2)} mi`
|
||||
}
|
||||
if (km < 1) {
|
||||
const meters = km * 1000
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
return `${km.toFixed(2)} km`
|
||||
}
|
||||
|
||||
export function formatDistanceShort(km: number, unit: UnitPreference): string {
|
||||
if (unit === 'IMPERIAL') {
|
||||
const miles = km * KM_TO_MILES
|
||||
if (miles < 0.1) {
|
||||
return `${Math.round(miles * 5280)} ft`
|
||||
}
|
||||
return `${miles.toFixed(1)} mi`
|
||||
}
|
||||
if (km < 1) {
|
||||
return `${Math.round(km * 1000)} m`
|
||||
}
|
||||
return `${km.toFixed(1)} km`
|
||||
}
|
||||
|
||||
export function formatRadius(meters: number, unit: UnitPreference): string {
|
||||
if (unit === 'IMPERIAL') {
|
||||
const feet = meters * METERS_TO_FEET
|
||||
if (feet < 5280) {
|
||||
return `${Math.round(feet)} ft`
|
||||
}
|
||||
const miles = feet / 5280
|
||||
return `${miles.toFixed(1)} mi`
|
||||
}
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)} km`
|
||||
}
|
||||
|
||||
export function kmToMiles(km: number): number {
|
||||
return km * KM_TO_MILES
|
||||
}
|
||||
|
||||
export function metersToFeet(meters: number): number {
|
||||
return meters * METERS_TO_FEET
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue