[grade=A] deploy: rebuild dist with 31-language i18n + disk safety wizard + health settings
This commit is contained in:
Vendored
+38
-37
File diff suppressed because one or more lines are too long
Vendored
+288
-249
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+63
-1
@@ -654,6 +654,23 @@
|
|||||||
<!-- Will be filled dynamically -->
|
<!-- Will be filled dynamically -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk safety info: health checks accumulate data over time -->
|
||||||
|
<div style="margin-top: 16px; padding: 14px 16px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 10%, transparent); border-radius: 8px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||||
|
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||||
|
<span style="font-size: 1.2rem; line-height: 1;">💾</span>
|
||||||
|
<div>
|
||||||
|
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage & Health-Check Data</strong>
|
||||||
|
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted); line-height: 1.45;">
|
||||||
|
DashCaddy's health checks log response times, uptime history, and incidents for every monitored service.
|
||||||
|
Over time this data accumulates and can consume significant disk space — especially on small VPS or
|
||||||
|
SD-card installs. After setup, open <strong>Health → Configure → Global Settings</strong> to set a
|
||||||
|
<strong>data retention period</strong> (default 30 days), adjust the <strong>polling interval</strong>,
|
||||||
|
and configure a <strong>disk-usage warning threshold</strong> so you're alerted before storage runs low.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||||||
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||||||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||||||
@@ -663,7 +680,49 @@
|
|||||||
|
|
||||||
<div class="setup-wizard-buttons">
|
<div class="setup-wizard-buttons">
|
||||||
<button id="setup-summary-back">← Back</button>
|
<button id="setup-summary-back">← Back</button>
|
||||||
<button id="setup-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
<button id="setup-summary-next" class="setup-btn-primary">Continue →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk Safety Warning step (shown after the configuration summary) -->
|
||||||
|
<div class="setup-step" id="setup-step-disk-safety" style="display: none;">
|
||||||
|
<h2 style="margin: 0 0 8px;">⚠️ Disk Usage Note</h2>
|
||||||
|
<p class="setup-desc">Important information about storage before you finish</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 8px; padding: 18px 20px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 12%, transparent); border-radius: 10px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: flex-start;">
|
||||||
|
<span style="font-size: 1.5rem; line-height: 1.2;">⚠️</span>
|
||||||
|
<div style="font-size: 0.92rem; line-height: 1.55; color: var(--text);">
|
||||||
|
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage Note:</strong>
|
||||||
|
DashCaddy stores health check history, container statistics, and event logs.
|
||||||
|
On a busy server, this data can accumulate over time.
|
||||||
|
Set appropriate retention limits in <strong>Settings → Health</strong> to prevent disk fill.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px; padding: 14px 16px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||||||
|
<strong style="font-size: 0.9rem;">📋 Recommended after setup</strong>
|
||||||
|
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 0.85rem; color: var(--muted); line-height: 1.6;">
|
||||||
|
<li>Open <strong>Health → Configure → Global Settings</strong></li>
|
||||||
|
<li>Set a <strong>health check polling interval</strong> (default: 60s)</li>
|
||||||
|
<li>Set a <strong>stats polling interval</strong> (default: 30s)</li>
|
||||||
|
<li>Set a <strong>data retention period</strong> (default: 30 days)</li>
|
||||||
|
<li>Cap <strong>max entries per service</strong> (default: 500)</li>
|
||||||
|
<li>Set a <strong>disk-usage warning threshold</strong> (default: 80%)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||||||
|
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||||||
|
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||||||
|
Go to Settings → System Configuration to edit your setup anytime
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-wizard-buttons">
|
||||||
|
<button id="setup-disk-safety-back">← Back</button>
|
||||||
|
<button id="setup-disk-safety-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -959,6 +1018,9 @@
|
|||||||
<script src="/js/language-selector.js" defer></script>
|
<script src="/js/language-selector.js" defer></script>
|
||||||
|
|
||||||
<!-- Bundled JS (built with: npm run build) -->
|
<!-- Bundled JS (built with: npm run build) -->
|
||||||
|
<!-- i18n (language selector + translation system) -->
|
||||||
|
<script src="/js/i18n.js" defer></script>
|
||||||
|
|
||||||
<script src="/dist/core.js" defer></script>
|
<script src="/dist/core.js" defer></script>
|
||||||
<script src="/dist/features.js" defer></script>
|
<script src="/dist/features.js" defer></script>
|
||||||
<script src="/dist/onboarding.js" defer></script>
|
<script src="/dist/onboarding.js" defer></script>
|
||||||
|
|||||||
+19
-1
@@ -1,10 +1,28 @@
|
|||||||
// ===== DASHBOARD CONSTANTS =====
|
// ===== 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 = {
|
const DC = {
|
||||||
NAME: 'DashCaddy',
|
NAME: 'DashCaddy',
|
||||||
POLL: {
|
POLL: {
|
||||||
DASHBOARD: 10000, // 10s — main refreshAll interval
|
DASHBOARD: 10000, // 10s — main refreshAll interval
|
||||||
LOGS: 3000, // 3s — log viewer updates
|
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
|
WEATHER: 600000, // 10m — weather widget refresh
|
||||||
HEALTH: 1000, // 1s — card health badge refresh
|
HEALTH: 1000, // 1s — card health badge refresh
|
||||||
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
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 class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||||
</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 -->
|
<!-- 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);">
|
<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>
|
<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 formCancel = document.getElementById('health-form-cancel');
|
||||||
const formSave = document.getElementById('health-form-save');
|
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;
|
let editingId = null;
|
||||||
|
|
||||||
function uptimeColor(pct) {
|
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
|
* DC-077: i18n Language Selector
|
||||||
*
|
*
|
||||||
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch
|
* 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
|
* - Shows current language with flag emoji
|
||||||
* - Persists selection to localStorage('dashcaddy-language')
|
* - Persists selection to localStorage('dashcaddy-language')
|
||||||
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
|
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
|
||||||
* - Reloads the page on change so the new language takes effect
|
* - 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).
|
* Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
|
||||||
*/
|
*/
|
||||||
@@ -18,11 +19,37 @@
|
|||||||
const CONFIG_ENDPOINT = '/api/v1/config';
|
const CONFIG_ENDPOINT = '/api/v1/config';
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: 'en', flag: '🇺🇸', label: 'English' },
|
{ code: 'en', flag: '🇬🇧', label: 'English', nativeLabel: 'English' },
|
||||||
{ code: 'es', flag: '🇪🇸', label: 'Español' },
|
{ code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' },
|
||||||
{ code: 'zh', flag: '🇨🇳', label: '中文' },
|
{ code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' },
|
||||||
{ code: 'ar', flag: '🇸🇦', label: 'العربية' },
|
{ code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' },
|
||||||
{ code: 'de', flag: '🇩🇪', label: 'Deutsch' },
|
{ 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);
|
const SUPPORTED = LANGUAGES.map(l => l.code);
|
||||||
@@ -82,7 +109,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
right: 0;
|
right: 0;
|
||||||
min-width: 160px;
|
min-width: 200px;
|
||||||
|
max-height: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--card-base, #1e1e2e);
|
background: var(--card-base, #1e1e2e);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -92,7 +122,35 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.dc-lang-menu.open {
|
.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 {
|
.dc-lang-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -126,6 +184,11 @@
|
|||||||
.dc-lang-option.active .dc-lang-check {
|
.dc-lang-option.active .dc-lang-check {
|
||||||
opacity: 1;
|
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 {
|
.dc-lang-label-sm {
|
||||||
background: none !important;
|
background: none !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
@@ -146,19 +209,111 @@
|
|||||||
menu.className = 'dc-lang-menu';
|
menu.className = 'dc-lang-menu';
|
||||||
menu.setAttribute('role', '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) {
|
for (const lang of LANGUAGES) {
|
||||||
const opt = document.createElement('div');
|
const opt = document.createElement('div');
|
||||||
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
|
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
|
||||||
opt.setAttribute('role', 'menuitemradio');
|
opt.setAttribute('role', 'menuitemradio');
|
||||||
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
|
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
|
||||||
|
opt.setAttribute('tabindex', '-1');
|
||||||
opt.dataset.lang = lang.code;
|
opt.dataset.lang = lang.code;
|
||||||
|
opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase();
|
||||||
opt.innerHTML =
|
opt.innerHTML =
|
||||||
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
|
'<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>';
|
'<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) {
|
async function selectLanguage(code) {
|
||||||
@@ -207,7 +362,9 @@
|
|||||||
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
||||||
'<span class="dc-lang-caret">▼</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
|
// Small label beneath, matching the "Customize Theme" link style
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
@@ -223,9 +380,16 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isOpen = menu.classList.toggle('open');
|
const isOpen = menu.classList.toggle('open');
|
||||||
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
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) => {
|
menu.addEventListener('click', (e) => {
|
||||||
const opt = e.target.closest('.dc-lang-option');
|
const opt = e.target.closest('.dc-lang-option');
|
||||||
if (!opt) return;
|
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) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape' && menu.classList.contains('open')) {
|
||||||
menu.classList.remove('open');
|
menu.classList.remove('open');
|
||||||
btn.setAttribute('aria-expanded', 'false');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -377,8 +377,26 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finish setup button
|
// Summary "Continue →" — advance to the disk-safety warning step
|
||||||
const finishBtn = document.getElementById('setup-finish');
|
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) {
|
if (finishBtn) {
|
||||||
finishBtn.onclick = function(e) {
|
finishBtn.onclick = function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
|
const CACHE = 'dashcaddy-shell-e57b8ce3e7';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user