[grade=A] deploy: rebuild dist with 31-language i18n + disk safety wizard + health settings
This commit is contained in:
+19
-1
@@ -1,10 +1,28 @@
|
||||
// ===== DASHBOARD CONSTANTS =====
|
||||
// Honor persisted health retention settings for polling cadences so the
|
||||
// Settings → Health → Global Settings panel actually takes effect. The
|
||||
// STATS interval (resource/container stat sampling) is driven from the
|
||||
// user-configurable statsPollingInterval. HEALTH is the lightweight card
|
||||
// badge refresh and stays at its fast default unless overridden.
|
||||
(function applyHealthPollingSettings() {
|
||||
try {
|
||||
var raw = (typeof localStorage !== 'undefined' && localStorage.getItem('dashcaddy-health-settings')) || null;
|
||||
if (raw) {
|
||||
var s = JSON.parse(raw);
|
||||
// values are stored in seconds; DC.POLL expects milliseconds
|
||||
if (s.statsPollingInterval && s.statsPollingInterval >= 5 && s.statsPollingInterval <= 3600) {
|
||||
window.__DC_STATS_OVERRIDE = s.statsPollingInterval * 1000;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore — fall back to defaults below */ }
|
||||
})();
|
||||
|
||||
const DC = {
|
||||
NAME: 'DashCaddy',
|
||||
POLL: {
|
||||
DASHBOARD: 10000, // 10s — main refreshAll interval
|
||||
LOGS: 3000, // 3s — log viewer updates
|
||||
STATS: 5000, // 5s — resource monitor refresh
|
||||
STATS: (typeof window !== 'undefined' && window.__DC_STATS_OVERRIDE) || 5000, // 5s default — resource monitor refresh (overridable via Settings → Health)
|
||||
WEATHER: 600000, // 10m — weather widget refresh
|
||||
HEALTH: 1000, // 1s — card health badge refresh
|
||||
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
||||
|
||||
@@ -34,6 +34,44 @@
|
||||
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Settings: retention, polling intervals, max entries, disk-usage threshold -->
|
||||
<div id="health-global-settings" style="margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 style="margin: 0 0 4px;">🌍 Global Settings</h4>
|
||||
<p class="text-muted-sm" style="margin: 0 0 12px;">Applies to all health checks. Settings are stored locally in this browser.</p>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||
<div>
|
||||
<label class="text-muted-sm">Health Check Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-interval" value="60" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service's health endpoint is checked.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Stats Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-stats-interval" value="30" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often container statistics (CPU/memory) are sampled.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Max Entries Per Service</label>
|
||||
<input type="number" id="health-setting-max-entries" value="500" min="10" max="100000" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Cap on stored history records per service.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Data Retention (days)</label>
|
||||
<input type="number" id="health-setting-retention" value="30" min="1" max="3650" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Health history older than this is pruned.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Disk-Usage Warning (%)</label>
|
||||
<input type="number" id="health-setting-disk-threshold" value="80" min="50" max="99" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Warn when disk usage exceeds this level.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px; align-items: center;">
|
||||
<button id="health-global-save" class="btn-accent-solid">Save Global Settings</button>
|
||||
<button id="health-global-reset" class="btn-sm">Reset to Defaults</button>
|
||||
<span id="health-global-status" style="font-size: 0.8rem; color: var(--muted);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Health Check Form -->
|
||||
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
||||
@@ -103,6 +141,77 @@
|
||||
const formCancel = document.getElementById('health-form-cancel');
|
||||
const formSave = document.getElementById('health-form-save');
|
||||
|
||||
// ---- Global health settings (retention, polling intervals, max entries, disk threshold) ----
|
||||
const HEALTH_SETTINGS_KEY = 'dashcaddy-health-settings';
|
||||
const HEALTH_DEFAULTS = { retentionDays: 30, pollingInterval: 60, statsPollingInterval: 30, maxEntriesPerService: 500, diskUsageThreshold: 80 };
|
||||
const globalSaveBtn = document.getElementById('health-global-save');
|
||||
const globalResetBtn = document.getElementById('health-global-reset');
|
||||
const globalStatusSpan = document.getElementById('health-global-status');
|
||||
const retentionInput = document.getElementById('health-setting-retention');
|
||||
const intervalInput = document.getElementById('health-setting-interval');
|
||||
const statsIntervalInput = document.getElementById('health-setting-stats-interval');
|
||||
const maxEntriesInput = document.getElementById('health-setting-max-entries');
|
||||
const diskThresholdInput = document.getElementById('health-setting-disk-threshold');
|
||||
|
||||
function loadHealthSettings() {
|
||||
try {
|
||||
const raw = safeGet(HEALTH_SETTINGS_KEY);
|
||||
const saved = raw ? JSON.parse(raw) : {};
|
||||
return Object.assign({}, HEALTH_DEFAULTS, saved);
|
||||
} catch (_) {
|
||||
return Object.assign({}, HEALTH_DEFAULTS);
|
||||
}
|
||||
}
|
||||
|
||||
function applyHealthSettingsToUI() {
|
||||
const s = loadHealthSettings();
|
||||
if (retentionInput) retentionInput.value = s.retentionDays;
|
||||
if (intervalInput) intervalInput.value = s.pollingInterval;
|
||||
if (statsIntervalInput) statsIntervalInput.value = s.statsPollingInterval;
|
||||
if (maxEntriesInput) maxEntriesInput.value = s.maxEntriesPerService;
|
||||
if (diskThresholdInput) diskThresholdInput.value = s.diskUsageThreshold;
|
||||
}
|
||||
|
||||
function saveHealthSettings() {
|
||||
const settings = {
|
||||
retentionDays: Math.max(1, Math.min(3650, parseInt(retentionInput?.value) || HEALTH_DEFAULTS.retentionDays)),
|
||||
pollingInterval: Math.max(5, Math.min(3600, parseInt(intervalInput?.value) || HEALTH_DEFAULTS.pollingInterval)),
|
||||
statsPollingInterval: Math.max(5, Math.min(3600, parseInt(statsIntervalInput?.value) || HEALTH_DEFAULTS.statsPollingInterval)),
|
||||
maxEntriesPerService: Math.max(10, Math.min(100000, parseInt(maxEntriesInput?.value) || HEALTH_DEFAULTS.maxEntriesPerService)),
|
||||
diskUsageThreshold: Math.max(50, Math.min(99, parseInt(diskThresholdInput?.value) || HEALTH_DEFAULTS.diskUsageThreshold))
|
||||
};
|
||||
try {
|
||||
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(settings));
|
||||
applyHealthSettingsToUI();
|
||||
if (globalStatusSpan) {
|
||||
globalStatusSpan.textContent = 'Saved ✓';
|
||||
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||
}
|
||||
if (typeof showNotification === 'function') showNotification('Global health settings saved', 'success');
|
||||
} catch (e) {
|
||||
if (globalStatusSpan) { globalStatusSpan.textContent = 'Save failed'; globalStatusSpan.style.color = 'var(--bad-fg)'; }
|
||||
if (typeof showNotification === 'function') showNotification('Failed to save settings: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetHealthSettings() {
|
||||
try {
|
||||
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(HEALTH_DEFAULTS));
|
||||
} catch (_) { /* ignore */ }
|
||||
applyHealthSettingsToUI();
|
||||
if (globalStatusSpan) {
|
||||
globalStatusSpan.textContent = 'Reset to defaults ✓';
|
||||
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
applyHealthSettingsToUI();
|
||||
globalSaveBtn?.addEventListener('click', saveHealthSettings);
|
||||
globalResetBtn?.addEventListener('click', resetHealthSettings);
|
||||
// ---- End global health settings ----
|
||||
|
||||
let editingId = null;
|
||||
|
||||
function uptimeColor(pct) {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* DC-077: i18n Frontend — Language selector and translation system
|
||||
*
|
||||
* Provides window.DCI18n.t(key) for the dashboard frontend.
|
||||
* Loads translations from /api/v1/i18n/translations/:lang
|
||||
* Language preference stored in localStorage.
|
||||
* Handles RTL for Arabic.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'dashcaddy-language';
|
||||
const DEFAULT_LANG = 'en';
|
||||
// Must match the 31 languages in language-selector.js and the backend i18n route.
|
||||
const SUPPORTED_LANGS = [
|
||||
'en', 'ar', 'bn', 'cs', 'da', 'de', 'el', 'es', 'fa', 'fi',
|
||||
'fr', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no',
|
||||
'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh',
|
||||
];
|
||||
const LANG_NAMES = {
|
||||
en: 'English', ar: 'العربية', bn: 'বাংলা', cs: 'Čeština', da: 'Dansk',
|
||||
de: 'Deutsch', el: 'Ελληνικά', es: 'Español', fa: 'فارسی', fi: 'Suomi',
|
||||
fr: 'Français', hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano',
|
||||
ja: '日本語', ko: '한국어', ms: 'Bahasa Melayu', nl: 'Nederlands', no: 'Norsk',
|
||||
pl: 'Polski', pt: 'Português', ro: 'Română', ru: 'Русский', sv: 'Svenska',
|
||||
th: 'ไทย', tr: 'Türkçe', uk: 'Українська', ur: 'اردو', vi: 'Tiếng Việt', zh: '中文',
|
||||
};
|
||||
// RTL languages need dir="rtl" on the document element. (No Hebrew per project policy.)
|
||||
const RTL_LANGS = new Set(['ar', 'fa', 'ur']);
|
||||
|
||||
// Validate the stored language — if it's invalid (old/corrupt), fall back to default.
|
||||
// Wrap in try/catch for environments where localStorage is disabled (private mode).
|
||||
function _readValidLang() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
|
||||
let currentLang = _readValidLang();
|
||||
let translations = {};
|
||||
let loaded = false;
|
||||
// Monotonic token to guard against out-of-order async resolution.
|
||||
// Each setLanguage / loadTranslations call captures the current value; if it
|
||||
// changed by the time the fetch resolves, the result is discarded.
|
||||
let _langRequestId = 0;
|
||||
|
||||
async function loadTranslations(lang, reqId) {
|
||||
// reqId is the monotonic token incremented by the caller (setLanguage/init).
|
||||
// If not provided (direct API call), allocate one for backward compatibility.
|
||||
if (reqId === undefined) reqId = ++_langRequestId;
|
||||
if (lang === DEFAULT_LANG) {
|
||||
translations = {}; // English is the default — no translation needed
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
|
||||
// Guard against out-of-order resolution: if another loadTranslations
|
||||
// started after this one (or the user switched languages), discard.
|
||||
if (reqId !== _langRequestId) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (reqId !== _langRequestId) return; // double-check after second await
|
||||
translations = data.translations || {};
|
||||
loaded = true;
|
||||
} else {
|
||||
// HTTP error — clear stale translations so we don't show the wrong language
|
||||
translations = {};
|
||||
loaded = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[i18n] Failed to load translations for', lang, e);
|
||||
if (reqId === _langRequestId) {
|
||||
translations = {};
|
||||
loaded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (currentLang === DEFAULT_LANG) return key;
|
||||
// If translations didn't load, fall back to the English key
|
||||
return translations[key] || key;
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
if (!SUPPORTED_LANGS.includes(lang)) return;
|
||||
currentLang = lang;
|
||||
try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* localStorage unavailable */ }
|
||||
|
||||
// RTL handling — always set dir/lang explicitly so switching back to LTR works.
|
||||
const isRtl = RTL_LANGS.has(lang);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
|
||||
loadTranslations(lang, reqId).then(() => {
|
||||
// Only apply if this is still the latest request.
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
function applyTranslations() {
|
||||
// Apply translations to elements with data-i18n attributes.
|
||||
// Always write the resolved value — when switching back to English or when a
|
||||
// key has no translation, this restores the original English text rather than
|
||||
// leaving the previous language's translated text visible.
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
// Apply to placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
// Apply to titles
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
}
|
||||
|
||||
function createLanguageSelector() {
|
||||
// Find the top bar area to insert the selector
|
||||
// Look for the auth-settings area or the header actions
|
||||
const targetContainer = document.querySelector('.header-actions') ||
|
||||
document.querySelector('#auth-settings-btn')?.parentElement ||
|
||||
document.querySelector('.top-bar-actions');
|
||||
|
||||
if (!targetContainer) {
|
||||
// If we can't find a target, try to add it near the settings button
|
||||
const settingsBtn = document.getElementById('auth-settings-btn');
|
||||
if (settingsBtn && settingsBtn.parentElement) {
|
||||
return createDropdown(settingsBtn.parentElement);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return createDropdown(targetContainer);
|
||||
}
|
||||
|
||||
function createDropdown(container) {
|
||||
// Check if selector already exists
|
||||
if (document.getElementById('dc-lang-selector')) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'dc-lang-selector';
|
||||
wrapper.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'dc-lang-btn';
|
||||
btn.className = 'lang-selector-btn';
|
||||
btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;';
|
||||
btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
|
||||
btn.title = 'Select Language';
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.id = 'dc-lang-dropdown';
|
||||
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 160px; max-height: 320px; overflow-y: auto;';
|
||||
|
||||
SUPPORTED_LANGS.forEach(lang => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'lang-option';
|
||||
item.style.cssText = 'padding: 8px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-primary, #e0e0e0);';
|
||||
item.onmouseenter = () => item.style.background = 'var(--card-hover, rgba(255,255,255,0.05))';
|
||||
item.onmouseleave = () => item.style.background = 'transparent';
|
||||
|
||||
const flag = document.createElement('span');
|
||||
flag.textContent = lang === currentLang ? '✓' : '';
|
||||
flag.style.cssText = 'width: 16px; color: var(--ok-fg, #4ade80);';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.textContent = LANG_NAMES[lang];
|
||||
|
||||
item.appendChild(flag);
|
||||
item.appendChild(name);
|
||||
item.onclick = () => {
|
||||
setLanguage(lang);
|
||||
dropdown.style.display = 'none';
|
||||
// Update button text
|
||||
btn.querySelector('.lang-current').textContent = lang.toUpperCase();
|
||||
// Update checkmarks
|
||||
dropdown.querySelectorAll('.lang-option').forEach((opt, i) => {
|
||||
opt.querySelector('span').textContent = SUPPORTED_LANGS[i] === lang ? '✓' : '';
|
||||
});
|
||||
// Show notification
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Language: ${LANG_NAMES[lang]}`, 'info');
|
||||
}
|
||||
};
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!wrapper.contains(e.target)) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
wrapper.appendChild(dropdown);
|
||||
container.insertBefore(wrapper, container.firstChild);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
function init() {
|
||||
// Always set dir/lang explicitly — covers LTR reset and RTL setup.
|
||||
const isRtl = RTL_LANGS.has(currentLang);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
function start() {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) {
|
||||
const reqId = ++_langRequestId;
|
||||
loadTranslations(currentLang, reqId).then(() => {
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
|
||||
|
||||
// Auto-init
|
||||
init();
|
||||
})();
|
||||
+180
-15
@@ -2,12 +2,13 @@
|
||||
* DC-077: i18n Language Selector
|
||||
*
|
||||
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch
|
||||
* the dashboard language between en / es / zh / ar / de.
|
||||
* the dashboard language. Supports all 31 backend languages with a searchable list.
|
||||
*
|
||||
* - Shows current language with flag emoji
|
||||
* - Persists selection to localStorage('dashcaddy-language')
|
||||
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
|
||||
* - Reloads the page on change so the new language takes effect
|
||||
* - Search filter for quickly finding a language in the 31-option list
|
||||
*
|
||||
* Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
|
||||
*/
|
||||
@@ -18,11 +19,37 @@
|
||||
const CONFIG_ENDPOINT = '/api/v1/config';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', flag: '🇺🇸', label: 'English' },
|
||||
{ code: 'es', flag: '🇪🇸', label: 'Español' },
|
||||
{ code: 'zh', flag: '🇨🇳', label: '中文' },
|
||||
{ code: 'ar', flag: '🇸🇦', label: 'العربية' },
|
||||
{ code: 'de', flag: '🇩🇪', label: 'Deutsch' },
|
||||
{ code: 'en', flag: '🇬🇧', label: 'English', nativeLabel: 'English' },
|
||||
{ code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' },
|
||||
{ code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' },
|
||||
{ code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' },
|
||||
{ code: 'da', flag: '🇩🇰', label: 'Danish', nativeLabel: 'Dansk' },
|
||||
{ code: 'de', flag: '🇩🇪', label: 'German', nativeLabel: 'Deutsch' },
|
||||
{ code: 'el', flag: '🇬🇷', label: 'Greek', nativeLabel: 'Ελληνικά' },
|
||||
{ code: 'es', flag: '🇪🇸', label: 'Spanish', nativeLabel: 'Español' },
|
||||
{ code: 'fa', flag: '🇮🇷', label: 'Persian', nativeLabel: 'فارسی' },
|
||||
{ code: 'fi', flag: '🇫🇮', label: 'Finnish', nativeLabel: 'Suomi' },
|
||||
{ code: 'fr', flag: '🇫🇷', label: 'French', nativeLabel: 'Français' },
|
||||
{ code: 'hi', flag: '🇮🇳', label: 'Hindi', nativeLabel: 'हिन्दी' },
|
||||
{ code: 'hu', flag: '🇭🇺', label: 'Hungarian', nativeLabel: 'Magyar' },
|
||||
{ code: 'id', flag: '🇮🇩', label: 'Indonesian', nativeLabel: 'Bahasa Indonesia' },
|
||||
{ code: 'it', flag: '🇮🇹', label: 'Italian', nativeLabel: 'Italiano' },
|
||||
{ code: 'ja', flag: '🇯🇵', label: 'Japanese', nativeLabel: '日本語' },
|
||||
{ code: 'ko', flag: '🇰🇷', label: 'Korean', nativeLabel: '한국어' },
|
||||
{ code: 'ms', flag: '🇲🇾', label: 'Malay', nativeLabel: 'Bahasa Melayu' },
|
||||
{ code: 'nl', flag: '🇳🇱', label: 'Dutch', nativeLabel: 'Nederlands' },
|
||||
{ code: 'no', flag: '🇳🇴', label: 'Norwegian', nativeLabel: 'Norsk' },
|
||||
{ code: 'pl', flag: '🇵🇱', label: 'Polish', nativeLabel: 'Polski' },
|
||||
{ code: 'pt', flag: '🇵🇹', label: 'Portuguese', nativeLabel: 'Português' },
|
||||
{ code: 'ro', flag: '🇷🇴', label: 'Romanian', nativeLabel: 'Română' },
|
||||
{ code: 'ru', flag: '🇷🇺', label: 'Russian', nativeLabel: 'Русский' },
|
||||
{ code: 'sv', flag: '🇸🇪', label: 'Swedish', nativeLabel: 'Svenska' },
|
||||
{ code: 'th', flag: '🇹🇭', label: 'Thai', nativeLabel: 'ไทย' },
|
||||
{ code: 'tr', flag: '🇹🇷', label: 'Turkish', nativeLabel: 'Türkçe' },
|
||||
{ code: 'uk', flag: '🇺🇦', label: 'Ukrainian', nativeLabel: 'Українська' },
|
||||
{ code: 'ur', flag: '🇵🇰', label: 'Urdu', nativeLabel: 'اردو' },
|
||||
{ code: 'vi', flag: '🇻🇳', label: 'Vietnamese', nativeLabel: 'Tiếng Việt' },
|
||||
{ code: 'zh', flag: '🇨🇳', label: 'Chinese', nativeLabel: '中文' },
|
||||
];
|
||||
|
||||
const SUPPORTED = LANGUAGES.map(l => l.code);
|
||||
@@ -82,7 +109,10 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
min-width: 200px;
|
||||
max-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-base, #1e1e2e);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
@@ -92,7 +122,35 @@
|
||||
display: none;
|
||||
}
|
||||
.dc-lang-menu.open {
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
.dc-lang-search {
|
||||
margin: 2px 0 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-base, #111);
|
||||
color: var(--fg);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dc-lang-search:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.dc-lang-list {
|
||||
overflow-y: auto;
|
||||
max-height: 280px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dc-lang-list::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
.dc-lang-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dc-lang-option {
|
||||
display: flex;
|
||||
@@ -126,6 +184,11 @@
|
||||
.dc-lang-option.active .dc-lang-check {
|
||||
opacity: 1;
|
||||
}
|
||||
.dc-lang-option.dc-lang-focus,
|
||||
.dc-lang-option:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.dc-lang-label-sm {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
@@ -146,19 +209,111 @@
|
||||
menu.className = 'dc-lang-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
// Search input
|
||||
const search = document.createElement('input');
|
||||
search.type = 'text';
|
||||
search.className = 'dc-lang-search';
|
||||
search.placeholder = 'Search language…';
|
||||
search.setAttribute('aria-label', 'Search languages');
|
||||
search.autocomplete = 'off';
|
||||
|
||||
// Scrollable option list
|
||||
const list = document.createElement('div');
|
||||
list.className = 'dc-lang-list';
|
||||
|
||||
for (const lang of LANGUAGES) {
|
||||
const opt = document.createElement('div');
|
||||
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
|
||||
opt.setAttribute('role', 'menuitemradio');
|
||||
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
|
||||
opt.setAttribute('tabindex', '-1');
|
||||
opt.dataset.lang = lang.code;
|
||||
opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase();
|
||||
opt.innerHTML =
|
||||
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
|
||||
'<span class="dc-lang-name">' + lang.label + '</span>' +
|
||||
'<span class="dc-lang-name">' + lang.nativeLabel +
|
||||
'<span style="opacity:0.5;font-size:0.8em;margin-left:6px;">' + lang.label + '</span>' +
|
||||
'</span>' +
|
||||
'<span class="dc-lang-check">✓</span>';
|
||||
menu.appendChild(opt);
|
||||
list.appendChild(opt);
|
||||
}
|
||||
return menu;
|
||||
|
||||
// Filter logic — extracted so we can reset from the open handler
|
||||
function applyFilter(query) {
|
||||
var q = (query || '').toLowerCase().trim();
|
||||
list.querySelectorAll('.dc-lang-option').forEach(function (opt) {
|
||||
var match = !q || opt.dataset.search.indexOf(q) !== -1;
|
||||
opt.style.display = match ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
search.addEventListener('input', function () {
|
||||
applyFilter(this.value);
|
||||
});
|
||||
|
||||
// Prevent clicks on search from closing the menu
|
||||
search.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
|
||||
// Expose reset so init() can restore visibility when reopening
|
||||
menu._resetFilter = function () {
|
||||
search.value = '';
|
||||
applyFilter('');
|
||||
};
|
||||
|
||||
// ===== Keyboard navigation (Arrow Up/Down, Enter, Space) =====
|
||||
function getVisibleOptions() {
|
||||
return Array.from(list.querySelectorAll('.dc-lang-option')).filter(
|
||||
function (o) { return o.style.display !== 'none'; }
|
||||
);
|
||||
}
|
||||
|
||||
function focusOption(opt) {
|
||||
if (!opt) return;
|
||||
var visible = getVisibleOptions();
|
||||
visible.forEach(function (o) { o.classList.remove('dc-lang-focus'); });
|
||||
opt.classList.add('dc-lang-focus');
|
||||
opt.focus();
|
||||
}
|
||||
|
||||
search.addEventListener('keydown', function (e) {
|
||||
var visible = getVisibleOptions();
|
||||
if (visible.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
focusOption(visible[0]);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
var active = list.querySelector('.dc-lang-option.active');
|
||||
if (active && active.style.display !== 'none') selectLanguage(active.dataset.lang);
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener('keydown', function (e) {
|
||||
var visible = getVisibleOptions();
|
||||
var currentIdx = visible.indexOf(document.activeElement);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var next = visible[Math.min(currentIdx + 1, visible.length - 1)];
|
||||
focusOption(next);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (currentIdx === 0) {
|
||||
search.focus();
|
||||
} else {
|
||||
focusOption(visible[currentIdx - 1]);
|
||||
}
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
var opt = document.activeElement;
|
||||
if (opt && opt.classList.contains('dc-lang-option')) {
|
||||
selectLanguage(opt.dataset.lang);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menu.appendChild(search);
|
||||
menu.appendChild(list);
|
||||
return { menu: menu, search: search };
|
||||
}
|
||||
|
||||
async function selectLanguage(code) {
|
||||
@@ -207,7 +362,9 @@
|
||||
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
||||
'<span class="dc-lang-caret">▼</span>';
|
||||
|
||||
const menu = buildMenu(current);
|
||||
const built = buildMenu(current);
|
||||
const menu = built.menu;
|
||||
const searchInput = built.search;
|
||||
|
||||
// Small label beneath, matching the "Customize Theme" link style
|
||||
const label = document.createElement('span');
|
||||
@@ -223,9 +380,16 @@
|
||||
e.stopPropagation();
|
||||
const isOpen = menu.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (isOpen) {
|
||||
// Reset filter: clear search text AND restore all hidden options
|
||||
if (typeof menu._resetFilter === 'function') {
|
||||
menu._resetFilter();
|
||||
}
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Option clicks
|
||||
// Option clicks (delegate to the list container)
|
||||
menu.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('.dc-lang-option');
|
||||
if (!opt) return;
|
||||
@@ -243,11 +407,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
// Close on Escape — return focus to the trigger button for accessibility
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === 'Escape' && menu.classList.contains('open')) {
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.focus();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -377,8 +377,26 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
};
|
||||
}
|
||||
|
||||
// Finish setup button
|
||||
const finishBtn = document.getElementById('setup-finish');
|
||||
// Summary "Continue →" — advance to the disk-safety warning step
|
||||
const summaryNext = document.getElementById('setup-summary-next');
|
||||
if (summaryNext) {
|
||||
summaryNext.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-disk-safety');
|
||||
};
|
||||
}
|
||||
|
||||
// Disk-safety step navigation
|
||||
const diskSafetyBack = document.getElementById('setup-disk-safety-back');
|
||||
if (diskSafetyBack) {
|
||||
diskSafetyBack.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-summary');
|
||||
};
|
||||
}
|
||||
|
||||
// Finish setup button (now on the disk-safety step)
|
||||
const finishBtn = document.getElementById('setup-disk-safety-finish');
|
||||
if (finishBtn) {
|
||||
finishBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user