From e2a95252bb9bfb4572606232214f2d2760e1ac1f Mon Sep 17 00:00:00 2001 From: Brian McGonagill Date: Tue, 24 Mar 2026 18:48:29 -0500 Subject: [PATCH] Many more changes for btter experience. --- backend/prisma/schema.prisma | 30 ++++--- backend/src/routes/users.ts | 7 +- frontend/src/App.vue | 15 ++++ frontend/src/components/Modal.vue | 112 ++++++++++++++++++++++++ frontend/src/composables/useModal.ts | 66 ++++++++++++++ frontend/src/pages/DashboardPage.vue | 9 +- frontend/src/pages/EditGamePage.vue | 28 +++--- frontend/src/pages/GameLivePage.vue | 33 ++++--- frontend/src/pages/GamePage.vue | 25 +++--- frontend/src/pages/JoinGamePage.vue | 7 +- frontend/src/pages/PlayGamePage.vue | 7 +- frontend/src/pages/SettingsPage.vue | 40 ++++++--- frontend/src/pages/SpectateGamePage.vue | 18 +++- frontend/src/services/api.ts | 2 +- frontend/src/types/index.ts | 1 + frontend/src/utils/units.ts | 57 ++++++++++++ 16 files changed, 385 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/Modal.vue create mode 100644 frontend/src/composables/useModal.ts create mode 100644 frontend/src/utils/units.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 82ffd47..093c399 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 6684296..d7e4d1f 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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 } }); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 02fdee5..456f77a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,24 @@ diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue new file mode 100644 index 0000000..27a22c9 --- /dev/null +++ b/frontend/src/components/Modal.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/composables/useModal.ts b/frontend/src/composables/useModal.ts new file mode 100644 index 0000000..cea8441 --- /dev/null +++ b/frontend/src/composables/useModal.ts @@ -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({ + 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 { + 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 { + return show({ message, title, type: 'alert' }).then(() => {}) +} + +export function confirm(message: string, title?: string, danger = false): Promise { + 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 } diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index 0257085..62b0777 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -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([]); 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'); } } diff --git a/frontend/src/pages/EditGamePage.vue b/frontend/src/pages/EditGamePage.vue index bba04c3..3d39d59 100644 --- a/frontend/src/pages/EditGamePage.vue +++ b/frontend/src/pages/EditGamePage.vue @@ -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(null); const routes = ref([]); @@ -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(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(() => {
{{ routeItem.routeLegs?.length || 0 }} legs - · {{ getRouteDistance(routeItem).toFixed(2) }} km + · {{ formatDistance(getRouteDistance(routeItem), unitPreference) }}
diff --git a/frontend/src/pages/GameLivePage.vue b/frontend/src/pages/GameLivePage.vue index 0916ca3..458ba3e 100644 --- a/frontend/src/pages/GameLivePage.vue +++ b/frontend/src/pages/GameLivePage.vue @@ -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: `
`, + 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'); } } diff --git a/frontend/src/pages/GamePage.vue b/frontend/src/pages/GamePage.vue index 710318d..14dbd29 100644 --- a/frontend/src/pages/GamePage.vue +++ b/frontend/src/pages/GamePage.vue @@ -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(() => {
Not set
Search Radius
-
{{ game.searchRadius || 500 }} meters
+
{{ formatRadius(game.searchRadius || 500, unitPreference) }}
diff --git a/frontend/src/pages/JoinGamePage.vue b/frontend/src/pages/JoinGamePage.vue index e8c204c..dc2fa23 100644 --- a/frontend/src/pages/JoinGamePage.vue +++ b/frontend/src/pages/JoinGamePage.vue @@ -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; } diff --git a/frontend/src/pages/PlayGamePage.vue b/frontend/src/pages/PlayGamePage.vue index 33aea24..7c34ad2 100644 --- a/frontend/src/pages/PlayGamePage.vue +++ b/frontend/src/pages/PlayGamePage.vue @@ -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; } diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue index 70975fa..d439360 100644 --- a/frontend/src/pages/SettingsPage.vue +++ b/frontend/src/pages/SettingsPage.vue @@ -1,9 +1,11 @@