360 lines
14 KiB
TypeScript
360 lines
14 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, ScoreboardResponse, 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 = ScoreboardResponse['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="canShowQuestion" (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
|
|
<button *ngIf="canMixAnswers" (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
|
|
<button *ngIf="canCalculateScores" (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
|
|
<button *ngIf="canRevealScoreboard || scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy(scoreboardError ? 'host.retry_scoreboard' : 'host.load_scoreboard') }}</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="scoreboardError" class="error">{{ scoreboardError }}</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>
|
|
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
|
|
<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 = '';
|
|
scoreboardError = '';
|
|
nextRoundError = '';
|
|
finishError = '';
|
|
scoreboardPayload = '';
|
|
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 canShowQuestion(): boolean {
|
|
return Boolean(this.session?.phase_view_model?.host?.can_show_question);
|
|
}
|
|
|
|
get canMixAnswers(): boolean {
|
|
return Boolean(this.session?.phase_view_model?.host?.can_mix_answers);
|
|
}
|
|
|
|
get canCalculateScores(): boolean {
|
|
return Boolean(this.session?.phase_view_model?.host?.can_calculate_scores);
|
|
}
|
|
|
|
get canRevealScoreboard(): boolean {
|
|
return Boolean(this.session?.phase_view_model?.host?.can_reveal_scoreboard);
|
|
}
|
|
|
|
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.scoreboardError = '';
|
|
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.scoreboardPayload = '';
|
|
this.resetFinalLeaderboard();
|
|
this.syncRouteFromSession();
|
|
});
|
|
}
|
|
|
|
async showQuestion(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
this.loading = true;
|
|
this.scoreboardError = '';
|
|
this.error = '';
|
|
try {
|
|
const code = this.normalizeCode(this.sessionCode);
|
|
const payload = await this.request<unknown>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
|
|
this.scoreboardPayload = JSON.stringify(payload, null, 2);
|
|
await this.refreshSession();
|
|
} catch (error) {
|
|
this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
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.scoreboardPayload = '';
|
|
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;
|
|
}
|
|
}
|
|
}
|