diff --git a/frontend/angular/package-lock.json b/frontend/angular/package-lock.json index 64938bb..58281d3 100644 --- a/frontend/angular/package-lock.json +++ b/frontend/angular/package-lock.json @@ -11,6 +11,7 @@ "@angular/common": "^19.2.0", "@angular/compiler": "^19.2.0", "@angular/core": "^19.2.0", + "@angular/forms": "^19.2.0", "@angular/platform-browser": "^19.2.0", "@angular/router": "^19.2.0", "rxjs": "~7.8.0", @@ -372,6 +373,24 @@ "zone.js": "~0.15.0" } }, + "node_modules/@angular/forms": { + "version": "19.2.19", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.19.tgz", + "integrity": "sha512-J09++utTVaPs962y/adeDjIgqyhzNpnzAS7Nex+HNy/LnWPcTNW781cOh1EGS1X/+CmgnI8HWs5z4KGeBeU1aA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.19", + "@angular/core": "19.2.19", + "@angular/platform-browser": "19.2.19", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "19.2.19", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.19.tgz", diff --git a/frontend/angular/package.json b/frontend/angular/package.json index 7e4082a..e17dbff 100644 --- a/frontend/angular/package.json +++ b/frontend/angular/package.json @@ -10,6 +10,7 @@ "@angular/common": "^19.2.0", "@angular/compiler": "^19.2.0", "@angular/core": "^19.2.0", + "@angular/forms": "^19.2.0", "@angular/platform-browser": "^19.2.0", "@angular/router": "^19.2.0", "rxjs": "~7.8.0", diff --git a/frontend/angular/src/app/app.component.ts b/frontend/angular/src/app/app.component.ts index 7de6cdb..709cb9a 100644 --- a/frontend/angular/src/app/app.component.ts +++ b/frontend/angular/src/app/app.component.ts @@ -13,7 +13,7 @@ export class AppComponent { constructor() { const shellRoute = document.body.dataset['wppShellRoute']; - if (shellRoute === '/host' || shellRoute === '/player') { + if (shellRoute?.startsWith('/host') || shellRoute?.startsWith('/player')) { void this.router.navigateByUrl(shellRoute); } } diff --git a/frontend/angular/src/app/app.routes.ts b/frontend/angular/src/app/app.routes.ts index 78880a8..e086bb0 100644 --- a/frontend/angular/src/app/app.routes.ts +++ b/frontend/angular/src/app/app.routes.ts @@ -5,10 +5,26 @@ export const routes: Routes = [ path: 'host', loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), }, + { + path: 'host/:phase', + loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), + }, + { + path: 'host/:phase/:context', + loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), + }, { path: 'player', loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), }, + { + path: 'player/:phase', + loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), + }, + { + path: 'player/:phase/:context', + loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), + }, { path: '', pathMatch: 'full', redirectTo: 'player' }, { path: '**', redirectTo: 'player' }, ]; diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index af26146..0085e97 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -1,11 +1,156 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +interface SessionDetail { + session: { code: string; status: string; current_round: number }; + round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; + players: Array<{ id: number; nickname: string; score: number }>; +} @Component({ selector: 'app-host-shell', standalone: true, + imports: [CommonModule, FormsModule], template: ` -

Host route placeholder

-

Host flow flyttes hertil i kommende SPA-issues.

+

Host SPA gameplay flow

+ +
+ + + + + + + + + +
+ +

{{ error }}

+

{{ scoreboardError }}

+ +
+

Status: {{ session.session.status }} · round {{ session.session.current_round }}

+

Round question id: {{ roundQuestionId || '-' }}

+

Prompt: {{ session.round_question.prompt }}

+ +
{{ scoreboardPayload }}
+
`, }) -export class HostShellComponent {} +export class HostShellComponent { + sessionCode = ''; + categorySlug = 'general'; + roundQuestionId = ''; + loading = false; + error = ''; + scoreboardError = ''; + scoreboardPayload = ''; + session: SessionDetail | null = null; + + private normalizeCode(value: string): string { + return value.trim().toUpperCase(); + } + + private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { + const response = await fetch(path, { + method, + headers: { + Accept: 'application/json', + ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), + credentials: 'same-origin', + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`); + } + + return body as T; + } + + async refreshSession(): Promise { + this.loading = true; + this.error = ''; + this.scoreboardError = ''; + try { + const code = this.normalizeCode(this.sessionCode); + this.session = await this.request(`/lobby/sessions/${encodeURIComponent(code)}`, 'GET'); + this.sessionCode = this.session.session.code; + this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; + } catch (error) { + this.error = `Session refresh failed: ${(error as Error).message}`; + } finally { + this.loading = false; + } + } + + async startRound(): Promise { + await this.runAction(async () => { + const code = this.normalizeCode(this.sessionCode); + await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/start`, 'POST', { + category_slug: this.categorySlug.trim(), + }); + await this.refreshSession(); + }); + } + + async showQuestion(): Promise { + await this.runAction(async () => { + const code = this.normalizeCode(this.sessionCode); + await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {}); + await this.refreshSession(); + }); + } + + async mixAnswers(): Promise { + await this.runAction(async () => { + const code = this.normalizeCode(this.sessionCode); + const roundQuestionId = this.roundQuestionId.trim(); + await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {}); + await this.refreshSession(); + }); + } + + async calculateScores(): Promise { + await this.runAction(async () => { + const code = this.normalizeCode(this.sessionCode); + const roundQuestionId = this.roundQuestionId.trim(); + await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {}); + await this.refreshSession(); + }); + } + + async loadScoreboard(): Promise { + this.loading = true; + this.scoreboardError = ''; + this.error = ''; + try { + const code = this.normalizeCode(this.sessionCode); + const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET'); + this.scoreboardPayload = JSON.stringify(payload, null, 2); + await this.refreshSession(); + } catch (error) { + this.scoreboardError = `Scoreboard failed: ${(error as Error).message}`; + } finally { + this.loading = false; + } + } + + private async runAction(action: () => Promise): Promise { + this.loading = true; + this.error = ''; + try { + await action(); + } catch (error) { + this.error = (error as Error).message; + } finally { + this.loading = false; + } + } +} diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 6396173..f8087de 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -1,11 +1,173 @@ +import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +interface SessionDetail { + session: { code: string; status: string; current_round: number }; + round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; +} @Component({ selector: 'app-player-shell', standalone: true, + imports: [CommonModule, FormsModule], template: ` -

Player route placeholder

-

Player flow flyttes hertil i kommende SPA-issues.

+

Player SPA gameplay flow

+ +
+ + + + +
+ +
+

Status: {{ session.session.status }}

+

Prompt: {{ session.round_question.prompt }}

+ + + + + +
+ +
+ + + +
+ +

{{ error }}

+

{{ submitError.message }}

`, }) -export class PlayerShellComponent {} +export class PlayerShellComponent { + sessionCode = ''; + nickname = ''; + playerId = 0; + sessionToken = ''; + lieText = ''; + selectedGuess = ''; + loading = false; + error = ''; + submitError: { kind: 'lie' | 'guess'; message: string } | null = null; + session: SessionDetail | null = null; + + private normalizeCode(value: string): string { + return value.trim().toUpperCase(); + } + + private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { + const response = await fetch(path, { + method, + headers: { + Accept: 'application/json', + ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), + credentials: 'same-origin', + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`); + } + + return body as T; + } + + async refreshSession(): Promise { + this.loading = true; + this.error = ''; + try { + const code = this.normalizeCode(this.sessionCode); + this.session = await this.request(`/lobby/sessions/${encodeURIComponent(code)}`, 'GET'); + this.sessionCode = this.session.session.code; + if (this.session.session.status !== 'guess') { + this.selectedGuess = ''; + } + } catch (error) { + this.error = `Session refresh failed: ${(error as Error).message}`; + } finally { + this.loading = false; + } + } + + async joinSession(): Promise { + this.loading = true; + this.error = ''; + try { + const payload = await this.request<{ + player: { id: number; session_token: string }; + session: { code: string }; + }>('/lobby/sessions/join', 'POST', { + code: this.normalizeCode(this.sessionCode), + nickname: this.nickname.trim(), + }); + this.playerId = payload.player.id; + this.sessionToken = payload.player.session_token; + this.sessionCode = payload.session.code; + await this.refreshSession(); + } catch (error) { + this.error = `Join failed: ${(error as Error).message}`; + } finally { + this.loading = false; + } + } + + async submitLie(): Promise { + if (!this.session?.round_question?.id) { + return; + } + this.loading = true; + this.submitError = null; + try { + await this.request( + `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/lies/submit`, + 'POST', + { + player_id: this.playerId, + session_token: this.sessionToken, + text: this.lieText, + } + ); + await this.refreshSession(); + } catch (error) { + this.submitError = { kind: 'lie', message: `Lie submit failed: ${(error as Error).message}` }; + } finally { + this.loading = false; + } + } + + async submitGuess(): Promise { + if (!this.session?.round_question?.id || !this.selectedGuess) { + return; + } + this.loading = true; + this.submitError = null; + try { + await this.request( + `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/guesses/submit`, + 'POST', + { + player_id: this.playerId, + session_token: this.sessionToken, + selected_text: this.selectedGuess, + } + ); + await this.refreshSession(); + } catch (error) { + this.submitError = { kind: 'guess', message: `Guess submit failed: ${(error as Error).message}` }; + } finally { + this.loading = false; + } + } +}