Many more changes for btter experience.
This commit is contained in:
parent
87059a62e2
commit
e2a95252bb
16 changed files with 385 additions and 72 deletions
|
|
@ -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