Merge pull request '[MVP] Angular-first host+player i18n integration without React (issue #222)' (#229) from feat/issue-222-angular-first-host-player-i18n into main
All checks were successful
CI / test-and-quality (push) Successful in 2m37s
All checks were successful
CI / test-and-quality (push) Successful in 2m37s
This commit was merged in pull request #229.
This commit is contained in:
@@ -55,7 +55,7 @@ type LeaderboardResponse = FinishGameResponse;
|
||||
<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 }} pts)</p>
|
||||
<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>
|
||||
|
||||
@@ -350,4 +350,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
|
||||
component.ngOnDestroy();
|
||||
});
|
||||
|
||||
it('installs secondary-device audio guard while player shell is mounted', async () => {
|
||||
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
|
||||
const mediaPrototype = { play: originalPlay };
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
location: { hash: '', search: '' },
|
||||
history: { state: null, replaceState: vi.fn() },
|
||||
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
HTMLMediaElement: { prototype: mediaPrototype },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
|
||||
|
||||
const component = new PlayerShellComponent();
|
||||
component.ngOnInit();
|
||||
|
||||
await expect(mediaPrototype.play()).resolves.toBeUndefined();
|
||||
|
||||
component.ngOnDestroy();
|
||||
|
||||
await expect(mediaPrototype.play()).rejects.toThrow('original play');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private unsubscribeLocale: (() => void) | null = null;
|
||||
private restoreAudioGuard: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
@@ -129,6 +130,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
|
||||
this.locale = locale;
|
||||
});
|
||||
this.installSecondaryDeviceAudioGuard();
|
||||
|
||||
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
||||
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
||||
@@ -158,6 +160,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
this.clearStateSyncTimer();
|
||||
this.unsubscribeLocale?.();
|
||||
this.unsubscribeLocale = null;
|
||||
this.restoreAudioGuard?.();
|
||||
this.restoreAudioGuard = null;
|
||||
}
|
||||
|
||||
private readonly handleOnline = (): void => {
|
||||
@@ -185,6 +189,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private installSecondaryDeviceAudioGuard(): void {
|
||||
if (!this.clientHasNoAudioOutput || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: { play?: () => Promise<void> } } }).HTMLMediaElement
|
||||
?.prototype;
|
||||
|
||||
if (!mediaPrototype || typeof mediaPrototype.play !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalPlay = mediaPrototype.play;
|
||||
mediaPrototype.play = () => Promise.resolve();
|
||||
this.restoreAudioGuard = () => {
|
||||
mediaPrototype.play = originalPlay;
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleStateSync(): void {
|
||||
this.clearStateSyncTimer();
|
||||
|
||||
@@ -232,7 +255,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Unknown error';
|
||||
return this.copy('common.unknown_error');
|
||||
}
|
||||
|
||||
private markOnline(): void {
|
||||
|
||||
@@ -45,6 +45,22 @@ describe('lobby i18n locale propagation', () => {
|
||||
expect(updates).toEqual(['en', 'da']);
|
||||
});
|
||||
|
||||
it('prefers backend-provided shell locale over browser defaults', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: { search: '' },
|
||||
localStorage: storageMock(),
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
body: { dataset: { wppLocale: 'da' } },
|
||||
querySelector: vi.fn(() => null),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US' });
|
||||
|
||||
const i18n = await import('./lobby-i18n');
|
||||
|
||||
expect(i18n.resolvePreferredLocale()).toBe('da');
|
||||
});
|
||||
|
||||
it('falls back to default en translation when da key is intentionally missing', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: { search: '' },
|
||||
|
||||
@@ -32,11 +32,14 @@ export function resolvePreferredLocale(): SupportedLocale {
|
||||
return activeLocale;
|
||||
}
|
||||
|
||||
const rootLocale =
|
||||
typeof document !== 'undefined' ? document.querySelector<HTMLElement>('app-root')?.dataset?.['wppLocale'] : null;
|
||||
const shellLocale = typeof document !== 'undefined' ? document.body?.dataset?.['wppLocale'] : null;
|
||||
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
|
||||
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
|
||||
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
|
||||
|
||||
activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
|
||||
activeLocale = normalizeLocale(rootLocale || shellLocale || queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
|
||||
return activeLocale;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="da">
|
||||
<html lang="{{ shell_locale|default:'en' }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WPP SPA Shell</title>
|
||||
<link rel="stylesheet" href="{{ spa_asset_base }}/styles.css?v={{ spa_asset_version|urlencode }}">
|
||||
</head>
|
||||
<body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">
|
||||
<app-root data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">Indlæser Angular app-shell…</app-root>
|
||||
<body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}" data-wpp-locale="{{ shell_locale|default:'en' }}">
|
||||
<app-root data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}" data-wpp-locale="{{ shell_locale|default:'en' }}">Indlæser Angular app-shell…</app-root>
|
||||
<script type="module" src="{{ spa_asset_base }}/main.js?v={{ spa_asset_version|urlencode }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.shortcuts import render
|
||||
from fupogfakta.models import Category
|
||||
|
||||
from .feature_flags import use_spa_ui
|
||||
from .i18n import lobby_i18n_catalog
|
||||
from .i18n import lobby_i18n_catalog, resolve_locale
|
||||
|
||||
|
||||
def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
||||
@@ -18,6 +18,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
||||
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
|
||||
"spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"),
|
||||
"lobby_i18n": lobby_i18n_catalog(),
|
||||
"shell_locale": resolve_locale(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
"round": {
|
||||
"en": "round",
|
||||
"da": "runde"
|
||||
},
|
||||
"points_short": {
|
||||
"en": "pts",
|
||||
"da": "point"
|
||||
},
|
||||
"unknown_error": {
|
||||
"en": "Unknown error",
|
||||
"da": "Ukendt fejl"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
|
||||
Reference in New Issue
Block a user