Add specific team chat channels, and update README

This commit is contained in:
Brian McGonagill 2026-03-25 08:32:15 -05:00
parent e2a95252bb
commit 2ab11f7a4b
8 changed files with 351 additions and 97 deletions

234
README.md
View file

@ -2,54 +2,51 @@
An online scavenger hunt application where Game Masters create and manage hunts, Teams compete to complete clues, and Spectators can follow along in real-time. An online scavenger hunt application where Game Masters create and manage hunts, Teams compete to complete clues, and Spectators can follow along in real-time.
## Inspration ## Inspiration
Scavenger Hunts, of course, but also a Movie from my childhood called "Midnight Madness". A game master creates teams from people he knows at his University. The Jocks, The Nerds, The awkward girls, and the average guy / girl mix. Yep, as cliche as you can get. BTW - Michael J Fox's first movie role as a young teen. Scavenger Hunts, of course, but also a Movie from my childhood called "Midnight Madness". A game master creates teams from people he knows at his University. The Jocks, The Nerds, The awkward girls, and the average guy / girl mix. Yep, as cliche as you can get. BTW - Michael J Fox's first movie role as a young teen.
## Game Play ## Game Play
### Game Master ### Game Master
Users can create a game, making them the Game Master. Give your game a name, and if you'd like a prize for the winner(s). Set the radius of the map the game legs and objectives should remain within. Set game rules for all to read and abide by. Set default leg time-outs, and time based penalty periods for taking too long, or breaking rules. And get on with creating routes. Users can create a game, making them the Game Master. Give your game a name, and if you'd like a prize for the winner(s). Set the radius of the map the game legs and objectives should remain within. Set game rules for all to read and abide by. Set default leg time-outs, and time based penalty periods for taking too long, or breaking rules. And get on with creating routes.
You can create 1 route for all teams to use, or you can create multiple routes, perhaps 1 per team. The system sows distance totlas for each route, so you can make sure they are approximately equal if needed. You can create 1 route for all teams to use, or you can create multiple routes, perhaps 1 per team. The system shows distance totals for each route, so you can make sure they are approximately equal if needed.
You can invite players who will make teams, and keep the game private, or you can make a public game, which still allows invites, but you can also allow useers to simply join a game and make their team. You can invite players who will make teams, and keep the game private, or you can make a public game, which still allows invites, but you can also allow users to simply join a game and make their team.
Game masters devise routes which can each have multiple legs and a clue for each leg. The game allows for taking a photo to prove the completion of the objective of a leg, or perhaps you want to encourage patronage at local businesses so you make it a purchase leg (where they still send a photo fo their purchase and receipt). It's all in good fun. Game masters devise routes which can each have multiple legs and a clue for each leg. The game allows for taking a photo to prove the completion of the objective of a leg, or perhaps you want to encourage patronage at local businesses so you make it a purchase leg (where they still send a photo of their purchase and receipt). It's all in good fun.
### Teams and Team Members ### Teams and Team Members
Once the teams are formed, and the routes, legs, and clues set, the game master will start the game. Once the teams are formed, and the routes, legs, and clues set, the game master will start the game.
The temas will attempt to solve a clue (if needed), and get to their first objective in the allotted time for the leg. Provide the required proof, and the game master will approve or deny their ability to go to the next leg. The teams will attempt to solve a clue (if needed), and get to their first objective in the allotted time for the leg. Provide the required proof, and the game master will approve or deny their ability to go to the next leg.
The game master will control the game from the game master dashboard, where the game master see's all temas' locations on the map, which leg they are on, and can chat with teams as needed. The game master will control the game from the game master dashboard, where the game master sees all teams' locations on the map, which leg they are on, and can chat with teams as needed (broadcast or direct messages).
The temas have the mobile version which runs right in your mobile device browser. Recommeneded to just have the team captain's device for the game. Don't leave the browser window during the game, or take a chance on a time based deduction, or removal from the game. Allow location tracking, and your team is set. The teams have the mobile version which runs right in your mobile device browser. Recommended to just have the team captain's device for the game. Don't leave the browser window during the game, or take a chance on a time based deduction, or removal from the game. Allow location tracking, and your team is set.
Use the map, built in photo taking options, and chat as needed to get clues, and complete objectives. And be the first team to the end. Use the map, built in photo sharing options, and chat as needed to get clues, and complete objectives. And be the first team to the end.
### Spectators ### Spectators
Part of "Midnight Madness" was how the Game Master's neighbors all got into the game and made it as exciting from the control room as it was on the streets. Part of "Midnight Madness" was how the Game Master's neighbors all got into the game and made it as exciting from the control room as it was on the streets.
With that in mind, public games will have a spectator mode, they'll be able to follow the team progression throughout the race. With that in mind, public games will have a spectator mode, they'll be able to follow the team progression throughout the race.
Could there be something where a spectator could assist the Game Master in some way? I need to ponder this a bit.
## Who would ever use this? ## Who would ever use this?
Well, me... I have an annual family reunion, and I think my family would have a blast with something like this. Well, me... I have an annual family reunion, and I think my family would have a blast with something like this.
Also, perhaps as a way to earn money for charity, or just a sa way to have local activities that people can join in on. It's a great way to get out of the house. Also, perhaps as a way to earn money for charity, or just a way to have local activities that people can join in on. It's a great way to get out of the house.
What about the Game Master? Don't forget, in the movie the game master moved to the final objective to meet the teams and be there to personally congratulate the winners. His neighbors all went with him as well. A laptop, and mobile hotspot, or tablet would likely be ideal for such mobility in this day and age! What about the Game Master? Don't forget, in the movie the game master moved to the final objective to meet the teams and be there to personally congratulate the winners. His neighbors all went with him as well. A laptop, and mobile hotspot, or tablet would likely be ideal for such mobility in this day and age!
In the end, this was a great way for me to work with an AI system that was doing the heavy lifting. It let me learn a lot about how AI operates when building applications, and how best to interact with the AI in the process.
## Features ## Features
- **Game Master Dashboard**: - **Game Master Dashboard**:
- Create scavenger hunts with routes which can each have mutliple legs and clues, - Create scavenger hunts with routes which can each have multiple legs and clues,
- Manage live games, - Manage live games,
- View team progress - View team progress on real-time map,
- Chat with all teams or direct message specific teams
- **Team Interface**: - **Team Interface**:
- Mobile-friendly gameplay with clues - Mobile-friendly gameplay with clues,
- Map navigation, - Map navigation,
- Photo submissions, and - Photo submissions, and
- Real-time chat - Real-time chat
@ -59,16 +56,19 @@ In the end, this was a great way for me to work with an AI system that was doing
- Live tracking via WebSockets (Socket.io) - Live tracking via WebSockets (Socket.io)
- **OpenStreetMap Integration**: - **OpenStreetMap Integration**:
- All maps use OpenStreetMap data - All maps use OpenStreetMap data
- **User Preferences**:
- Metric/Imperial distance units
## Tech Stack ## Tech Stack
| Component | Technology | | Component | Technology |
|-----------|------------| |-----------|------------|
| Frontend | Vue 3 + TypeScript + Vite | | Frontend | Vue 3 + TypeScript + Vite + Pinia |
| Backend | Node.js + Express | | Backend | Node.js + Express + TypeScript |
| Real-time | Socket.io | | Real-time | Socket.io |
| Database | PostgreSQL + Prisma | | Database | PostgreSQL + Prisma |
| Maps | Leaflet + OpenStreetMap | | Maps | Leaflet + OpenStreetMap |
| Styling | Pico CSS |
## Prerequisites ## Prerequisites
@ -85,13 +85,13 @@ The easiest way to run the application:
```bash ```bash
# Start all services (PostgreSQL, Backend, Frontend) # Start all services (PostgreSQL, Backend, Frontend)
docker-compose up -d docker compose up -d
# View logs # View logs
docker-compose logs -f docker compose logs -f
# Stop services # Stop services
docker-compose down docker compose down
``` ```
The application will be available at: The application will be available at:
@ -166,7 +166,7 @@ The frontend will run on http://localhost:5173
4. Click "Create Game" to save as draft 4. Click "Create Game" to save as draft
5. Click "Edit Game" to add routes / legs / clues 5. Click "Edit Game" to add routes / legs / clues
6. Once legs are added, click "Publish Game" to make it live 6. Once legs are added, click "Publish Game" to make it live
7. Use the "Live Dashboard" to monitor teams 7. Use the "Live Dashboard" to monitor teams and communicate via chat
### As a Team Member ### As a Team Member
@ -189,78 +189,156 @@ The frontend will run on http://localhost:5173
## Project Structure ## Project Structure
``` ```
/home/brian/Development/scavenge TreasureTrails/
├── docker-compose.yml # Docker Compose configuration ├── docker-compose.yml # Docker Compose configuration
├── README.md # This file
├── application_requirements.md # Original requirements
├── backend/ ├── backend/
│ ├── Dockerfile # Backend container │ ├── Dockerfile # Backend container
│ ├── package.json
│ ├── tsconfig.json
│ ├── prisma/ │ ├── prisma/
│ │ └── schema.prisma # Database schema │ │ └── schema.prisma # Database schema
│ ├── src/ │ └── src/
│ │ ├── index.ts # Server entry point │ ├── index.ts # Server entry point
│ │ ├── middleware/ │ ├── middleware/
│ │ │ └── auth.ts # JWT authentication │ │ └── auth.ts # JWT authentication
│ │ ├── routes/ │ ├── routes/
│ │ │ ├── auth.ts # Auth endpoints │ │ ├── auth.ts # Registration, login, me
│ │ │ ├── games.ts # Game CRUD │ │ ├── games.ts # Game CRUD, publish, archive
│ │ │ ├── legs.ts # Leg/clue management │ │ ├── routes.ts # Route and leg management
│ │ │ ├── teams.ts # Team management │ │ ├── teams.ts # Team CRUD, advance, deduct
│ │ │ └── upload.ts # File uploads │ │ ├── upload.ts # Photo uploads
│ │ └── socket/ │ │ └── users.ts # User profile, settings
│ │ └── index.ts # Socket.io handlers │ └── socket/
│ ├── .env # Environment variables │ └── index.ts # Socket.io handlers
│ └── package.json
├── frontend/ ├── frontend/
│ ├── Dockerfile # Frontend container │ ├── Dockerfile # Frontend container
│ ├── nginx.conf # Nginx configuration │ ├── nginx.conf # Nginx configuration
│ ├── src/ │ ├── index.html
│ │ ├── pages/ # Vue page components │ ├── package.json
│ │ ├── stores/ # Pinia state management │ ├── tsconfig.json
│ │ ├── services/ # API service │ ├── vite.config.ts
│ │ ├── types/ # TypeScript types │ └── src/
│ │ ├── router/ # Vue Router config │ ├── main.ts # App entry point
│ │ ├── App.vue │ ├── App.vue # Root component
│ │ └── main.ts │ ├── style.css # Global styles
│ └── package.json │ ├── components/
├── application_requirements.md # Original requirements │ │ ├── Modal.vue # Reusable modal component
└── README.md # This file │ │ └── NavBar.vue # Navigation bar
│ ├── composables/
│ │ └── useModal.ts # Modal alert/confirm composable
│ ├── pages/
│ │ ├── HomePage.vue # Landing page
│ │ ├── LoginPage.vue # Login form
│ │ ├── RegisterPage.vue # Registration form
│ │ ├── DashboardPage.vue # User's games list
│ │ ├── CreateGamePage.vue # Game creation wizard
│ │ ├── GamePage.vue # Game details & management
│ │ ├── EditGamePage.vue # Route/leg editor
│ │ ├── JoinGamePage.vue # Team creation/joining
│ │ ├── PlayGamePage.vue # Team gameplay interface
│ │ ├── GameLivePage.vue # Game Master dashboard
│ │ ├── SpectateGamePage.vue # Public spectator view
│ │ ├── InvitePage.vue # Invite link landing
│ │ └── SettingsPage.vue # User profile settings
│ ├── router/
│ │ └── index.ts # Vue Router configuration
│ ├── services/
│ │ └── api.ts # Axios API service
│ ├── stores/
│ │ └── auth.ts # Pinia auth store
│ ├── types/
│ │ └── index.ts # TypeScript interfaces
│ └── utils/
│ └── units.ts # Distance conversion utilities
``` ```
## API Endpoints ## API Endpoints
### Authentication ### Authentication
- `POST /api/auth/register` - Create account | Method | Endpoint | Description |
- `POST /api/auth/login` - Login |--------|----------|-------------|
- `GET /api/auth/me` - Get current user | POST | `/api/auth/register` | Create new account |
| POST | `/api/auth/login` | Login to account |
| GET | `/api/auth/me` | Get current user info |
### Users
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/users/me` | Get user profile |
| PUT | `/api/users/me` | Update profile (name, screenName, avatar, unitPreference) |
| GET | `/api/users/me/location-history` | Get location tracking history |
| GET | `/api/users/me/games` | Get user's game participation history |
| DELETE | `/api/users/me/location-data` | Delete all location history |
| DELETE | `/api/users/me/account` | Delete user account |
### Games ### Games
- `GET /api/games` - List public games | Method | Endpoint | Description |
- `GET /api/games/my-games` - List user's games |--------|----------|-------------|
- `POST /api/games` - Create game | GET | `/api/games` | List public games |
- `GET /api/games/:id` - Get game details | GET | `/api/games/my-games` | List games created by user |
- `PUT /api/games/:id` - Update game | POST | `/api/games` | Create new game |
- `POST /api/games/:id/publish` - Publish game | GET | `/api/games/:id` | Get game details |
- `GET /api/games/:id/invite` - Get invite code | PUT | `/api/games/:id` | Update game settings |
| DELETE | `/api/games/:id` | Delete game (draft only) |
| POST | `/api/games/:id/publish` | Publish game (make live) |
| POST | `/api/games/:id/end` | End a live game |
| POST | `/api/games/:id/archive` | Archive ended game |
| POST | `/api/games/:id/unarchive` | Unarchive game |
| GET | `/api/games/:id/invite` | Get game invite code |
| GET | `/api/games/invite/:code` | Get game by invite code |
### Legs ### Routes
- `POST /api/legs/game/:gameId` - Add leg | Method | Endpoint | Description |
- `PUT /api/legs/:legId` - Update leg |--------|----------|-------------|
- `DELETE /api/legs/:legId` - Delete leg | GET | `/api/routes/game/:gameId` | Get routes for a game |
- `POST /api/legs/:legId/photo` - Submit photo | GET | `/api/routes/:routeId` | Get route details |
| POST | `/api/routes` | Create new route |
| PUT | `/api/routes/:routeId` | Update route |
| DELETE | `/api/routes/:routeId` | Delete route |
| POST | `/api/routes/:routeId/copy` | Copy route |
| POST | `/api/routes/:routeId/legs` | Add leg to route |
| PUT | `/api/routes/:routeId/legs/:legId` | Update leg |
| DELETE | `/api/routes/:routeId/legs/:legId` | Delete leg |
| POST | `/api/routes/:routeId/legs/:legId/photo` | Submit photo for leg |
### Teams ### Teams
- `GET /api/teams/game/:gameId` - List teams | Method | Endpoint | Description |
- `POST /api/teams/game/:gameId` - Create team |--------|----------|-------------|
- `POST /api/teams/:teamId/join` - Join team | GET | `/api/teams/game/:gameId` | List teams in game |
- `POST /api/teams/:teamId/advance` - GM advances team | GET | `/api/teams/:teamId` | Get team details |
- `POST /api/teams/:teamId/deduct` - GM applies time penalty | POST | `/api/teams/game/:gameId` | Create team |
- `POST /api/teams/:teamId/disqualify` - GM disqualifies team | POST | `/api/teams/:teamId/join` | Join team |
| POST | `/api/teams/:teamId/leave` | Leave team |
| POST | `/api/teams/:teamId/assign-route` | Assign route to team |
| POST | `/api/teams/:teamId/advance` | Advance team to next leg |
| POST | `/api/teams/:teamId/deduct` | Apply time penalty |
| POST | `/api/teams/:teamId/disqualify` | Disqualify team |
| POST | `/api/teams/:teamId/location` | Update team location |
### Upload
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/upload/upload` | Upload photo file |
## Socket Events ## Socket Events
- `join-game` - Join a game's socket room ### Client → Server
- `team-location` - Broadcast team position | Event | Payload | Description |
- `chat-message` - Send/receive chat |-------|---------|-------------|
- `team-advanced` - Notify team advancement | `join-game` | `gameId` | Join a game's socket room |
| `leave-game` | `gameId` | Leave a game's socket room |
| `team-location` | `{ gameId, teamId, lat, lng }` | Broadcast team position |
| `chat-message` | `{ gameId, teamId?, isDirect?, message, userId, userName }` | Send chat message |
| `team-advanced` | `{ gameId, teamId }` | Notify team advancement |
### Server → Client
| Event | Payload | Description |
|-------|---------|-------------|
| `team-location` | `{ teamId, lat, lng }` | Team position update |
| `chat-message` | `{ id, teamId?, isDirect, userId, userName, message, sentAt }` | Chat message (filtered by direct) |
| `team-advanced` | `{ teamId }` | Team advanced to next leg |
## Environment Variables ## Environment Variables

View file

@ -135,12 +135,13 @@ model PhotoSubmission {
model ChatMessage { model ChatMessage {
id String @id @default(uuid()) id String @id @default(uuid())
gameId String gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
teamId String? teamId String?
team Team? @relation(fields: [teamId], references: [id]) team Team? @relation(fields: [teamId], references: [id])
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
message String message String
isDirect Boolean @default(false)
sentAt DateTime @default(now()) sentAt DateTime @default(now())
} }

View file

@ -201,6 +201,10 @@ router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
return res.status(403).json({ error: 'Not authorized' }); return res.status(403).json({ error: 'Not authorized' });
} }
if (game.status !== 'DRAFT') {
return res.status(400).json({ error: 'Only draft games can be deleted' });
}
await prisma.game.delete({ where: { id } }); await prisma.game.delete({ where: { id } });
res.json({ message: 'Game deleted' }); res.json({ message: 'Game deleted' });

View file

@ -26,24 +26,39 @@ export default function setupSocket(io: Server) {
}); });
}); });
socket.on('chat-message', async (data: { gameId: string; teamId?: string; message: string; userId: string; userName: string }) => { socket.on('chat-message', async (data: {
gameId: string;
teamId?: string;
isDirect?: boolean;
message: string;
userId: string;
userName: string
}) => {
const chatMessage = await prisma.chatMessage.create({ const chatMessage = await prisma.chatMessage.create({
data: { data: {
gameId: data.gameId, gameId: data.gameId,
teamId: data.teamId, teamId: data.teamId || null,
userId: data.userId, userId: data.userId,
message: data.message message: data.message,
isDirect: data.isDirect || false
} }
}); });
io.to(`game:${data.gameId}`).emit('chat-message', { const messageData = {
id: chatMessage.id, id: chatMessage.id,
teamId: data.teamId, teamId: data.teamId,
isDirect: chatMessage.isDirect,
userId: data.userId, userId: data.userId,
userName: data.userName, userName: data.userName,
message: data.message, message: data.message,
sentAt: chatMessage.sentAt sentAt: chatMessage.sentAt
}); };
if (data.isDirect && data.teamId) {
io.to(`game:${data.gameId}`).emit('chat-message', messageData);
} else {
io.to(`game:${data.gameId}`).emit('chat-message', messageData);
}
}); });
socket.on('team-advanced', async (data: { gameId: string; teamId: string }) => { socket.on('team-advanced', async (data: { gameId: string; teamId: string }) => {

View file

@ -25,6 +25,7 @@ let map: L.Map | null = null;
let teamMarkers: { [key: string]: L.Marker } = {}; let teamMarkers: { [key: string]: L.Marker } = {};
const chatMessage = ref(''); const chatMessage = ref('');
const chatTarget = ref<'all' | string>('all');
const selectedTeam = ref<Team | null>(null); const selectedTeam = ref<Team | null>(null);
async function loadGame() { async function loadGame() {
@ -134,7 +135,11 @@ function connectSocket() {
}); });
socket.on('chat-message', (data: ChatMessage) => { socket.on('chat-message', (data: ChatMessage) => {
chatMessages.value.push(data); if (data.isDirect && data.teamId) {
chatMessages.value.push(data);
} else if (!data.isDirect) {
chatMessages.value.push(data);
}
}); });
socket.on('team-advanced', () => { socket.on('team-advanced', () => {
@ -145,8 +150,12 @@ function connectSocket() {
async function sendChat() { async function sendChat() {
if (!chatMessage.value.trim() || !socket) return; if (!chatMessage.value.trim() || !socket) return;
const isDirect = chatTarget.value !== 'all';
socket.emit('chat-message', { socket.emit('chat-message', {
gameId: gameId.value, gameId: gameId.value,
teamId: isDirect ? chatTarget.value : undefined,
isDirect,
message: chatMessage.value, message: chatMessage.value,
userId: authStore.user?.id, userId: authStore.user?.id,
userName: authStore.user?.name userName: authStore.user?.name
@ -271,15 +280,39 @@ onUnmounted(() => {
<section> <section>
<h2>Chat</h2> <h2>Chat</h2>
<div class="chat-target">
<label>
Send to:
<select v-model="chatTarget">
<option value="all">All Teams</option>
<option v-for="team in teams" :key="team.id" :value="team.id">
{{ team.name }} only
</option>
</select>
</label>
</div>
<div class="chat-messages"> <div class="chat-messages">
<article v-for="msg in chatMessages" :key="msg.id" style="margin: 0.5rem 0; padding: 0.5rem;"> <article
<strong>{{ msg.userName }}:</strong> {{ msg.message }} v-for="msg in chatMessages"
:key="msg.id"
class="chat-message"
:class="{ 'direct-message': msg.isDirect }"
style="margin: 0.5rem 0; padding: 0.5rem;"
>
<div class="message-header">
<strong>{{ msg.userName }}</strong>
<span v-if="msg.isDirect" class="direct-badge">
{{ teams.find(t => t.id === msg.teamId)?.name || 'Unknown' }}
</span>
<span v-else class="broadcast-badge"> All</span>
</div>
<div class="message-text">{{ msg.message }}</div>
</article> </article>
<article v-if="!chatMessages.length" style="text-align: center; color: var(--pico-muted-color);"> <article v-if="!chatMessages.length" style="text-align: center; color: var(--pico-muted-color);">
No messages yet No messages yet
</article> </article>
</div> </div>
<form @submit.prevent="sendChat" class="grid"> <form @submit.prevent="sendChat" class="chat-form">
<input v-model="chatMessage" placeholder="Type a message..." /> <input v-model="chatMessage" placeholder="Type a message..." />
<button type="submit">Send</button> <button type="submit">Send</button>
</form> </form>
@ -300,8 +333,63 @@ onUnmounted(() => {
} }
.chat-messages { .chat-messages {
height: calc(100% - 60px); height: calc(100% - 120px);
overflow-y: auto; overflow-y: auto;
margin-bottom: 0.5rem;
}
.chat-target {
margin-bottom: 0.5rem;
}
.chat-target select {
width: 100%;
}
.chat-form {
display: flex;
gap: 0.5rem;
}
.chat-form input {
flex: 1;
margin: 0;
}
.chat-message {
border-left: 3px solid var(--pico-primary-background-color);
}
.chat-message.direct-message {
border-left-color: var(--pico-warning-background-color);
background: var(--pico-muted-border-color);
}
.message-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.broadcast-badge, .direct-badge {
font-size: 0.75rem;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.broadcast-badge {
background: var(--pico-primary-background-color);
color: white;
}
.direct-badge {
background: var(--pico-warning-background-color);
color: var(--pico-warning-color);
}
.message-text {
word-break: break-word;
} }
.selected { .selected {

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router'; import { useRoute, RouterLink, useRouter } 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';
@ -8,6 +8,7 @@ import { alert, confirm } from '../composables/useModal';
import { formatRadius } from '../utils/units'; import { formatRadius } from '../utils/units';
const route = useRoute(); const route = useRoute();
const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const game = ref<Game | null>(null); const game = ref<Game | null>(null);
@ -86,6 +87,17 @@ async function copyInviteLink() {
} }
} }
async function deleteGame() {
if (!await confirm('Are you sure you want to delete this game? This action cannot be undone.', 'Delete Game', true)) return;
try {
await gameService.delete(gameId.value);
router.push('/dashboard');
} catch (err: any) {
await alert(err.response?.data?.error || 'Failed to delete game');
}
}
onMounted(() => { onMounted(() => {
loadGame(); loadGame();
}); });
@ -115,6 +127,7 @@ onMounted(() => {
<div v-if="isGameMaster" class="grid"> <div v-if="isGameMaster" class="grid">
<RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" role="button" class="secondary">Edit Game</RouterLink> <RouterLink v-if="game.status === 'DRAFT'" :to="`/games/${game.id}/edit`" role="button" class="secondary">Edit Game</RouterLink>
<button v-if="game.status === 'DRAFT'" @click="publishGame">Publish Game</button> <button v-if="game.status === 'DRAFT'" @click="publishGame">Publish Game</button>
<button v-if="game.status === 'DRAFT'" @click="deleteGame" class="contrast">Delete Game</button>
<RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/live`" role="button">Live Dashboard</RouterLink> <RouterLink v-if="game.status === 'LIVE'" :to="`/games/${game.id}/live`" role="button">Live Dashboard</RouterLink>
<button v-if="game.status === 'LIVE'" @click="endGame" class="contrast">End Game</button> <button v-if="game.status === 'LIVE'" @click="endGame" class="contrast">End Game</button>
<button v-if="game.status === 'ENDED'" @click="archiveGame" class="secondary">Archive Game</button> <button v-if="game.status === 'ENDED'" @click="archiveGame" class="secondary">Archive Game</button>

View file

@ -111,7 +111,11 @@ function connectSocket() {
}); });
socket.on('chat-message', (data: ChatMessage) => { socket.on('chat-message', (data: ChatMessage) => {
chatMessages.value.push(data); if (!data.isDirect) {
chatMessages.value.push(data);
} else if (data.teamId === team.value?.id) {
chatMessages.value.push(data);
}
}); });
} }
@ -274,8 +278,22 @@ onUnmounted(() => {
<section> <section>
<h3>Chat</h3> <h3>Chat</h3>
<div class="chat-messages"> <div class="chat-messages">
<article v-for="msg in chatMessages" :key="msg.id" style="margin: 0.5rem 0; padding: 0.5rem;"> <article
<strong>{{ msg.userName }}:</strong> {{ msg.message }} v-for="msg in chatMessages"
:key="msg.id"
class="chat-message"
:class="{ 'direct-message': msg.isDirect }"
style="margin: 0.5rem 0; padding: 0.5rem;"
>
<div class="message-header">
<strong>{{ msg.userName }}</strong>
<span v-if="msg.isDirect" class="direct-badge"> To your team</span>
<span v-else class="broadcast-badge"> All teams</span>
</div>
<div class="message-text">{{ msg.message }}</div>
</article>
<article v-if="!chatMessages.length" style="text-align: center; color: var(--pico-muted-color);">
No messages yet
</article> </article>
</div> </div>
<form @submit.prevent="sendChat" class="grid"> <form @submit.prevent="sendChat" class="grid">
@ -319,6 +337,42 @@ onUnmounted(() => {
overflow-y: auto; overflow-y: auto;
} }
.chat-message {
border-left: 3px solid var(--pico-primary-background-color);
}
.chat-message.direct-message {
border-left-color: var(--pico-ins-color);
background: var(--pico-ins-background-color);
}
.message-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.broadcast-badge, .direct-badge {
font-size: 0.7rem;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.broadcast-badge {
background: var(--pico-primary-background-color);
color: white;
}
.direct-badge {
background: var(--pico-ins-background-color);
color: var(--pico-ins-color);
}
.message-text {
word-break: break-word;
}
.error { .error {
color: var(--pico-del-color); color: var(--pico-del-color);
} }

View file

@ -137,11 +137,12 @@ export interface PhotoSubmission {
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
gameId: string; gameId?: string;
teamId?: string; teamId?: string;
userId: string; userId: string;
userName: string; userName: string;
message: string; message: string;
isDirect?: boolean;
sentAt: string; sentAt: string;
} }