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 }}
+
+ - {{ p.nickname }}: {{ p.score }}
+
+
{{ 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;
+ }
+ }
+}