[MVP] Angular-first host+player i18n integration without React (issue #222) #229
@@ -55,7 +55,7 @@ type LeaderboardResponse = FinishGameResponse;
|
|||||||
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
|
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
|
||||||
<div *ngIf="finalLeaderboard.length">
|
<div *ngIf="finalLeaderboard.length">
|
||||||
<h3>{{ copy('host.final_leaderboard') }}</h3>
|
<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>
|
<ol>
|
||||||
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
|
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -350,4 +350,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
component.ngOnDestroy();
|
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 reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private unsubscribeLocale: (() => void) | null = null;
|
private unsubscribeLocale: (() => void) | null = null;
|
||||||
|
private restoreAudioGuard: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||||
@@ -129,6 +130,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
|
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
|
||||||
this.locale = locale;
|
this.locale = locale;
|
||||||
});
|
});
|
||||||
|
this.installSecondaryDeviceAudioGuard();
|
||||||
|
|
||||||
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
||||||
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
||||||
@@ -158,6 +160,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
this.clearStateSyncTimer();
|
this.clearStateSyncTimer();
|
||||||
this.unsubscribeLocale?.();
|
this.unsubscribeLocale?.();
|
||||||
this.unsubscribeLocale = null;
|
this.unsubscribeLocale = null;
|
||||||
|
this.restoreAudioGuard?.();
|
||||||
|
this.restoreAudioGuard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly handleOnline = (): void => {
|
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 {
|
private scheduleStateSync(): void {
|
||||||
this.clearStateSyncTimer();
|
this.clearStateSyncTimer();
|
||||||
|
|
||||||
@@ -232,7 +255,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
return 'Unknown error';
|
return this.copy('common.unknown_error');
|
||||||
}
|
}
|
||||||
|
|
||||||
private markOnline(): void {
|
private markOnline(): void {
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ describe('lobby i18n locale propagation', () => {
|
|||||||
expect(updates).toEqual(['en', 'da']);
|
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 () => {
|
it('falls back to default en translation when da key is intentionally missing', async () => {
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
location: { search: '' },
|
location: { search: '' },
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ export function resolvePreferredLocale(): SupportedLocale {
|
|||||||
return activeLocale;
|
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 queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
|
||||||
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
|
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
|
||||||
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
|
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;
|
return activeLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="da">
|
<html lang="{{ shell_locale|default:'en' }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>WPP SPA Shell</title>
|
<title>WPP SPA Shell</title>
|
||||||
<link rel="stylesheet" href="{{ spa_asset_base }}/styles.css?v={{ spa_asset_version|urlencode }}">
|
<link rel="stylesheet" href="{{ spa_asset_base }}/styles.css?v={{ spa_asset_version|urlencode }}">
|
||||||
</head>
|
</head>
|
||||||
<body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">
|
<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 }}">Indlæser Angular app-shell…</app-root>
|
<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>
|
<script type="module" src="{{ spa_asset_base }}/main.js?v={{ spa_asset_version|urlencode }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.shortcuts import render
|
|||||||
from fupogfakta.models import Category
|
from fupogfakta.models import Category
|
||||||
|
|
||||||
from .feature_flags import use_spa_ui
|
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):
|
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_base": settings.WPP_SPA_ASSET_BASE,
|
||||||
"spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"),
|
"spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"),
|
||||||
"lobby_i18n": lobby_i18n_catalog(),
|
"lobby_i18n": lobby_i18n_catalog(),
|
||||||
|
"shell_locale": resolve_locale(request),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,14 @@
|
|||||||
"round": {
|
"round": {
|
||||||
"en": "round",
|
"en": "round",
|
||||||
"da": "runde"
|
"da": "runde"
|
||||||
|
},
|
||||||
|
"points_short": {
|
||||||
|
"en": "pts",
|
||||||
|
"da": "point"
|
||||||
|
},
|
||||||
|
"unknown_error": {
|
||||||
|
"en": "Unknown error",
|
||||||
|
"da": "Ukendt fejl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
Reference in New Issue
Block a user