Files
weirsoe-party-protocol/frontend/angular/src/app/features/host/host-shell.component.ts
DEV-bot 5bb035deec
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 10s
fix(lobby): tighten canonical host round flow for issue 287
2026-03-16 02:07:17 +00:00

291 lines
11 KiB
TypeScript

import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, SessionDetailResponse } from '../../../../../src/api/types';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
type SessionDetail = SessionDetailResponse;
type LeaderboardEntry = FinishGameResponse['leaderboard'][number];
type LeaderboardResponse = FinishGameResponse;
@Component({
selector: 'app-host-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<h2>{{ copy('host.title') }}</h2>
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button *ngIf="canStartRound" (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button *ngIf="canStartNextRound || nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy(nextRoundError ? 'host.retry_next_round' : 'host.start_next_round') }}</button>
<button *ngIf="canFinishGame || finishError" (click)="finishGame()" [disabled]="loading">{{ copy(finishError ? 'host.retry_finish' : 'host.finish_game') }}</button>
</div>
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
<p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="nextRoundError" class="error">{{ nextRoundError }}</p>
<p *ngIf="finishError" class="error">{{ finishError }}</p>
<div *ngIf="session" class="panel">
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }} · {{ copy('common.round') }} {{ session.session.current_round }}</p>
<p><strong>{{ copy('common.round_question_id') }}:</strong> {{ roundQuestionId || '-' }}</p>
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div>
<div *ngIf="finalLeaderboard.length">
<h3>{{ copy('host.final_leaderboard') }}</h3>
<p *ngIf="finalWinner"><strong>{{ copy('host.winner') }}:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})</p>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
</ol>
</div>
<pre *ngIf="finalLeaderboardPayload">{{ finalLeaderboardPayload }}</pre>
</div>
`,
})
export class HostShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale();
readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
sessionCode = '';
categorySlug = 'general';
roundQuestionId = '';
loading = false;
error = '';
nextRoundError = '';
finishError = '';
finalLeaderboardPayload = '';
finalLeaderboard: LeaderboardEntry[] = [];
finalWinner: LeaderboardEntry | null = null;
session: SessionDetail | null = null;
private readonly api = createApiClient();
private readonly controller = createVerticalSliceController(this.api);
private unsubscribeLocale: (() => void) | null = null;
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
if (typeof window === 'undefined') {
return;
}
const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? '';
const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? '';
const candidate = codeFromRoute || storedCode;
if (!candidate) {
return;
}
this.sessionCode = this.normalizeCode(candidate);
this.persistSessionCode(this.sessionCode);
void this.refreshSession();
}
ngOnDestroy(): void {
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
}
get canStartRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session);
}
get canStartNextRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_next_round);
}
get canFinishGame(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_finish_game);
}
copy(key: string): string {
return t(key, this.locale);
}
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
private persistSessionCode(code: string): void {
if (typeof window !== 'undefined') {
window.sessionStorage.setItem('wpp.host-session-code', code);
}
}
private async request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<T> {
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<void> {
this.loading = true;
this.error = '';
this.nextRoundError = '';
this.finishError = '';
try {
const state = await this.controller.hydrateLobby(this.sessionCode);
if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
}
this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code;
this.persistSessionCode(this.sessionCode);
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
if (this.session.session.status !== 'finished') {
this.resetFinalLeaderboard();
}
this.syncRouteFromSession();
} catch (error) {
this.error = `${this.copy('host.session_refresh_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async startRound(): Promise<void> {
await this.runAction(async () => {
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
}
this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code;
this.persistSessionCode(this.sessionCode);
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
this.resetFinalLeaderboard();
this.syncRouteFromSession();
});
}
async startNextRound(): Promise<void> {
this.loading = true;
this.nextRoundError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
if (!code) {
throw new Error(this.copy('host.session_code_required'));
}
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
this.resetFinalLeaderboard();
await this.refreshSession();
} catch (error) {
this.nextRoundError = `${this.copy('host.next_round_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async finishGame(): Promise<void> {
this.loading = true;
this.finishError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
if (!code) {
throw new Error(this.copy('host.session_code_required'));
}
const payload = await this.request<LeaderboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {});
this.finalLeaderboardPayload = JSON.stringify(payload, null, 2);
this.finalLeaderboard = [...payload.leaderboard].sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.nickname.localeCompare(b.nickname);
});
this.finalWinner = payload.winner ?? this.finalLeaderboard[0] ?? null;
await this.refreshSession();
} catch (error) {
this.finishError = `${this.copy('host.finish_game_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
private resetFinalLeaderboard(): void {
this.finalLeaderboardPayload = '';
this.finalLeaderboard = [];
this.finalWinner = null;
}
private syncRouteFromSession(): void {
if (!this.session) {
return;
}
const phase = this.session.session.status || 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) {
return;
}
const targetPath = `#/host/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`;
if (typeof window === 'undefined' || window.location.hash === targetPath) {
return;
}
window.history.replaceState(window.history.state, '', targetPath);
}
private async runAction(action: () => Promise<void>): Promise<void> {
this.loading = true;
this.error = '';
try {
await action();
} catch (error) {
this.error = (error as Error).message;
} finally {
this.loading = false;
}
}
}