generated from Labyricorn/labyricorn-project-template
Implement CyberSim Phase 2.5: Localization, Branding, and Immersive Login
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* CyberSim OS - Immersive Workstation Login & Network Selection Screen (Phase 2.5)
|
||||
*
|
||||
* Immersive fictional workstation lockscreen, runtime language switching,
|
||||
* learner first-name validation, and dynamic workplace network selection.
|
||||
*/
|
||||
|
||||
import { i18n } from './i18n.js';
|
||||
import { branding } from './branding.js';
|
||||
|
||||
export class LoginScreen {
|
||||
constructor({ containerElement, onConnect }) {
|
||||
this.container = containerElement;
|
||||
this.onConnect = onConnect;
|
||||
this.selectedScenarioId = null;
|
||||
this.scenarios = [];
|
||||
this.unsubscribeI18n = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to ensure learner input is never rendered as executable markup.
|
||||
*/
|
||||
static escapeHtml(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and render the login screen.
|
||||
*/
|
||||
async render() {
|
||||
this.scenarios = branding.getScenariosCatalog();
|
||||
if (this.scenarios.length > 0 && !this.selectedScenarioId) {
|
||||
this.selectedScenarioId = this.scenarios[0].id;
|
||||
}
|
||||
|
||||
this._drawUI();
|
||||
|
||||
// Listen to language changes to re-render login UI dynamically
|
||||
if (this.unsubscribeI18n) this.unsubscribeI18n();
|
||||
this.unsubscribeI18n = i18n.onChange(() => {
|
||||
this._drawUI();
|
||||
});
|
||||
}
|
||||
|
||||
_drawUI() {
|
||||
if (!this.container) return;
|
||||
|
||||
const org = branding.getOrganization();
|
||||
const enabledLocales = i18n.getEnabledLocales();
|
||||
const currentLocale = i18n.getLocale();
|
||||
|
||||
// Preserve entered first name if switching language
|
||||
const existingInput = this.container.querySelector('#login-firstname-input');
|
||||
const existingName = existingInput ? existingInput.value : '';
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="cs-login-backdrop">
|
||||
<div class="cs-login-card">
|
||||
<!-- Corporate Header & Branding -->
|
||||
<div class="cs-login-header">
|
||||
<div class="cs-login-org-brand">
|
||||
${org.logo ? `<img src="${encodeURI(org.logo)}" alt="Logo" class="cs-login-logo">` : `
|
||||
<div class="cs-login-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</div>
|
||||
`}
|
||||
<div>
|
||||
<h1 class="cs-login-org-name">${LoginScreen.escapeHtml(org.name)}</h1>
|
||||
<div class="cs-login-sub">${i18n.t('login.subtitle')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Language Selector -->
|
||||
<div class="cs-login-lang-select">
|
||||
<label for="login-lang-dropdown" style="font-size:11px; color:#94a3b8; margin-right:6px;">${i18n.t('login.language')}:</label>
|
||||
<select id="login-lang-dropdown" class="cs-select cs-select-sm">
|
||||
${enabledLocales.map(loc => `
|
||||
<option value="${loc}" ${loc === currentLocale ? 'selected' : ''}>
|
||||
${i18n.getLocaleDisplayName(loc)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Welcome & Instructions -->
|
||||
<div class="cs-login-body">
|
||||
<div class="cs-login-welcome">
|
||||
<h2>${i18n.t('login.welcome')}</h2>
|
||||
<p>${i18n.t('login.instructions')}</p>
|
||||
</div>
|
||||
|
||||
<!-- Learner First Name Input -->
|
||||
<div class="cs-form-group" style="margin-bottom:18px;">
|
||||
<label class="cs-form-label" for="login-firstname-input">
|
||||
${i18n.t('login.firstName')} <span style="color:#ef4444;">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="login-firstname-input"
|
||||
class="cs-input"
|
||||
placeholder="${i18n.t('login.firstNamePlaceholder')}"
|
||||
maxlength="50"
|
||||
value="${LoginScreen.escapeHtml(existingName)}"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<div id="login-error-msg" class="cs-login-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Network / Workplace Selection -->
|
||||
<div class="cs-form-group">
|
||||
<label class="cs-form-label">
|
||||
${i18n.t('login.availableNetworks')}
|
||||
</label>
|
||||
<div class="cs-network-list" id="cs-network-list">
|
||||
${this.scenarios.map(sc => {
|
||||
const isSelected = sc.id === this.selectedScenarioId;
|
||||
const isSupported = !sc.supportedLocales || sc.supportedLocales.includes(currentLocale);
|
||||
const iconSvg = this._getNetworkIconSvg(sc.networkIcon);
|
||||
|
||||
return `
|
||||
<div class="cs-network-card ${isSelected ? 'selected' : ''} ${!isSupported ? 'unsupported' : ''}" data-scenario-id="${sc.id}" data-supported="${isSupported}">
|
||||
<div class="cs-network-icon">${iconSvg}</div>
|
||||
<div class="cs-network-info">
|
||||
<div class="cs-network-name">${LoginScreen.escapeHtml(sc.networkName || sc.id)}</div>
|
||||
<div class="cs-network-desc">${LoginScreen.escapeHtml(sc.networkDescription || '')}</div>
|
||||
${!isSupported ? `<div class="cs-network-badge-warn">${i18n.t('login.networkUnavailable')}</div>` : ''}
|
||||
</div>
|
||||
<div class="cs-network-radio">
|
||||
<input type="radio" name="network-choice" value="${sc.id}" ${isSelected ? 'checked' : ''} ${!isSupported ? 'disabled' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connect Action -->
|
||||
<div style="margin-top:24px;">
|
||||
<button id="btn-login-connect" class="cs-btn cs-btn-primary cs-btn-lg" style="width:100%;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
||||
<polyline points="10 17 15 12 10 7"></polyline>
|
||||
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||
</svg>
|
||||
${i18n.t('login.connect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Simulation Safety Disclaimer -->
|
||||
<div class="cs-login-footer">
|
||||
<div class="cs-login-disclaimer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#eab308" stroke-width="2" style="flex-shrink:0;">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
<span>${i18n.t('login.disclaimer')}</span>
|
||||
</div>
|
||||
<div style="margin-top:4px; font-size:10px; color:#64748b;">
|
||||
${i18n.t('login.sessionInfo')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_getNetworkIconSvg(type) {
|
||||
switch (type) {
|
||||
case 'health':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`;
|
||||
case 'corporate':
|
||||
default:
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// Language dropdown change
|
||||
const langSelect = this.container.querySelector('#login-lang-dropdown');
|
||||
if (langSelect) {
|
||||
langSelect.addEventListener('change', async (e) => {
|
||||
const newLocale = e.target.value;
|
||||
await i18n.setLocale(newLocale);
|
||||
});
|
||||
}
|
||||
|
||||
// Network card selection
|
||||
const cards = this.container.querySelectorAll('.cs-network-card');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
if (card.dataset.supported === 'false') return;
|
||||
cards.forEach(c => {
|
||||
c.classList.remove('selected');
|
||||
const radio = c.querySelector('input[type="radio"]');
|
||||
if (radio) radio.checked = false;
|
||||
});
|
||||
card.classList.add('selected');
|
||||
const radio = card.querySelector('input[type="radio"]');
|
||||
if (radio) radio.checked = true;
|
||||
this.selectedScenarioId = card.dataset.scenarioId;
|
||||
});
|
||||
});
|
||||
|
||||
// First name input on Enter
|
||||
const nameInput = this.container.querySelector('#login-firstname-input');
|
||||
if (nameInput) {
|
||||
nameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this._handleConnect();
|
||||
}
|
||||
});
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
// Connect button click
|
||||
const connectBtn = this.container.querySelector('#btn-login-connect');
|
||||
if (connectBtn) {
|
||||
connectBtn.addEventListener('click', () => this._handleConnect());
|
||||
}
|
||||
}
|
||||
|
||||
_showError(msg) {
|
||||
const errorEl = this.container.querySelector('#login-error-msg');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
_clearError() {
|
||||
const errorEl = this.container.querySelector('#login-error-msg');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = '';
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
_handleConnect() {
|
||||
this._clearError();
|
||||
const nameInput = this.container.querySelector('#login-firstname-input');
|
||||
const rawName = nameInput ? nameInput.value : '';
|
||||
const firstName = rawName.trim();
|
||||
|
||||
// 1. Validate First Name
|
||||
if (!firstName) {
|
||||
this._showError(i18n.t('login.firstNameRequired'));
|
||||
if (nameInput) nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstName.length > 50) {
|
||||
this._showError(i18n.t('login.firstNameTooLong'));
|
||||
if (nameInput) nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Validate Selected Network
|
||||
const selectedScenario = this.scenarios.find(s => s.id === this.selectedScenarioId);
|
||||
if (!selectedScenario) {
|
||||
this._showError(i18n.t('login.selectNetwork'));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLocale = i18n.getLocale();
|
||||
if (selectedScenario.supportedLocales && !selectedScenario.supportedLocales.includes(currentLocale)) {
|
||||
this._showError(i18n.t('login.networkUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up listener
|
||||
if (this.unsubscribeI18n) {
|
||||
this.unsubscribeI18n();
|
||||
this.unsubscribeI18n = null;
|
||||
}
|
||||
|
||||
// Connect callback
|
||||
if (this.onConnect) {
|
||||
this.onConnect({
|
||||
firstName,
|
||||
scenarioId: selectedScenario.id,
|
||||
scenarioPath: selectedScenario.path,
|
||||
locale: currentLocale
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user