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