feat(i18n): share lobby message catalog across frontend/backend
This commit is contained in:
31
frontend/src/spa/lobby-i18n.ts
Normal file
31
frontend/src/spa/lobby-i18n.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import lobbyCatalog from '../../../shared/i18n/lobby.json';
|
||||||
|
|
||||||
|
type FrontendErrorKey = keyof typeof lobbyCatalog.frontend.errors;
|
||||||
|
|
||||||
|
const frontendErrors = lobbyCatalog.frontend.errors;
|
||||||
|
|
||||||
|
const apiErrorMap: Record<string, FrontendErrorKey> = {
|
||||||
|
session_code_required: 'session_code_required',
|
||||||
|
session_not_found: 'session_not_found',
|
||||||
|
nickname_invalid: 'nickname_invalid',
|
||||||
|
nickname_taken: 'nickname_taken'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function lobbyMessage(key: FrontendErrorKey): string {
|
||||||
|
return frontendErrors[key] ?? frontendErrors.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey): string {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return lobbyMessage(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const code = typeof record.error_code === 'string' ? record.error_code : '';
|
||||||
|
const mappedKey = apiErrorMap[code];
|
||||||
|
if (!mappedKey) {
|
||||||
|
return lobbyMessage(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lobbyMessage(mappedKey);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type SessionContextStore as PersistedSessionContextStore
|
type SessionContextStore as PersistedSessionContextStore
|
||||||
} from './session-context-store';
|
} from './session-context-store';
|
||||||
import { deriveGameplayPhase, type GameplayPhase } from './gameplay-phase-machine';
|
import { deriveGameplayPhase, type GameplayPhase } from './gameplay-phase-machine';
|
||||||
|
import { lobbyMessage, lobbyMessageFromApiPayload } from './lobby-i18n';
|
||||||
|
|
||||||
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
|
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ export function createVerticalSliceController(
|
|||||||
|
|
||||||
if (!state.sessionCode) {
|
if (!state.sessionCode) {
|
||||||
state.loadingSession = false;
|
state.loadingSession = false;
|
||||||
state.errorMessage = 'Session-kode mangler.';
|
state.errorMessage = lobbyMessage('session_code_required');
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export function createVerticalSliceController(
|
|||||||
state.loadingSession = false;
|
state.loadingSession = false;
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
state.errorMessage = 'Kunne ikke hente lobby-status.';
|
state.errorMessage = lobbyMessageFromApiPayload(result.error.payload, 'session_fetch_failed');
|
||||||
state.gameplayPhase = null;
|
state.gameplayPhase = null;
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
@@ -92,7 +93,7 @@ export function createVerticalSliceController(
|
|||||||
const join = await api.joinSession({ code: requestCode, nickname });
|
const join = await api.joinSession({ code: requestCode, nickname });
|
||||||
if (!join.ok) {
|
if (!join.ok) {
|
||||||
state.joinState = 'error';
|
state.joinState = 'error';
|
||||||
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
|
state.errorMessage = lobbyMessageFromApiPayload(join.error.payload, 'join_failed');
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,14 +120,14 @@ export function createVerticalSliceController(
|
|||||||
|
|
||||||
if (!codeToUse) {
|
if (!codeToUse) {
|
||||||
state.startRoundState = 'error';
|
state.startRoundState = 'error';
|
||||||
state.errorMessage = 'Session-kode mangler.';
|
state.errorMessage = lobbyMessage('session_code_required');
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = await api.startRound(codeToUse, { category_slug: categorySlug });
|
const start = await api.startRound(codeToUse, { category_slug: categorySlug });
|
||||||
if (!start.ok) {
|
if (!start.ok) {
|
||||||
state.startRoundState = 'error';
|
state.startRoundState = 'error';
|
||||||
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
|
state.errorMessage = lobbyMessageFromApiPayload(start.error.payload, 'start_round_failed');
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
joinSession: vi.fn().mockResolvedValue({
|
joinSession: vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } }
|
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.joinState).toBe('error');
|
expect(state.joinState).toBe('error');
|
||||||
expect(state.errorMessage).toContain('Join fejlede');
|
expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces a friendly error when round start fails', async () => {
|
it('surfaces a friendly error when round start fails', async () => {
|
||||||
@@ -178,7 +178,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
startRound: vi.fn().mockResolvedValue({
|
startRound: vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } }
|
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby', error_code: 'round_start_invalid_phase' } }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.startRoundState).toBe('error');
|
expect(state.startRoundState).toBe('error');
|
||||||
expect(state.errorMessage).toContain('Kunne ikke starte runden');
|
expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows local validation error and avoids API call when hydrating without any session code', async () => {
|
it('shows local validation error and avoids API call when hydrating without any session code', async () => {
|
||||||
@@ -197,7 +197,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
await controller.hydrateLobby(' ');
|
await controller.hydrateLobby(' ');
|
||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.errorMessage).toBe('Session-kode mangler.');
|
expect(state.errorMessage).toBe('Session code is required.');
|
||||||
expect(state.loadingSession).toBe(false);
|
expect(state.loadingSession).toBe(false);
|
||||||
expect(api.getSession).not.toHaveBeenCalled();
|
expect(api.getSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -210,7 +210,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.startRoundState).toBe('error');
|
expect(state.startRoundState).toBe('error');
|
||||||
expect(state.errorMessage).toBe('Session-kode mangler.');
|
expect(state.errorMessage).toBe('Session code is required.');
|
||||||
expect(api.startRound).not.toHaveBeenCalled();
|
expect(api.startRound).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "ES2022",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": ["vitest/globals", "node"]
|
"types": ["vitest/globals", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "tests"]
|
"include": ["src", "tests", "../shared/i18n/*.json"]
|
||||||
}
|
}
|
||||||
|
|||||||
20
lobby/i18n.py
Normal file
20
lobby/i18n.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def lobby_i18n_catalog() -> dict:
|
||||||
|
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json"
|
||||||
|
with catalog_path.open(encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def lobby_i18n_errors() -> dict:
|
||||||
|
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {})
|
||||||
|
|
||||||
|
|
||||||
|
def api_error(*, code: str, message: str, status: int) -> JsonResponse:
|
||||||
|
return JsonResponse({"error": message, "error_code": code}, status=status)
|
||||||
@@ -5,6 +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
|
||||||
|
|
||||||
|
|
||||||
def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
||||||
@@ -15,6 +16,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
|||||||
"shell_route": shell_route,
|
"shell_route": shell_route,
|
||||||
"shell_kind": shell_kind,
|
"shell_kind": shell_kind,
|
||||||
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
|
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
|
||||||
|
"lobby_i18n": lobby_i18n_catalog(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,11 +32,18 @@ def host_screen(request, spa_path=None):
|
|||||||
return _render_spa_shell(request, host_route, "host")
|
return _render_spa_shell(request, host_route, "host")
|
||||||
|
|
||||||
categories = Category.objects.filter(is_active=True).order_by("name")
|
categories = Category.objects.filter(is_active=True).order_by("name")
|
||||||
return render(request, "lobby/host_screen.html", {"categories": categories})
|
return render(
|
||||||
|
request,
|
||||||
|
"lobby/host_screen.html",
|
||||||
|
{
|
||||||
|
"categories": categories,
|
||||||
|
"lobby_i18n": lobby_i18n_catalog(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def player_screen(request):
|
def player_screen(request):
|
||||||
if use_spa_ui():
|
if use_spa_ui():
|
||||||
return _render_spa_shell(request, "/player", "player")
|
return _render_spa_shell(request, "/player", "player")
|
||||||
|
|
||||||
return render(request, "lobby/player_screen.html")
|
return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()})
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from fupogfakta.models import (
|
|||||||
ScoreEvent,
|
ScoreEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .i18n import api_error, lobby_i18n_errors
|
||||||
|
|
||||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
SESSION_CODE_LENGTH = 6
|
SESSION_CODE_LENGTH = 6
|
||||||
MAX_CODE_GENERATION_ATTEMPTS = 20
|
MAX_CODE_GENERATION_ATTEMPTS = 20
|
||||||
@@ -29,6 +31,7 @@ JOINABLE_STATUSES = {
|
|||||||
GameSession.Status.GUESS,
|
GameSession.Status.GUESS,
|
||||||
GameSession.Status.REVEAL,
|
GameSession.Status.REVEAL,
|
||||||
}
|
}
|
||||||
|
ERROR_CODES = lobby_i18n_errors()
|
||||||
|
|
||||||
|
|
||||||
def _json_body(request: HttpRequest) -> dict:
|
def _json_body(request: HttpRequest) -> dict:
|
||||||
@@ -124,21 +127,41 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
|||||||
nickname = str(payload.get("nickname", "")).strip()
|
nickname = str(payload.get("nickname", "")).strip()
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return JsonResponse({"error": "Session code is required"}, status=400)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("session_code_required", "session_code_required"),
|
||||||
|
message="Session code is required",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
if len(nickname) < 2 or len(nickname) > 40:
|
if len(nickname) < 2 or len(nickname) > 40:
|
||||||
return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
|
||||||
|
message="Nickname must be between 2 and 40 characters",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = GameSession.objects.get(code=code)
|
session = GameSession.objects.get(code=code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return JsonResponse({"error": "Session not found"}, status=404)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||||
|
message="Session not found",
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
if session.status not in JOINABLE_STATUSES:
|
if session.status not in JOINABLE_STATUSES:
|
||||||
return JsonResponse({"error": "Session is not joinable"}, status=400)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
|
||||||
|
message="Session is not joinable",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
||||||
return JsonResponse({"error": "Nickname already taken"}, status=409)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("nickname_taken", "nickname_taken"),
|
||||||
|
message="Nickname already taken",
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
|
||||||
player = Player.objects.create(session=session, nickname=nickname)
|
player = Player.objects.create(session=session, nickname=nickname)
|
||||||
|
|
||||||
@@ -166,7 +189,11 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
try:
|
try:
|
||||||
session = GameSession.objects.get(code=session_code)
|
session = GameSession.objects.get(code=session_code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return JsonResponse({"error": "Session not found"}, status=404)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||||
|
message="Session not found",
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
players = list(
|
players = list(
|
||||||
session.players.order_by("nickname").values(
|
session.players.order_by("nickname").values(
|
||||||
@@ -223,25 +250,41 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
category_slug = str(payload.get("category_slug", "")).strip()
|
category_slug = str(payload.get("category_slug", "")).strip()
|
||||||
|
|
||||||
if not category_slug:
|
if not category_slug:
|
||||||
return JsonResponse({"error": "category_slug is required"}, status=400)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("category_slug_required", "category_slug_required"),
|
||||||
|
message="category_slug is required",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
session_code = _normalize_session_code(code)
|
session_code = _normalize_session_code(code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = GameSession.objects.get(code=session_code)
|
session = GameSession.objects.get(code=session_code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return JsonResponse({"error": "Session not found"}, status=404)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||||
|
message="Session not found",
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
if session.host_id != request.user.id:
|
if session.host_id != request.user.id:
|
||||||
return JsonResponse({"error": "Only host can start round"}, status=403)
|
return JsonResponse({"error": "Only host can start round"}, status=403)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LOBBY:
|
if session.status != GameSession.Status.LOBBY:
|
||||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
||||||
|
message="Round can only be started from lobby",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
category = Category.objects.get(slug=category_slug, is_active=True)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
return JsonResponse({"error": "Category not found"}, status=404)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("category_not_found", "category_not_found"),
|
||||||
|
message="Category not found",
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
if not Question.objects.filter(category=category, is_active=True).exists():
|
if not Question.objects.filter(category=category, is_active=True).exists():
|
||||||
return JsonResponse({"error": "Category has no active questions"}, status=400)
|
return JsonResponse({"error": "Category has no active questions"}, status=400)
|
||||||
@@ -249,7 +292,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
if session.status != GameSession.Status.LOBBY:
|
if session.status != GameSession.Status.LOBBY:
|
||||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
||||||
|
message="Round can only be started from lobby",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
round_config, created = RoundConfig.objects.get_or_create(
|
round_config, created = RoundConfig.objects.get_or_create(
|
||||||
session=session,
|
session=session,
|
||||||
@@ -257,7 +304,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
defaults={"category": category},
|
defaults={"category": category},
|
||||||
)
|
)
|
||||||
if not created:
|
if not created:
|
||||||
return JsonResponse({"error": "Round already configured"}, status=409)
|
return api_error(
|
||||||
|
code=ERROR_CODES.get("round_already_configured", "round_already_configured"),
|
||||||
|
message="Round already configured",
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
|
||||||
session.status = GameSession.Status.LIE
|
session.status = GameSession.Status.LIE
|
||||||
session.save(update_fields=["status"])
|
session.save(update_fields=["status"])
|
||||||
|
|||||||
27
shared/i18n/lobby.json
Normal file
27
shared/i18n/lobby.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"frontend": {
|
||||||
|
"errors": {
|
||||||
|
"session_code_required": "Session code is required.",
|
||||||
|
"session_fetch_failed": "Could not load lobby status.",
|
||||||
|
"join_failed": "Join failed. Check code or nickname and try again.",
|
||||||
|
"start_round_failed": "Could not start round. Refresh the lobby and try again.",
|
||||||
|
"session_not_found": "Session code is invalid or the session no longer exists.",
|
||||||
|
"nickname_invalid": "Nickname must be between 2 and 40 characters.",
|
||||||
|
"nickname_taken": "Nickname is already taken.",
|
||||||
|
"unknown": "Action failed. Refresh status and try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"error_codes": {
|
||||||
|
"session_code_required": "session_code_required",
|
||||||
|
"nickname_invalid": "nickname_invalid",
|
||||||
|
"session_not_found": "session_not_found",
|
||||||
|
"session_not_joinable": "session_not_joinable",
|
||||||
|
"nickname_taken": "nickname_taken",
|
||||||
|
"category_slug_required": "category_slug_required",
|
||||||
|
"category_not_found": "category_not_found",
|
||||||
|
"round_start_invalid_phase": "round_start_invalid_phase",
|
||||||
|
"round_already_configured": "round_already_configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user