fix: recursive data nesting guard + VM destroy in uninstall wizard
- Cleaned 242MB of recursive data/data/data/ nesting - Added nesting-guard.js: auto-detects and removes recursive duplicates at startup - Wired VM sandbox cleanup into uninstall wizard (calls vmDestroy before regular uninstall) - Container stats, health data, and VM disk all cleaned on uninstall
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* - 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
|
||||
*
|
||||
* Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'dashcaddy-language';
|
||||
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' },
|
||||
];
|
||||
|
||||
const SUPPORTED = LANGUAGES.map(l => l.code);
|
||||
|
||||
function getCurrentLanguage() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED.includes(stored)) return stored;
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function langMeta(code) {
|
||||
return LANGUAGES.find(l => l.code === code) || LANGUAGES[0];
|
||||
}
|
||||
|
||||
// ===== Inject styles once =====
|
||||
function injectStyles() {
|
||||
if (document.getElementById('dc-lang-selector-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'dc-lang-selector-styles';
|
||||
style.textContent = `
|
||||
.dc-lang-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.dc-lang-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
background: var(--card-base);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
}
|
||||
.dc-lang-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.dc-lang-btn .dc-lang-flag {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.dc-lang-btn .dc-lang-caret {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.dc-lang-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--card-base, #1e1e2e);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 4px;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
}
|
||||
.dc-lang-menu.open {
|
||||
display: block;
|
||||
}
|
||||
.dc-lang-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg);
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-lang-option:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
.dc-lang-option.active {
|
||||
background: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dc-lang-option .dc-lang-flag {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.dc-lang-option .dc-lang-check {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dc-lang-option.active .dc-lang-check {
|
||||
opacity: 1;
|
||||
}
|
||||
.dc-lang-label-sm {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
opacity: 0.8;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function buildMenu(current) {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'dc-lang-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
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.dataset.lang = lang.code;
|
||||
opt.innerHTML =
|
||||
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
|
||||
'<span class="dc-lang-name">' + lang.label + '</span>' +
|
||||
'<span class="dc-lang-check">✓</span>';
|
||||
menu.appendChild(opt);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function selectLanguage(code) {
|
||||
if (!SUPPORTED.includes(code) || code === getCurrentLanguage()) return;
|
||||
// Persist locally immediately for instant reload
|
||||
localStorage.setItem(STORAGE_KEY, code);
|
||||
|
||||
// Best-effort backend sync — don't block the reload on failure
|
||||
try {
|
||||
if (typeof postJSON === 'function') {
|
||||
await postJSON(CONFIG_ENDPOINT, { language: code });
|
||||
} else if (typeof secureFetch === 'function') {
|
||||
await secureFetch(CONFIG_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: code }),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: localStorage already holds the preference
|
||||
console.warn('[LanguageSelector] backend sync failed:', err);
|
||||
}
|
||||
|
||||
// Reload so the new language takes effect across the dashboard
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function init() {
|
||||
injectStyles();
|
||||
|
||||
const current = getCurrentLanguage();
|
||||
const meta = langMeta(current);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'dc-lang-wrap';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'dc-lang-btn';
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-label', 'Select language');
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.title = 'Switch language';
|
||||
btn.innerHTML =
|
||||
'<span class="dc-lang-flag">' + meta.flag + '</span>' +
|
||||
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
||||
'<span class="dc-lang-caret">▼</span>';
|
||||
|
||||
const menu = buildMenu(current);
|
||||
|
||||
// Small label beneath, matching the "Customize Theme" link style
|
||||
const label = document.createElement('span');
|
||||
label.className = 'dc-lang-label-sm';
|
||||
label.textContent = 'Language';
|
||||
|
||||
wrap.appendChild(btn);
|
||||
wrap.appendChild(label);
|
||||
wrap.appendChild(menu);
|
||||
|
||||
// Toggle menu open/closed
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = menu.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
});
|
||||
|
||||
// Option clicks
|
||||
menu.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('.dc-lang-option');
|
||||
if (!opt) return;
|
||||
const code = opt.dataset.lang;
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
selectLanguage(code);
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!wrap.contains(e.target)) {
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Expose for programmatic use / testing
|
||||
window.DCLanguageSelector = {
|
||||
getCurrentLanguage,
|
||||
selectLanguage,
|
||||
LANGUAGES,
|
||||
STORAGE_KEY,
|
||||
};
|
||||
|
||||
// Auto-mount into the navbar next to the theme toggle.
|
||||
// The dashboard loads core.js (globals) before this deferred script, but the
|
||||
// navbar container is always present in the initial HTML.
|
||||
function mount() {
|
||||
const group = document.querySelector('.theme-toggle-group');
|
||||
if (group && group.parentNode) {
|
||||
// Insert immediately after the theme-toggle-group so it sits beside it
|
||||
group.parentNode.insertBefore(init(), group.nextSibling);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
|
||||
console.log('[LanguageSelector] Module loaded — current language:', getCurrentLanguage());
|
||||
})();
|
||||
Reference in New Issue
Block a user