Many more changes for btter experience.

This commit is contained in:
Brian McGonagill 2026-03-24 18:48:29 -05:00
parent 87059a62e2
commit e2a95252bb
16 changed files with 385 additions and 72 deletions

View file

@ -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');
}
}

View file

@ -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>

View file

@ -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');
}
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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">

View file

@ -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;