Many more changes for btter experience.
This commit is contained in:
parent
87059a62e2
commit
e2a95252bb
16 changed files with 385 additions and 72 deletions
|
|
@ -14,6 +14,7 @@ model User {
|
||||||
name String
|
name String
|
||||||
screenName String?
|
screenName String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
unitPreference UnitPreference @default(METRIC)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
games Game[] @relation("GameMaster")
|
games Game[] @relation("GameMaster")
|
||||||
|
|
@ -23,6 +24,11 @@ model User {
|
||||||
locationHistory LocationHistory[]
|
locationHistory LocationHistory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UnitPreference {
|
||||||
|
METRIC
|
||||||
|
IMPERIAL
|
||||||
|
}
|
||||||
|
|
||||||
model Game {
|
model Game {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
name: true,
|
name: true,
|
||||||
screenName: true,
|
screenName: true,
|
||||||
avatarUrl: true,
|
avatarUrl: true,
|
||||||
|
unitPreference: true,
|
||||||
createdAt: 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) => {
|
router.put('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { name, screenName, avatarUrl } = req.body;
|
const { name, screenName, avatarUrl, unitPreference } = req.body;
|
||||||
|
|
||||||
const updated = await prisma.user.update({
|
const updated = await prisma.user.update({
|
||||||
where: { id: req.user!.id },
|
where: { id: req.user!.id },
|
||||||
data: {
|
data: {
|
||||||
name: name || undefined,
|
name: name || undefined,
|
||||||
screenName: screenName !== undefined ? screenName || null : undefined,
|
screenName: screenName !== undefined ? screenName || null : undefined,
|
||||||
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined
|
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined,
|
||||||
|
unitPreference: unitPreference || undefined
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
@ -46,6 +48,7 @@ router.put('/me', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
name: true,
|
name: true,
|
||||||
screenName: true,
|
screenName: true,
|
||||||
avatarUrl: true,
|
avatarUrl: true,
|
||||||
|
unitPreference: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import NavBar from './components/NavBar.vue'
|
import NavBar from './components/NavBar.vue'
|
||||||
|
import Modal from './components/Modal.vue'
|
||||||
|
import { state, key, handleConfirm, handleCancel } from './composables/useModal'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<RouterView />
|
<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>
|
</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 { RouterLink } from 'vue-router';
|
||||||
import type { Game } from '../types';
|
import type { Game } from '../types';
|
||||||
import { gameService } from '../services/api';
|
import { gameService } from '../services/api';
|
||||||
|
import { alert, confirm } from '../composables/useModal';
|
||||||
|
|
||||||
const games = ref<Game[]>([]);
|
const games = ref<Game[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
@ -31,24 +32,24 @@ async function loadGames() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveGame(gameId: string) {
|
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 {
|
try {
|
||||||
await gameService.archive(gameId);
|
await gameService.archive(gameId);
|
||||||
await loadGames();
|
await loadGames();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to archive game');
|
await alert('Failed to archive game');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unarchiveGame(gameId: string) {
|
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 {
|
try {
|
||||||
await gameService.unarchive(gameId);
|
await gameService.unarchive(gameId);
|
||||||
await loadGames();
|
await loadGames();
|
||||||
} catch (err) {
|
} 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 'leaflet/dist/leaflet.css';
|
||||||
import type { Game, Route } from '../types';
|
import type { Game, Route } from '../types';
|
||||||
import { gameService, routeService } from '../services/api';
|
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 route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const game = ref<Game | null>(null);
|
const game = ref<Game | null>(null);
|
||||||
const routes = ref<Route[]>([]);
|
const routes = ref<Route[]>([]);
|
||||||
|
|
@ -20,6 +24,8 @@ const selectedRoute = computed(() =>
|
||||||
routes.value.find(r => r.id === selectedRouteId.value) || null
|
routes.value.find(r => r.id === selectedRouteId.value) || null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const unitPreference = computed(() => authStore.user?.unitPreference || 'METRIC');
|
||||||
|
|
||||||
const mapContainer = ref<HTMLDivElement | null>(null);
|
const mapContainer = ref<HTMLDivElement | null>(null);
|
||||||
let map: L.Map | null = null;
|
let map: L.Map | null = null;
|
||||||
let routeMarkers: { [routeId: string]: L.Marker[] } = {};
|
let routeMarkers: { [routeId: string]: L.Marker[] } = {};
|
||||||
|
|
@ -76,7 +82,7 @@ async function saveRoute() {
|
||||||
|
|
||||||
updateMapMarkers();
|
updateMapMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to update route');
|
await alert('Failed to update route');
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +168,7 @@ function updateMapMarkers() {
|
||||||
|
|
||||||
async function createRoute() {
|
async function createRoute() {
|
||||||
if (!newRoute.value.name.trim()) {
|
if (!newRoute.value.name.trim()) {
|
||||||
alert('Please enter a route name');
|
await alert('Please enter a route name');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +187,7 @@ async function createRoute() {
|
||||||
newRoute.value = { name: '', description: '', color: '#3498db' };
|
newRoute.value = { name: '', description: '', color: '#3498db' };
|
||||||
updateMapMarkers();
|
updateMapMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to create route');
|
await alert('Failed to create route');
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -194,12 +200,12 @@ async function copyRoute(routeId: string) {
|
||||||
selectedRouteId.value = response.data.id;
|
selectedRouteId.value = response.data.id;
|
||||||
updateMapMarkers();
|
updateMapMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to copy route');
|
await alert('Failed to copy route');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRoute(routeId: string) {
|
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 {
|
try {
|
||||||
await routeService.delete(routeId);
|
await routeService.delete(routeId);
|
||||||
|
|
@ -211,13 +217,13 @@ async function deleteRoute(routeId: string) {
|
||||||
|
|
||||||
updateMapMarkers();
|
updateMapMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete route');
|
await alert('Failed to delete route');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addLeg() {
|
async function addLeg() {
|
||||||
if (!selectedRouteId.value || !newLeg.value.description) {
|
if (!selectedRouteId.value || !newLeg.value.description) {
|
||||||
alert('Please enter a description');
|
await alert('Please enter a description');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,14 +255,14 @@ async function addLeg() {
|
||||||
|
|
||||||
updateMapMarkers();
|
updateMapMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to add leg');
|
await alert('Failed to add leg');
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLeg(legId: string) {
|
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 {
|
try {
|
||||||
await routeService.deleteLeg(selectedRouteId.value!, legId);
|
await routeService.deleteLeg(selectedRouteId.value!, legId);
|
||||||
|
|
@ -268,7 +274,7 @@ async function deleteLeg(legId: string) {
|
||||||
|
|
||||||
updateMapMarkers();
|
updateMapMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete leg');
|
await alert('Failed to delete leg');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,7 +380,7 @@ onUnmounted(() => {
|
||||||
<footer>
|
<footer>
|
||||||
<small>
|
<small>
|
||||||
{{ routeItem.routeLegs?.length || 0 }} legs
|
{{ routeItem.routeLegs?.length || 0 }} legs
|
||||||
· {{ getRouteDistance(routeItem).toFixed(2) }} km
|
· {{ formatDistance(getRouteDistance(routeItem), unitPreference) }}
|
||||||
</small>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'leaflet/dist/leaflet.css';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import type { Game, Team, ChatMessage, Route } from '../types';
|
import type { Game, Team, ChatMessage, Route } from '../types';
|
||||||
import { gameService, teamService, routeService } from '../services/api';
|
import { gameService, teamService, routeService } from '../services/api';
|
||||||
|
import { alert, confirm } from '../composables/useModal';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
@ -109,10 +110,22 @@ function connectSocket() {
|
||||||
team.lat = data.lat;
|
team.lat = data.lat;
|
||||||
team.lng = data.lng;
|
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]) {
|
if (teamMarkers[data.teamId]) {
|
||||||
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
|
teamMarkers[data.teamId].remove();
|
||||||
} else if (map) {
|
}
|
||||||
const marker = L.marker([data.lat, data.lng])
|
|
||||||
|
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)
|
.addTo(map)
|
||||||
.bindPopup(team.name);
|
.bindPopup(team.name);
|
||||||
teamMarkers[data.teamId] = marker;
|
teamMarkers[data.teamId] = marker;
|
||||||
|
|
@ -148,30 +161,30 @@ async function advanceTeam(teamId: string) {
|
||||||
await loadGame();
|
await loadGame();
|
||||||
socket?.emit('team-advanced', { gameId: gameId.value, teamId });
|
socket?.emit('team-advanced', { gameId: gameId.value, teamId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to advance team');
|
await alert('Failed to advance team');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deductTime(teamId: string) {
|
async function deductTime(teamId: string) {
|
||||||
const seconds = prompt('Enter deduction in seconds:', '60');
|
const secondsStr = window.prompt('Enter deduction in seconds:', '60');
|
||||||
if (!seconds) return;
|
if (!secondsStr) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await teamService.deduct(teamId, parseInt(seconds));
|
await teamService.deduct(teamId, parseInt(secondsStr));
|
||||||
await loadGame();
|
await loadGame();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to deduct time');
|
await alert('Failed to deduct time');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disqualifyTeam(teamId: string) {
|
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 {
|
try {
|
||||||
await teamService.disqualify(teamId);
|
await teamService.disqualify(teamId);
|
||||||
await loadGame();
|
await loadGame();
|
||||||
} catch (err) {
|
} 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 { useAuthStore } from '../stores/auth';
|
||||||
import type { Game } from '../types';
|
import type { Game } from '../types';
|
||||||
import { gameService } from '../services/api';
|
import { gameService } from '../services/api';
|
||||||
|
import { alert, confirm } from '../composables/useModal';
|
||||||
|
import { formatRadius } from '../utils/units';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
@ -14,6 +16,7 @@ const error = ref('');
|
||||||
|
|
||||||
const gameId = computed(() => route.params.id as string);
|
const gameId = computed(() => route.params.id as string);
|
||||||
const isGameMaster = computed(() => game.value?.gameMasterId === authStore.user?.id);
|
const isGameMaster = computed(() => game.value?.gameMasterId === authStore.user?.id);
|
||||||
|
const unitPreference = computed(() => authStore.user?.unitPreference || 'METRIC');
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
@ -29,46 +32,46 @@ async function loadGame() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishGame() {
|
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 {
|
try {
|
||||||
await gameService.publish(gameId.value);
|
await gameService.publish(gameId.value);
|
||||||
await loadGame();
|
await loadGame();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to publish game');
|
await alert('Failed to publish game');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveGame() {
|
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 {
|
try {
|
||||||
await gameService.archive(gameId.value);
|
await gameService.archive(gameId.value);
|
||||||
await loadGame();
|
await loadGame();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to archive game');
|
await alert('Failed to archive game');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unarchiveGame() {
|
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 {
|
try {
|
||||||
await gameService.unarchive(gameId.value);
|
await gameService.unarchive(gameId.value);
|
||||||
await loadGame();
|
await loadGame();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to unarchive game');
|
await alert('Failed to unarchive game');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function endGame() {
|
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 {
|
try {
|
||||||
await gameService.end(gameId.value);
|
await gameService.end(gameId.value);
|
||||||
await loadGame();
|
await loadGame();
|
||||||
} catch (err) {
|
} 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 response = await gameService.getInvite(gameId.value);
|
||||||
const link = `${window.location.origin}/invite/${response.data.inviteCode}`;
|
const link = `${window.location.origin}/invite/${response.data.inviteCode}`;
|
||||||
await navigator.clipboard.writeText(link);
|
await navigator.clipboard.writeText(link);
|
||||||
alert('Invite link copied to clipboard!');
|
await alert('Invite link copied to clipboard!');
|
||||||
} catch (err) {
|
} 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>
|
<dd v-else>Not set</dd>
|
||||||
|
|
||||||
<dt>Search Radius</dt>
|
<dt>Search Radius</dt>
|
||||||
<dd>{{ game.searchRadius || 500 }} meters</dd>
|
<dd>{{ formatRadius(game.searchRadius || 500, unitPreference) }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import type { Game, Team } from '../types';
|
import type { Game, Team } from '../types';
|
||||||
import { gameService, teamService } from '../services/api';
|
import { gameService, teamService } from '../services/api';
|
||||||
|
import { alert } from '../composables/useModal';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -44,7 +45,7 @@ async function loadGame() {
|
||||||
|
|
||||||
async function createTeam() {
|
async function createTeam() {
|
||||||
if (!newTeamName.value.trim()) {
|
if (!newTeamName.value.trim()) {
|
||||||
alert('Please enter a team name');
|
await alert('Please enter a team name');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ async function createTeam() {
|
||||||
await loadGame();
|
await loadGame();
|
||||||
router.push(`/games/${gameId.value}/play`);
|
router.push(`/games/${gameId.value}/play`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to create team');
|
await alert('Failed to create team');
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +69,7 @@ async function joinTeam(teamId: string) {
|
||||||
await loadGame();
|
await loadGame();
|
||||||
router.push(`/games/${gameId.value}/play`);
|
router.push(`/games/${gameId.value}/play`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to join team');
|
await alert('Failed to join team');
|
||||||
} finally {
|
} finally {
|
||||||
joining.value = false;
|
joining.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'leaflet/dist/leaflet.css';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import type { Game, Team, ChatMessage, Route, RouteLeg } from '../types';
|
import type { Game, Team, ChatMessage, Route, RouteLeg } from '../types';
|
||||||
import { teamService, uploadService } from '../services/api';
|
import { teamService, uploadService } from '../services/api';
|
||||||
|
import { alert } from '../composables/useModal';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
@ -164,7 +165,7 @@ function handlePhotoSelect(event: Event) {
|
||||||
|
|
||||||
async function submitPhoto() {
|
async function submitPhoto() {
|
||||||
if (!photoFile.value || !currentLeg.value || !team.value) {
|
if (!photoFile.value || !currentLeg.value || !team.value) {
|
||||||
alert('Please select a photo');
|
await alert('Please select a photo');
|
||||||
return;
|
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;
|
showPhotoUpload.value = false;
|
||||||
photoFile.value = null;
|
photoFile.value = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to submit photo');
|
await alert('Failed to submit photo');
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false;
|
uploading.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { userService, uploadService } from '../services/api';
|
import { userService, uploadService } from '../services/api';
|
||||||
import type { User, UserGameHistory, LocationHistory } from '../types';
|
import type { User, UserGameHistory, LocationHistory } from '../types';
|
||||||
|
import { alert, confirm } from '../composables/useModal';
|
||||||
|
import { formatDistance } from '../utils/units';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
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 locationHistory = ref<{ totalLocations: number; byGame: { game: { id: string; name: string }; locations: LocationHistory[]; locationCount: number }[] }>({ totalLocations: 0, byGame: [] });
|
||||||
const gamesHistory = ref<UserGameHistory[]>([]);
|
const gamesHistory = ref<UserGameHistory[]>([]);
|
||||||
|
|
||||||
|
const unitPreference = ref<'METRIC' | 'IMPERIAL'>('METRIC');
|
||||||
|
|
||||||
|
const displayUnitPreference = computed(() => unitPreference.value);
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -31,6 +37,7 @@ async function loadProfile() {
|
||||||
screenName.value = response.data.screenName || '';
|
screenName.value = response.data.screenName || '';
|
||||||
avatarUrl.value = response.data.avatarUrl || '';
|
avatarUrl.value = response.data.avatarUrl || '';
|
||||||
avatarPreview.value = response.data.avatarUrl || '';
|
avatarPreview.value = response.data.avatarUrl || '';
|
||||||
|
unitPreference.value = response.data.unitPreference || 'METRIC';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load profile:', err);
|
console.error('Failed to load profile:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -65,7 +72,7 @@ async function handleAvatarSelect(event: Event) {
|
||||||
avatarUrl.value = uploadRes.data.url;
|
avatarUrl.value = uploadRes.data.url;
|
||||||
avatarPreview.value = uploadRes.data.url;
|
avatarPreview.value = uploadRes.data.url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to upload avatar');
|
await alert('Failed to upload avatar');
|
||||||
} finally {
|
} finally {
|
||||||
uploadingAvatar.value = false;
|
uploadingAvatar.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -78,40 +85,41 @@ async function saveProfile() {
|
||||||
const response = await userService.updateProfile({
|
const response = await userService.updateProfile({
|
||||||
name: name.value,
|
name: name.value,
|
||||||
screenName: screenName.value || undefined,
|
screenName: screenName.value || undefined,
|
||||||
avatarUrl: avatarUrl.value || undefined
|
avatarUrl: avatarUrl.value || undefined,
|
||||||
|
unitPreference: unitPreference.value
|
||||||
});
|
});
|
||||||
user.value = response.data;
|
user.value = response.data;
|
||||||
authStore.user = response.data;
|
authStore.user = response.data;
|
||||||
alert('Profile updated successfully!');
|
await alert('Profile updated successfully!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to update profile');
|
await alert('Failed to update profile');
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLocationData() {
|
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 {
|
try {
|
||||||
await userService.deleteLocationData();
|
await userService.deleteLocationData();
|
||||||
alert('Location data deleted.');
|
await alert('Location data deleted.');
|
||||||
loadLocationHistory();
|
loadLocationHistory();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete location data');
|
await alert('Failed to delete location data');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAccount() {
|
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 (!await 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('This is your final warning. This action cannot be undone.')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userService.deleteAccount();
|
await userService.deleteAccount();
|
||||||
authStore.logout();
|
authStore.logout();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (err) {
|
} 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" />
|
<input v-model="screenName" type="text" placeholder="Optional alias" />
|
||||||
</label>
|
</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">
|
<button @click="saveProfile" :disabled="saving">
|
||||||
{{ saving ? 'Saving...' : 'Save Profile' }}
|
{{ saving ? 'Saving...' : 'Save Profile' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -274,7 +290,7 @@ onMounted(() => {
|
||||||
<small>Legs</small>
|
<small>Legs</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<strong>{{ game.totalDistance }} km</strong>
|
<strong>{{ formatDistance(game.totalDistance, displayUnitPreference) }}</strong>
|
||||||
<small>Distance</small>
|
<small>Distance</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,22 @@ function connectSocket() {
|
||||||
team.lat = data.lat;
|
team.lat = data.lat;
|
||||||
team.lng = data.lng;
|
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]) {
|
if (teamMarkers[data.teamId]) {
|
||||||
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
|
teamMarkers[data.teamId].remove();
|
||||||
} else if (map) {
|
}
|
||||||
const marker = L.marker([data.lat, data.lng])
|
|
||||||
|
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)
|
.addTo(map)
|
||||||
.bindPopup(team.name);
|
.bindPopup(team.name);
|
||||||
teamMarkers[data.teamId] = marker;
|
teamMarkers[data.teamId] = marker;
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export const uploadService = {
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
getProfile: () => api.get<User>('/users/me'),
|
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),
|
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'),
|
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'),
|
getGamesHistory: () => api.get<UserGameHistory[]>('/users/me/games'),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export interface User {
|
||||||
name: string;
|
name: string;
|
||||||
screenName?: string;
|
screenName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
unitPreference?: 'METRIC' | 'IMPERIAL';
|
||||||
createdAt?: string;
|
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