AI Intent Router:
- Wired /api/v1/ai/intent and /api/v1/ai/capabilities into app.js
- Pattern matching works offline, no API key needed
- Handles: deploy, recommend, diagnose, backup, health, list
- AI chat floating button on dashboard (🤖)
- Suggestion chips: Deploy Plex, Stream movies, Block ads, System health
- Deploy buttons in chat launch the app selector
TOTP Fix:
- secureFetch() was missing credentials: same-origin
- Session cookie was not being sent on API calls
- Added credentials: same-origin to all fetch calls
- Users no longer prompted for TOTP on every action
Nesting Guard:
- Fixed logging module path (../utils/logging not ./logging)
- Switched to console.log to avoid module export mismatch
MCP Server:
- 551-line JSON-RPC server ready at src/mcp/mcp-server.js
- Configurable via DASHCADDY_URL + DASHCADDY_API_KEY env vars
286 lines
8.5 KiB
JavaScript
286 lines
8.5 KiB
JavaScript
/**
|
|
* 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());
|
|
})();
|