3070 lines
149 KiB
PHP
3070 lines
149 KiB
PHP
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
|
||
const settingsMobileNav = document.getElementById('settingsMobileNav');
|
||
document.querySelectorAll('.settings-tab-btn[data-settings-tab]').forEach(button => {
|
||
settingsMobileNav?.add(new Option(button.textContent.trim(), button.dataset.settingsTab));
|
||
});
|
||
// A settings link (#settings/…) can activate a tab before this script runs: show it in the menu too
|
||
if (settingsMobileNav) {
|
||
settingsMobileNav.value = document.querySelector('.settings-tab-btn.active')?.dataset.settingsTab || settingsMobileNav.value;
|
||
window.rjSelectSync?.(settingsMobileNav);
|
||
}
|
||
settingsMobileNav?.addEventListener('change', () => activateSettingsTab(settingsMobileNav.value));
|
||
function loadSettingsTabData(tab) {
|
||
if (tab === 'users' && typeof loadUsers === 'function' && typeof _usersLoaded !== 'undefined' && !_usersLoaded) {
|
||
_usersLoaded = true;
|
||
loadUsers();
|
||
}
|
||
if (tab === 'menus' && typeof loadMenuOrder === 'function' && typeof _menuOrderLoaded !== 'undefined' && !_menuOrderLoaded) {
|
||
_menuOrderLoaded = true;
|
||
loadMenuOrder();
|
||
}
|
||
if (tab === 'forms' && typeof loadFormsAdmin === 'function' && !formsAdminLoaded) {
|
||
formsAdminLoaded = true;
|
||
loadFormsAdmin();
|
||
}
|
||
if (tab === 'ai' && AI_FEATURES_ENABLED && typeof loadAiSettings === 'function') {
|
||
loadAiSettings();
|
||
}
|
||
}
|
||
|
||
function activateSettingsTab(tab, options) {
|
||
options = options || {};
|
||
var btn = document.querySelector('.settings-tab-btn[data-settings-tab="' + tab + '"]');
|
||
if (!btn) return;
|
||
document.querySelectorAll('.settings-tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||
document.querySelectorAll('.settings-panel').forEach(function(p) { p.classList.remove('active'); });
|
||
btn.classList.add('active');
|
||
if (typeof window.rjSetTopbarTitle === 'function') {
|
||
window.rjSetTopbarTitle([t('settings.title'), btn.textContent.trim()]);
|
||
}
|
||
// Looked up here: the initial route can call this before the constant above is initialized
|
||
var mobileNav = document.getElementById('settingsMobileNav');
|
||
if (mobileNav) { mobileNav.value = tab; window.rjSelectSync?.(mobileNav); }
|
||
var panel = document.getElementById('settingsPanel-' + tab);
|
||
if (panel) panel.classList.add('active');
|
||
loadSettingsTabData(tab);
|
||
if (!options.silent) updateDashboardHash('settings', tab, !!options.replace);
|
||
}
|
||
|
||
document.querySelectorAll('.settings-tab-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var action = this.getAttribute('data-settings-action');
|
||
if (action === 'backup') {
|
||
switchTab('backup');
|
||
return;
|
||
}
|
||
var tab = this.getAttribute('data-settings-tab');
|
||
if (!tab) return;
|
||
activateSettingsTab(tab);
|
||
});
|
||
});
|
||
|
||
async function loadSettings() {
|
||
try {
|
||
const response = await fetch('api.php?action=load-settings');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
populateSettings(currentSettings);
|
||
applyTheme(currentSettings.theme || {});
|
||
settingsLoaded = true;
|
||
} else {
|
||
showToast(t('toast.error_loading_settings', {message: result.message}), 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_loading_settings', {message: error.message}), 'error');
|
||
}
|
||
}
|
||
|
||
// Bypass links: one row per URL parameter / key pair, each with its
|
||
// assembled address. Keys are stored as entered. A key hashed by an
|
||
// older version is not shown; saving the form replaces it with the
|
||
// visible links.
|
||
function bypassSiteRoot() {
|
||
return window.location.origin + window.location.pathname.replace(/\/admin\/.*$/, '/');
|
||
}
|
||
|
||
function updateBypassRowLink(row) {
|
||
var out = row.querySelector('.bypass-row__link code');
|
||
var param = (row.querySelector('.bypass-row__param-input').value || '').trim() || 'preview';
|
||
var key = row.querySelector('.bypass-row__key-input').value || '';
|
||
if (out) out.textContent = bypassSiteRoot() + '?' + encodeURIComponent(param) + '=' + (key ? encodeURIComponent(key) : t('settings.access_bypass_key_placeholder'));
|
||
}
|
||
|
||
function addBypassRow(param, key) {
|
||
var container = document.getElementById('bypassLinks');
|
||
var tpl = document.getElementById('bypassRowTemplate');
|
||
if (!container || !tpl) return null;
|
||
var row = tpl.content.firstElementChild.cloneNode(true);
|
||
row.querySelector('.bypass-row__param-input').value = param || '';
|
||
row.querySelector('.bypass-row__key-input').value = key || '';
|
||
row.querySelectorAll('input').forEach(function(input) {
|
||
input.addEventListener('input', function() { updateBypassRowLink(row); });
|
||
});
|
||
row.querySelector('.bypass-row__remove').addEventListener('click', function() {
|
||
row.remove();
|
||
});
|
||
container.appendChild(row);
|
||
updateBypassRowLink(row);
|
||
return row;
|
||
}
|
||
|
||
function renderBypassLinks(maintenance) {
|
||
var container = document.getElementById('bypassLinks');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
var links = Array.isArray(maintenance.bypassLinks) ? maintenance.bypassLinks : [];
|
||
links.forEach(function(link) { addBypassRow(link.param, link.key); });
|
||
if (!container.children.length) addBypassRow(maintenance.bypassParam || 'preview', '');
|
||
}
|
||
|
||
function collectBypassLinks() {
|
||
var container = document.getElementById('bypassLinks');
|
||
var rows = container ? Array.prototype.slice.call(container.querySelectorAll('.bypass-row')) : [];
|
||
var links = [];
|
||
rows.forEach(function(row) {
|
||
var param = row.querySelector('.bypass-row__param-input').value.trim() || 'preview';
|
||
var key = row.querySelector('.bypass-row__key-input').value.trim();
|
||
if (key) links.push({ param: param, key: key });
|
||
});
|
||
return {
|
||
links: links,
|
||
firstParam: rows.length ? (rows[0].querySelector('.bypass-row__param-input').value.trim() || 'preview') : 'preview'
|
||
};
|
||
}
|
||
|
||
document.getElementById('bypassAddBtn')?.addEventListener('click', function() {
|
||
var row = addBypassRow('preview', '');
|
||
if (row) row.querySelector('.bypass-row__key-input').focus();
|
||
});
|
||
|
||
function populateSettings(settings) {
|
||
// Branding
|
||
var faviconPath = settings.favicon || '/assets/images/favicon.svg';
|
||
document.getElementById('settingsFavicon').value = faviconPath;
|
||
updateFaviconPreview(faviconPath);
|
||
updateClearButton(document.getElementById('settingsFavicon'));
|
||
|
||
var faviconPngPath = settings.favicon_png || '';
|
||
document.getElementById('settingsFaviconPng').value = faviconPngPath;
|
||
updateFaviconPngPreview(faviconPngPath);
|
||
updateClearButton(document.getElementById('settingsFaviconPng'));
|
||
|
||
var logoPath = settings.branding.logo || '';
|
||
document.getElementById('settingsLogo').value = logoPath;
|
||
document.getElementById('settingsName').value = settings.branding.name || '';
|
||
document.getElementById('settingsShowBranding').checked = settings.branding.showBranding !== false;
|
||
updateLogoPreview(logoPath);
|
||
updateClearButton(document.getElementById('settingsLogo'));
|
||
|
||
var adminLogoPath = settings.branding.adminLogo || '';
|
||
document.getElementById('settingsAdminLogo').value = adminLogoPath;
|
||
updateAdminLogoPreview(adminLogoPath);
|
||
updateClearButton(document.getElementById('settingsAdminLogo'));
|
||
|
||
var logoDarkPath = settings.branding.logoDark || '';
|
||
document.getElementById('settingsLogoDark').value = logoDarkPath;
|
||
updateLogoDarkPreview(logoDarkPath);
|
||
updateClearButton(document.getElementById('settingsLogoDark'));
|
||
|
||
var logoDisplay = settings.branding.logoDisplay || 'both';
|
||
var displayRadio = document.querySelector('input[name="settingsLogoDisplay"][value="' + logoDisplay + '"]');
|
||
if (displayRadio) displayRadio.checked = true;
|
||
updateLogoDisplayVisibility();
|
||
|
||
var logoSize = settings.branding.logoSize || 'medium';
|
||
var sizeRadio = document.querySelector('input[name="settingsLogoSize"][value="' + logoSize + '"]');
|
||
if (sizeRadio) sizeRadio.checked = true;
|
||
|
||
var defaultOgPath = settings.seo?.defaultOgImage || '';
|
||
var defaultOgInput = document.getElementById('settingsDefaultOgImage');
|
||
if (defaultOgInput) {
|
||
defaultOgInput.value = defaultOgPath;
|
||
updateDefaultOgPreview(defaultOgPath);
|
||
updateClearButton(defaultOgInput);
|
||
}
|
||
|
||
document.getElementById('settingsNoindexSite').checked = !!settings.seo?.noindexSite;
|
||
document.getElementById('settingsSiteUrl').value = settings.seo?.siteUrl || '';
|
||
|
||
// Theme
|
||
populateThemeForm(settings.theme || {});
|
||
|
||
// Button style
|
||
var glowCheckbox = document.getElementById('settingsButtonGlow');
|
||
if (glowCheckbox) glowCheckbox.checked = settings.theme.buttonGlow !== false;
|
||
var radiusSlider = document.getElementById('settingsButtonRadius');
|
||
var radiusValue = settings.theme.buttonRadius != null ? settings.theme.buttonRadius : 6;
|
||
if (radiusSlider) {
|
||
radiusSlider.value = radiusValue;
|
||
document.getElementById('settingsButtonRadiusValue').textContent = radiusValue + 'px';
|
||
}
|
||
var dashboardSettings = settings.dashboard || {};
|
||
var itemsPerPageEl = document.getElementById('settingsItemsPerPage');
|
||
if (itemsPerPageEl) itemsPerPageEl.value = clampDashboardPageSize(dashboardSettings.itemsPerPage);
|
||
var iconItemsPerPageEl = document.getElementById('settingsIconItemsPerPage');
|
||
if (iconItemsPerPageEl) iconItemsPerPageEl.value = clampDashboardPageSize(dashboardSettings.iconManagerItemsPerPage);
|
||
var mediaItemsPerPageEl = document.getElementById('settingsMediaItemsPerPage');
|
||
if (mediaItemsPerPageEl) mediaItemsPerPageEl.value = clampMediaPageSize(dashboardSettings.mediaItemsPerPage);
|
||
|
||
// Language
|
||
var langSelect = document.getElementById('settingsAdminLanguage');
|
||
if (langSelect) langSelect.value = settings.general?.adminLanguage || '';
|
||
var aiLabelArtworkSelect = document.getElementById('settingsAiLabelArtwork');
|
||
if (aiLabelArtworkSelect) aiLabelArtworkSelect.value = settings.general?.aiLabelArtwork === 'page' ? 'page' : 'en';
|
||
|
||
// Frontend-login redirect mode (default: 'auto')
|
||
var loginMode = (settings.general && settings.general.frontendLoginRedirect) || 'auto';
|
||
var modeRadio = document.querySelector('input[name="frontendLoginRedirect"][value="' + loginMode + '"]');
|
||
if (modeRadio) modeRadio.checked = true;
|
||
var loginVisual = settings.login || {};
|
||
var loginBrandEl = document.getElementById('loginBrandAsset');
|
||
if (loginBrandEl) loginBrandEl.value = loginVisual.brandAsset || 'favicon';
|
||
var loginImageLayoutEl = document.getElementById('loginImageLayout');
|
||
if (loginImageLayoutEl) loginImageLayoutEl.value = normalizeVisualImageLayout(loginVisual.imageLayout || 'none');
|
||
var loginOverlayColorEl = document.getElementById('loginOverlayColor');
|
||
if (loginOverlayColorEl) loginOverlayColorEl.value = loginVisual.overlayColor || '#ffffff';
|
||
setOverlayOpacity('loginOverlayOpacity', 'loginOverlayOpacityValue', loginVisual.overlayOpacity, 86);
|
||
updateVisualOverlayVisibility('loginImageLayout', 'loginOverlayColorGroup');
|
||
var loginBoxStyleEl = document.getElementById('loginBoxStyle');
|
||
if (loginBoxStyleEl) loginBoxStyleEl.value = loginVisual.boxStyle || 'card';
|
||
setLoginColorPair('loginBoxColor', loginVisual.boxColor || '#ffffff');
|
||
setLoginColorPair('loginBoxTextColor', loginVisual.boxTextColor || '#111827');
|
||
updateLoginBoxColorVisibility();
|
||
var loginImageEl = document.getElementById('loginImage');
|
||
if (loginImageEl) {
|
||
loginImageEl.value = loginVisual.image || '';
|
||
updateClearButton(loginImageEl);
|
||
}
|
||
|
||
// Access / privacy
|
||
var access = settings.access || {};
|
||
var maintenance = access.maintenance || {};
|
||
var enabledEl = document.getElementById('maintenanceEnabled');
|
||
if (enabledEl) enabledEl.checked = !!maintenance.enabled;
|
||
var maintModeEl = document.getElementById('maintenanceMode');
|
||
if (maintModeEl) maintModeEl.value = maintenance.mode || 'maintenance';
|
||
var maintTitleEl = document.getElementById('maintenanceTitle');
|
||
if (maintTitleEl) maintTitleEl.value = maintenance.title || '';
|
||
var maintTextEl = document.getElementById('maintenanceText');
|
||
if (maintTextEl) maintTextEl.value = maintenance.text || '';
|
||
var maintUntilEl = document.getElementById('maintenanceUntil');
|
||
if (maintUntilEl) maintUntilEl.value = (maintenance.until || '').slice(0, 16);
|
||
var maintCountdownEl = document.getElementById('maintenanceCountdown');
|
||
if (maintCountdownEl) maintCountdownEl.checked = !!maintenance.showCountdown;
|
||
var maintBrandEl = document.getElementById('maintenanceBrandAsset');
|
||
if (maintBrandEl) maintBrandEl.value = maintenance.brandAsset || 'none';
|
||
var maintImageLayoutEl = document.getElementById('maintenanceImageLayout');
|
||
if (maintImageLayoutEl) maintImageLayoutEl.value = normalizeVisualImageLayout(maintenance.imageLayout || 'none');
|
||
var maintOverlayColorEl = document.getElementById('maintenanceOverlayColor');
|
||
if (maintOverlayColorEl) maintOverlayColorEl.value = maintenance.overlayColor || '#ffffff';
|
||
setOverlayOpacity('maintenanceOverlayOpacity', 'maintenanceOverlayOpacityValue', maintenance.overlayOpacity, 88);
|
||
updateVisualOverlayVisibility('maintenanceImageLayout', 'maintenanceOverlayColorGroup');
|
||
var maintImageEl = document.getElementById('maintenanceImage');
|
||
if (maintImageEl) {
|
||
maintImageEl.value = maintenance.image || '';
|
||
updateClearButton(maintImageEl);
|
||
}
|
||
renderBypassLinks(maintenance);
|
||
var modules = settings.modules || {};
|
||
var moduleAiEl = document.getElementById('moduleAi');
|
||
if (moduleAiEl) moduleAiEl.checked = modules.ai !== false;
|
||
var moduleNewsEl = document.getElementById('moduleNews');
|
||
if (moduleNewsEl) moduleNewsEl.checked = modules.news !== false;
|
||
var moduleEventsEl = document.getElementById('moduleEvents');
|
||
if (moduleEventsEl) moduleEventsEl.checked = modules.events !== false;
|
||
var moduleMessagesEl = document.getElementById('moduleMessages');
|
||
if (moduleMessagesEl) moduleMessagesEl.checked = modules.messages !== false;
|
||
var moduleIconManagerEl = document.getElementById('moduleIconManager');
|
||
if (moduleIconManagerEl) moduleIconManagerEl.checked = modules.iconManager !== false;
|
||
var media = settings.media || {};
|
||
var mediaMaxUploadEl = document.getElementById('settingsMediaMaxUpload');
|
||
if (mediaMaxUploadEl) mediaMaxUploadEl.value = media.maxUploadMb || 20;
|
||
var mediaWebMaxSideEl = document.getElementById('settingsMediaWebMaxSide');
|
||
if (mediaWebMaxSideEl) mediaWebMaxSideEl.value = media.webMaxSide || 2000;
|
||
var mediaWebQualityEl = document.getElementById('settingsMediaWebQuality');
|
||
if (mediaWebQualityEl) mediaWebQualityEl.value = media.webQuality || 82;
|
||
var mediaWebVersionEl = document.getElementById('settingsMediaWebVersion');
|
||
if (mediaWebVersionEl) mediaWebVersionEl.checked = media.webVersion !== false;
|
||
const analyticsEnabled = document.getElementById('analyticsEnabled');
|
||
if (analyticsEnabled) analyticsEnabled.checked = settings.privacy?.analyticsEnabled !== false;
|
||
var obfuscationEl = document.getElementById('emailObfuscation');
|
||
if (obfuscationEl) obfuscationEl.checked = !!(settings.privacy && settings.privacy.emailObfuscation);
|
||
var rememberThemeEl = document.getElementById('rememberPublicTheme');
|
||
if (rememberThemeEl) rememberThemeEl.checked = !(settings.privacy && settings.privacy.rememberPublicTheme === false);
|
||
|
||
updateColorPreview();
|
||
updateBtnStylePreview();
|
||
|
||
// Email
|
||
var email = settings.email || {};
|
||
var methodSelect = document.getElementById('settingsEmailMethod');
|
||
if (methodSelect) methodSelect.value = email.method || 'inactive';
|
||
document.getElementById('settingsRecipientEmail').value = email.recipientEmail || '';
|
||
document.getElementById('settingsBccEmail').value = email.bccEmail || '';
|
||
document.getElementById('settingsFromEmail').value = email.fromEmail || '';
|
||
document.getElementById('settingsFromName').value = email.fromName || '';
|
||
document.getElementById('settingsSmtpHost').value = email.smtpHost || '';
|
||
document.getElementById('settingsSmtpPort').value = email.smtpPort || 587;
|
||
document.getElementById('settingsSmtpUsername').value = email.smtpUsername || '';
|
||
document.getElementById('settingsSmtpPassword').value = '';
|
||
document.getElementById('settingsSmtpEncryption').value = email.smtpEncryption || 'tls';
|
||
// Show/hide SMTP fields based on method
|
||
toggleSmtpFields(email.method || 'inactive');
|
||
// Mark password field if saved
|
||
if (email.smtpPassword) {
|
||
document.getElementById('settingsSmtpPassword').placeholder = '••••••••';
|
||
}
|
||
}
|
||
|
||
function updateLogoPreview(path) {
|
||
var img = document.getElementById('logoPreviewImg');
|
||
if (path) {
|
||
img.src = path;
|
||
img.style.display = 'block';
|
||
} else {
|
||
img.removeAttribute('src');
|
||
img.style.display = 'none';
|
||
}
|
||
updateLogoDisplayVisibility();
|
||
}
|
||
|
||
function updateFaviconPreview(path) {
|
||
var img = document.getElementById('faviconPreviewImg');
|
||
if (path) {
|
||
img.src = path;
|
||
img.style.display = 'block';
|
||
} else {
|
||
img.removeAttribute('src');
|
||
img.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function updateFaviconPngPreview(path) {
|
||
var img = document.getElementById('faviconPngPreviewImg');
|
||
if (!img) return;
|
||
if (path) {
|
||
img.src = path;
|
||
img.style.display = 'block';
|
||
} else {
|
||
img.removeAttribute('src');
|
||
img.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function updateAdminLogoPreview(path) {
|
||
var img = document.getElementById('adminLogoPreviewImg');
|
||
if (!img) return;
|
||
if (path) {
|
||
img.src = path;
|
||
img.style.display = 'block';
|
||
} else {
|
||
img.removeAttribute('src');
|
||
img.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function updateLogoDarkPreview(path) {
|
||
var img = document.getElementById('logoDarkPreviewImg');
|
||
if (!img) return;
|
||
if (path) {
|
||
img.src = path;
|
||
img.style.display = 'block';
|
||
} else {
|
||
img.removeAttribute('src');
|
||
img.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function updateDefaultOgPreview(path) {
|
||
var img = document.getElementById('defaultOgPreviewImg');
|
||
if (!img) return;
|
||
if (path) {
|
||
img.src = path;
|
||
img.style.display = 'block';
|
||
} else {
|
||
img.removeAttribute('src');
|
||
img.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 3-way logo display selector is only relevant when no logo is set
|
||
function updateLogoDisplayVisibility() {
|
||
var logoVal = document.getElementById('settingsLogo').value.trim();
|
||
var group = document.getElementById('logoDisplayGroup');
|
||
if (group) group.style.display = logoVal ? 'none' : '';
|
||
}
|
||
|
||
function normalizeVisualImageLayout(layout) {
|
||
return layout === 'split' ? 'left' : (['none', 'background', 'left', 'right'].includes(layout) ? layout : 'none');
|
||
}
|
||
|
||
function updateVisualOverlayVisibility(layoutSelectId, groupId) {
|
||
var select = document.getElementById(layoutSelectId);
|
||
var group = document.getElementById(groupId);
|
||
if (!select || !group) return;
|
||
group.hidden = normalizeVisualImageLayout(select.value) !== 'background';
|
||
}
|
||
|
||
function setOverlayOpacity(inputId, valueId, value, fallback) {
|
||
var input = document.getElementById(inputId);
|
||
var valueEl = document.getElementById(valueId);
|
||
if (!input) return;
|
||
var numeric = Number.isFinite(Number(value)) ? Number(value) : fallback;
|
||
numeric = Math.max(0, Math.min(100, Math.round(numeric)));
|
||
input.value = numeric;
|
||
if (valueEl) valueEl.textContent = numeric + '%';
|
||
}
|
||
|
||
function syncOverlayOpacity(inputId, valueId) {
|
||
var input = document.getElementById(inputId);
|
||
if (!input) return;
|
||
setOverlayOpacity(inputId, valueId, input.value, Number(input.value) || 0);
|
||
}
|
||
|
||
function updateLoginBoxColorVisibility() {
|
||
var select = document.getElementById('loginBoxStyle');
|
||
var group = document.getElementById('loginBoxColorGroup');
|
||
if (!select || !group) return;
|
||
group.hidden = select.value !== 'card';
|
||
}
|
||
|
||
// ============================================================
|
||
// THEME COLORS — auto-derivation, auto-badge, live preview
|
||
// ============================================================
|
||
|
||
// Defaults — kept in sync with server-side ($defaults in api.php load-settings)
|
||
var THEME_DEFAULTS = {
|
||
adminTheme: 'light',
|
||
preset: 'soft',
|
||
density: 'auto',
|
||
font: 'system',
|
||
fontSize: 'default',
|
||
primaryColor: '#3858e9',
|
||
accentColor: '',
|
||
sidebarBg: '',
|
||
darkPrimaryColor: '',
|
||
darkAccentColor: '',
|
||
darkSidebarBg: '',
|
||
dangerColor: '',
|
||
warningColor: '',
|
||
successColor: '',
|
||
buttonGlow: true,
|
||
buttonRadius: 6
|
||
};
|
||
// Default semantic colours (match css/raiju-admin-tokens.css); success follows the base colour
|
||
var SEMANTIC_DEFAULTS = { dangerColor: '#dc2626', warningColor: '#d97706' };
|
||
|
||
var BRANDING_DEFAULTS = {
|
||
favicon: <?php echo json_encode($_defaultFavicon); ?>,
|
||
favicon_png: '',
|
||
logo: '',
|
||
logoDark: '',
|
||
adminLogo: '',
|
||
name: <?php echo json_encode(defined('SITE_NAME') ? SITE_NAME : 'CMS'); ?>,
|
||
showBranding: true,
|
||
logoDisplay: 'both',
|
||
logoSize: 'medium',
|
||
defaultOgImage: ''
|
||
};
|
||
|
||
var THEME_PRESETS = ['soft', 'classic', 'paper', 'studio', 'console', 'pop'];
|
||
var THEME_DENSITIES = ['auto', 'compact', 'comfortable'];
|
||
var THEME_FONTS = ['system', 'inter', 'plex', 'atkinson', 'public', 'geist-mono'];
|
||
var THEME_FONT_SIZES = ['small', 'default', 'large'];
|
||
// Retired fonts map to their closest successor
|
||
var THEME_FONT_ALIASES = { geist: 'plex' };
|
||
// Each preset brings its own defaults: the matching face, button radius
|
||
// and glow, for Console the orange base colour and for Pop its four-colour palette. Picking a card applies
|
||
// them; every field stays free to change afterwards. The colour a user had
|
||
// before Console is remembered and comes back when they leave Console.
|
||
var THEME_PRESET_DEFAULTS = {
|
||
soft: { font: 'inter', buttonRadius: 6, buttonGlow: true },
|
||
classic: { font: 'public', buttonRadius: 4, buttonGlow: false },
|
||
paper: { font: 'atkinson', buttonRadius: 6, buttonGlow: false },
|
||
studio: { font: 'plex', buttonRadius: 6, buttonGlow: false },
|
||
console: { font: 'geist-mono', buttonRadius: 0, buttonGlow: false, colors: { primaryColor: '#ff9214', dangerColor: '#fa7000', warningColor: '#ffc400', successColor: '#2ecc71' } },
|
||
pop: { font: 'atkinson', buttonRadius: 24, buttonGlow: true, colors: { primaryColor: '#3d8ee9', dangerColor: '#f0625d', warningColor: '#ef9524', successColor: '#27b39a' } }
|
||
};
|
||
var presetColorBackup = {};
|
||
var PRESET_COLOR_KEYS = ['primaryColor', 'dangerColor', 'warningColor', 'successColor'];
|
||
var HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||
|
||
// Display values for "auto" sidebar fields (the stylesheet derives the real
|
||
// colour in OKLCH; this sRGB mix is only what the colour input shows)
|
||
function deriveSidebarLight(primary) {
|
||
return mixColors(primary, '#ffffff', 0.10);
|
||
}
|
||
function deriveSidebarDark(primary) {
|
||
return mixColors(primary, '#0b0d12', 0.10);
|
||
}
|
||
|
||
// Mix two hex colors (sRGB approximation of CSS color-mix(in srgb, a X%, b))
|
||
function mixColors(a, b, ratio) {
|
||
a = a.replace('#', '');
|
||
b = b.replace('#', '');
|
||
var ar = parseInt(a.substring(0, 2), 16);
|
||
var ag = parseInt(a.substring(2, 4), 16);
|
||
var ab = parseInt(a.substring(4, 6), 16);
|
||
var br = parseInt(b.substring(0, 2), 16);
|
||
var bg = parseInt(b.substring(2, 4), 16);
|
||
var bb = parseInt(b.substring(4, 6), 16);
|
||
var r = Math.round(ar * ratio + br * (1 - ratio));
|
||
var g = Math.round(ag * ratio + bg * (1 - ratio));
|
||
var bl = Math.round(ab * ratio + bb * (1 - ratio));
|
||
return '#' + [r, g, bl].map(function(c) { return c.toString(16).padStart(2, '0'); }).join('');
|
||
}
|
||
|
||
function normalizeHexColor(value) {
|
||
return String(value || '').trim().toLowerCase();
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
hex = normalizeHexColor(hex).replace('#', '');
|
||
return [
|
||
parseInt(hex.substring(0, 2), 16),
|
||
parseInt(hex.substring(2, 4), 16),
|
||
parseInt(hex.substring(4, 6), 16)
|
||
];
|
||
}
|
||
|
||
function relativeLuminance(hex) {
|
||
return hexToRgb(hex).map(function(channel) {
|
||
var value = channel / 255;
|
||
return value <= 0.03928
|
||
? value / 12.92
|
||
: Math.pow((value + 0.055) / 1.055, 2.4);
|
||
}).reduce(function(total, channel, index) {
|
||
return total + channel * [0.2126, 0.7152, 0.0722][index];
|
||
}, 0);
|
||
}
|
||
|
||
function contrastRatio(a, b) {
|
||
var l1 = relativeLuminance(a);
|
||
var l2 = relativeLuminance(b);
|
||
var lighter = Math.max(l1, l2);
|
||
var darker = Math.min(l1, l2);
|
||
return (lighter + 0.05) / (darker + 0.05);
|
||
}
|
||
|
||
// Contrast of a surface colour against whichever text the stylesheet
|
||
// will put on it (white below ~0.68 lightness, near-black above)
|
||
function bestTextContrast(hex) {
|
||
return Math.max(contrastRatio(hex, '#ffffff'), contrastRatio(hex, '#111111'));
|
||
}
|
||
|
||
function adjustColorForContrast(hex, background, minimumRatio, direction) {
|
||
hex = normalizeHexColor(hex);
|
||
if (contrastRatio(hex, background) >= minimumRatio) return hex;
|
||
var target = direction === 'lighter' ? '#ffffff' : '#000000';
|
||
for (var step = 1; step <= 20; step++) {
|
||
var candidate = mixColors(hex, target, 1 - (step * 0.05));
|
||
if (contrastRatio(candidate, background) >= minimumRatio) {
|
||
return candidate;
|
||
}
|
||
}
|
||
return target;
|
||
}
|
||
|
||
// Keep the base colour readable as button text background; the dark
|
||
// variant is lifted by the stylesheet itself (max(l, 0.72) in OKLCH).
|
||
function sanitizeThemeContrast(theme) {
|
||
var result = Object.assign({}, theme);
|
||
var warnings = [];
|
||
var minReadable = 3.0;
|
||
|
||
function enforce(key, background, direction, labelKey) {
|
||
if (!result[key]) return;
|
||
var original = normalizeHexColor(result[key]);
|
||
var adjusted = adjustColorForContrast(original, background, minReadable, direction);
|
||
result[key] = adjusted;
|
||
if (adjusted !== original) {
|
||
warnings.push(t('settings.theme_color_adjusted', {field: t(labelKey), value: adjusted}));
|
||
}
|
||
}
|
||
|
||
// Base and accent: the stylesheet chooses white or dark text on them,
|
||
// so they only need to be readable with one of the two
|
||
function enforceAgainstText(key, labelKey) {
|
||
if (!result[key] || !HEX_RE.test(result[key])) return;
|
||
var hex = normalizeHexColor(result[key]);
|
||
if (bestTextContrast(hex) >= minReadable) { result[key] = hex; return; }
|
||
enforce(key, '#ffffff', 'darker', labelKey);
|
||
}
|
||
enforceAgainstText('primaryColor', 'settings.base_color');
|
||
enforceAgainstText('accentColor', 'settings.accent_color');
|
||
enforceAgainstText('dangerColor', 'settings.danger_color');
|
||
enforceAgainstText('warningColor', 'settings.warning_color');
|
||
enforceAgainstText('successColor', 'settings.success_color');
|
||
enforce('sidebarBg', '#1a1a1a', 'lighter', 'settings.sidebar_bg_light');
|
||
enforce('darkSidebarBg', '#e5e5e5', 'darker', 'settings.sidebar_bg_dark');
|
||
|
||
return { theme: result, warnings: warnings };
|
||
}
|
||
|
||
function updateThemeContrastFeedback() {
|
||
var minReadable = 3.0;
|
||
var values = {
|
||
primaryColor: document.getElementById('settingsPrimaryColor').value,
|
||
accentColor: document.getElementById('settingsAccentColor').value,
|
||
sidebarBg: document.getElementById('settingsSidebarBg').value,
|
||
darkSidebarBg: document.getElementById('settingsDarkSidebarBg').value,
|
||
dangerColor: document.getElementById('settingsDangerColor').value,
|
||
warningColor: document.getElementById('settingsWarningColor').value,
|
||
successColor: document.getElementById('settingsSuccessColor').value
|
||
};
|
||
var checks = {
|
||
primaryColor: {background: null, textContrast: true},
|
||
accentColor: {background: null, textContrast: true},
|
||
sidebarBg: {background: '#1a1a1a', textContrast: true},
|
||
darkSidebarBg: {background: '#e5e5e5', textContrast: true},
|
||
dangerColor: {background: null, textContrast: true},
|
||
warningColor: {background: null, textContrast: true},
|
||
successColor: {background: null, textContrast: true}
|
||
};
|
||
|
||
Object.keys(checks).forEach(function(key) {
|
||
var el = document.querySelector('.theme-contrast-feedback[data-contrast-for="' + key + '"]');
|
||
if (!el) return;
|
||
var value = values[key];
|
||
if (!HEX_RE.test(value)) {
|
||
el.dataset.state = 'error';
|
||
el.textContent = t('settings.contrast_invalid');
|
||
return;
|
||
}
|
||
var ratio = checks[key].background ? contrastRatio(value, checks[key].background) : bestTextContrast(value);
|
||
var state = ratio < minReadable ? 'error' : 'ok';
|
||
var keyLabel = checks[key].textContrast
|
||
? (ratio < minReadable ? 'settings.text_contrast_error' : 'settings.text_contrast_ok')
|
||
: (ratio < minReadable ? 'settings.contrast_error' : 'settings.contrast_ok');
|
||
el.dataset.state = state;
|
||
el.textContent = t(keyLabel, {
|
||
ratio: ratio.toFixed(1)
|
||
});
|
||
});
|
||
}
|
||
|
||
// Optional colour fields: empty in JSON means "auto" (derived, badge shown)
|
||
var AUTO_STATE = {
|
||
accentColor: true,
|
||
sidebarBg: true,
|
||
darkSidebarBg: true,
|
||
dangerColor: true,
|
||
warningColor: true,
|
||
successColor: true
|
||
};
|
||
|
||
function setAutoField(name, storedValue, derivedValue) {
|
||
var hex = document.getElementById('settings' + capitalize(name));
|
||
var picker = document.getElementById('settings' + capitalize(name) + 'Picker');
|
||
var badge = document.querySelector('.auto-badge[data-auto-for="' + name + '"]');
|
||
var isAuto = !storedValue;
|
||
AUTO_STATE[name] = isAuto;
|
||
var displayValue = isAuto ? derivedValue : storedValue;
|
||
if (hex) hex.value = displayValue;
|
||
if (picker) picker.value = displayValue;
|
||
if (badge) badge.hidden = !isAuto;
|
||
}
|
||
|
||
function capitalize(s) {
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
|
||
function pickOne(value, allowed, fallback) {
|
||
return allowed.indexOf(value) !== -1 ? value : fallback;
|
||
}
|
||
|
||
// Fill the Design form from settings.theme
|
||
function populateThemeForm(theme) {
|
||
var mode = pickOne(theme.adminTheme, ['light', 'dark', 'system'], 'light');
|
||
document.getElementById('settingsAdminTheme').value = mode;
|
||
document.querySelectorAll('.theme-option').forEach(function(btn) {
|
||
btn.classList.toggle('selected', btn.dataset.theme === mode);
|
||
});
|
||
|
||
var preset = pickOne(theme.preset, THEME_PRESETS, 'soft');
|
||
document.getElementById('settingsThemePreset').value = preset;
|
||
document.querySelectorAll('.preset-card').forEach(function(card) {
|
||
var on = card.dataset.preset === preset;
|
||
card.classList.toggle('selected', on);
|
||
card.setAttribute('aria-checked', on ? 'true' : 'false');
|
||
});
|
||
|
||
var primary = HEX_RE.test(theme.primaryColor || '') ? normalizeHexColor(theme.primaryColor) : THEME_DEFAULTS.primaryColor;
|
||
document.getElementById('settingsPrimaryColor').value = primary;
|
||
document.getElementById('settingsPrimaryColorPicker').value = primary;
|
||
|
||
// An accent equal to the base colour is the old default, not a deliberate override
|
||
var accent = HEX_RE.test(theme.accentColor || '') ? normalizeHexColor(theme.accentColor) : '';
|
||
if (accent === primary) accent = '';
|
||
setAutoField('accentColor', accent, primary);
|
||
setAutoField('sidebarBg', theme.sidebarBg || '', deriveSidebarLight(primary));
|
||
setAutoField('darkSidebarBg', theme.darkSidebarBg || '', deriveSidebarDark(primary));
|
||
setAutoField('dangerColor', theme.dangerColor || '', SEMANTIC_DEFAULTS.dangerColor);
|
||
setAutoField('warningColor', theme.warningColor || '', SEMANTIC_DEFAULTS.warningColor);
|
||
setAutoField('successColor', theme.successColor || '', primary);
|
||
|
||
var fontSelect = document.getElementById('settingsFont');
|
||
if (fontSelect) {
|
||
fontSelect.value = pickOne(THEME_FONT_ALIASES[theme.font] || theme.font, THEME_FONTS, 'system');
|
||
window.rjSelectSync?.(fontSelect);
|
||
}
|
||
var density = pickOne(theme.density, THEME_DENSITIES, 'auto');
|
||
var densityRadio = document.querySelector('input[name="settingsDensity"][value="' + density + '"]');
|
||
if (densityRadio) densityRadio.checked = true;
|
||
var fontSize = pickOne(theme.fontSize, THEME_FONT_SIZES, 'default');
|
||
var fontSizeRadio = document.querySelector('input[name="settingsFontSize"][value="' + fontSize + '"]');
|
||
if (fontSizeRadio) fontSizeRadio.checked = true;
|
||
|
||
updateThemeContrastFeedback();
|
||
}
|
||
|
||
// Read current theme state from the form (returns the same shape as settings.theme)
|
||
function readThemeFormState() {
|
||
var densityRadio = document.querySelector('input[name="settingsDensity"]:checked');
|
||
var fontSizeRadio = document.querySelector('input[name="settingsFontSize"]:checked');
|
||
return {
|
||
adminTheme: document.getElementById('settingsAdminTheme').value,
|
||
preset: pickOne(document.getElementById('settingsThemePreset').value, THEME_PRESETS, 'soft'),
|
||
density: pickOne(densityRadio ? densityRadio.value : 'auto', THEME_DENSITIES, 'auto'),
|
||
font: pickOne(document.getElementById('settingsFont')?.value, THEME_FONTS, 'system'),
|
||
fontSize: pickOne(fontSizeRadio ? fontSizeRadio.value : 'default', THEME_FONT_SIZES, 'default'),
|
||
primaryColor: normalizeHexColor(document.getElementById('settingsPrimaryColor').value),
|
||
accentColor: AUTO_STATE.accentColor ? '' : normalizeHexColor(document.getElementById('settingsAccentColor').value),
|
||
sidebarBg: AUTO_STATE.sidebarBg ? '' : normalizeHexColor(document.getElementById('settingsSidebarBg').value),
|
||
darkPrimaryColor: '',
|
||
darkAccentColor: '',
|
||
darkSidebarBg: AUTO_STATE.darkSidebarBg ? '' : normalizeHexColor(document.getElementById('settingsDarkSidebarBg').value),
|
||
dangerColor: AUTO_STATE.dangerColor ? '' : normalizeHexColor(document.getElementById('settingsDangerColor').value),
|
||
warningColor: AUTO_STATE.warningColor ? '' : normalizeHexColor(document.getElementById('settingsWarningColor').value),
|
||
successColor: AUTO_STATE.successColor ? '' : normalizeHexColor(document.getElementById('settingsSuccessColor').value),
|
||
buttonGlow: document.getElementById('settingsButtonGlow').checked,
|
||
buttonRadius: parseInt(document.getElementById('settingsButtonRadius').value, 10)
|
||
};
|
||
}
|
||
|
||
function syncThemeFormColors(theme) {
|
||
function setPair(id, value) {
|
||
var input = document.getElementById(id);
|
||
var picker = document.getElementById(id + 'Picker');
|
||
if (input && value) input.value = value;
|
||
if (picker && value) picker.value = value;
|
||
}
|
||
|
||
setPair('settingsPrimaryColor', theme.primaryColor);
|
||
if (!AUTO_STATE.accentColor) setPair('settingsAccentColor', theme.accentColor);
|
||
if (!AUTO_STATE.sidebarBg) setPair('settingsSidebarBg', theme.sidebarBg);
|
||
if (!AUTO_STATE.darkSidebarBg) setPair('settingsDarkSidebarBg', theme.darkSidebarBg);
|
||
if (!AUTO_STATE.dangerColor) setPair('settingsDangerColor', theme.dangerColor);
|
||
if (!AUTO_STATE.warningColor) setPair('settingsWarningColor', theme.warningColor);
|
||
if (!AUTO_STATE.successColor) setPair('settingsSuccessColor', theme.successColor);
|
||
refreshAutoDisplays();
|
||
}
|
||
|
||
// Re-derive the displayed value of auto fields when the base colour changes
|
||
function refreshAutoDisplays() {
|
||
var primary = document.getElementById('settingsPrimaryColor').value;
|
||
if (!HEX_RE.test(primary)) return;
|
||
if (AUTO_STATE.accentColor) {
|
||
document.getElementById('settingsAccentColor').value = primary;
|
||
document.getElementById('settingsAccentColorPicker').value = primary;
|
||
}
|
||
if (AUTO_STATE.sidebarBg) {
|
||
var v = deriveSidebarLight(primary);
|
||
document.getElementById('settingsSidebarBg').value = v;
|
||
document.getElementById('settingsSidebarBgPicker').value = v;
|
||
}
|
||
if (AUTO_STATE.darkSidebarBg) {
|
||
var sd = deriveSidebarDark(primary);
|
||
document.getElementById('settingsDarkSidebarBg').value = sd;
|
||
document.getElementById('settingsDarkSidebarBgPicker').value = sd;
|
||
}
|
||
if (AUTO_STATE.dangerColor) {
|
||
document.getElementById('settingsDangerColor').value = SEMANTIC_DEFAULTS.dangerColor;
|
||
document.getElementById('settingsDangerColorPicker').value = SEMANTIC_DEFAULTS.dangerColor;
|
||
}
|
||
if (AUTO_STATE.warningColor) {
|
||
document.getElementById('settingsWarningColor').value = SEMANTIC_DEFAULTS.warningColor;
|
||
document.getElementById('settingsWarningColorPicker').value = SEMANTIC_DEFAULTS.warningColor;
|
||
}
|
||
if (AUTO_STATE.successColor) {
|
||
document.getElementById('settingsSuccessColor').value = primary;
|
||
document.getElementById('settingsSuccessColorPicker').value = primary;
|
||
}
|
||
}
|
||
|
||
// Live preview of everything in the form
|
||
function updateColorPreview() {
|
||
refreshAutoDisplays();
|
||
var theme = readThemeFormState();
|
||
applyTheme(theme);
|
||
updateThemeContrastFeedback();
|
||
}
|
||
|
||
// Button preview follows the live tokens, only radius and glow are set here
|
||
function updateBtnStylePreview() {
|
||
var glow = document.getElementById('settingsButtonGlow').checked;
|
||
var radius = parseInt(document.getElementById('settingsButtonRadius').value, 10);
|
||
var primary = document.getElementById('settingsPrimaryColor').value;
|
||
var btnPrimary = document.getElementById('previewBtnPrimary');
|
||
var btnSecondary = document.getElementById('previewBtnSecondary');
|
||
if (btnPrimary) {
|
||
// Same layering as the real .btn-primary: colour below, sheen and shadow only with the glow
|
||
btnPrimary.style.backgroundColor = '';
|
||
btnPrimary.style.backgroundImage = glow ? 'var(--rj-primary-sheen)' : 'none';
|
||
btnPrimary.style.borderRadius = radius + 'px';
|
||
btnPrimary.style.boxShadow = glow && HEX_RE.test(primary)
|
||
? '0 2px 8px rgba(0,0,0,0.15), 0 4px 20px ' + hexToRgba(primary, 0.35)
|
||
: 'none';
|
||
btnPrimary.style.textShadow = glow ? '' : 'none';
|
||
}
|
||
if (btnSecondary) {
|
||
btnSecondary.style.borderRadius = radius + 'px';
|
||
}
|
||
}
|
||
|
||
// The glow switch applies live to the real buttons too (rj-flat-buttons on <html>), not only to the preview
|
||
document.getElementById('settingsButtonGlow').addEventListener('change', updateColorPreview);
|
||
document.getElementById('settingsButtonRadius').addEventListener('input', function() {
|
||
document.getElementById('settingsButtonRadiusValue').textContent = this.value + 'px';
|
||
updateBtnStylePreview();
|
||
});
|
||
|
||
// Theme-aware browser favicon (recolors SVG currentColor on theme change)
|
||
var THEME_FAVICON_COLORS = { light: '#0a0a0a', dark: '#e5e5e5' };
|
||
var faviconSvgCache = null;
|
||
function updateAdminBrowserFavicon(theme) {
|
||
var link = document.querySelector('link[rel="icon"]');
|
||
if (!link) return;
|
||
var href = link.getAttribute('data-original-href') || link.getAttribute('href');
|
||
if (!href || !/\.svg(\?|#|$)/i.test(href)) return;
|
||
if (!link.getAttribute('data-original-href')) link.setAttribute('data-original-href', href);
|
||
function apply() {
|
||
if (!faviconSvgCache) return;
|
||
var color = THEME_FAVICON_COLORS[theme] || THEME_FAVICON_COLORS.light;
|
||
var patched = faviconSvgCache
|
||
.replace(/<svg\b/, '<svg data-theme="' + theme + '"')
|
||
.replace(/currentColor/g, color);
|
||
link.setAttribute('href', 'data:image/svg+xml;utf8,' + encodeURIComponent(patched));
|
||
}
|
||
if (faviconSvgCache === null) {
|
||
fetch(href).then(function(r){ return r.ok ? r.text() : null; }).then(function(svg){
|
||
if (svg) { faviconSvgCache = svg; apply(); }
|
||
}).catch(function(){});
|
||
} else {
|
||
apply();
|
||
}
|
||
}
|
||
// Initial favicon apply
|
||
updateAdminBrowserFavicon(document.documentElement.getAttribute('data-site-theme') || 'light');
|
||
|
||
// Theme selector buttons — instant preview on click
|
||
document.querySelectorAll('.theme-option').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
document.querySelectorAll('.theme-option').forEach(function(b) { b.classList.remove('selected'); });
|
||
this.classList.add('selected');
|
||
var themeValue = this.dataset.theme;
|
||
document.getElementById('settingsAdminTheme').value = themeValue;
|
||
// Instant preview
|
||
var resolved = themeValue;
|
||
if (resolved === 'system') {
|
||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
document.documentElement.setAttribute('data-site-theme', resolved);
|
||
updateAdminBrowserFavicon(resolved);
|
||
updateBtnStylePreview();
|
||
});
|
||
});
|
||
|
||
// Bind a hex/picker pair. `optional` = field can be in auto mode; manual edits exit auto.
|
||
function bindColorPair(name, optional) {
|
||
var cap = capitalize(name);
|
||
var hex = document.getElementById('settings' + cap);
|
||
var picker = document.getElementById('settings' + cap + 'Picker');
|
||
if (!hex || !picker) return;
|
||
|
||
function onPicker() {
|
||
hex.value = this.value;
|
||
if (optional) {
|
||
AUTO_STATE[name] = false;
|
||
var badge = document.querySelector('.auto-badge[data-auto-for="' + name + '"]');
|
||
if (badge) badge.hidden = true;
|
||
}
|
||
updateColorPreview();
|
||
}
|
||
function onHex() {
|
||
if (/^#[0-9a-fA-F]{6}$/.test(this.value)) {
|
||
picker.value = this.value;
|
||
if (optional) {
|
||
AUTO_STATE[name] = false;
|
||
var badge = document.querySelector('.auto-badge[data-auto-for="' + name + '"]');
|
||
if (badge) badge.hidden = true;
|
||
}
|
||
updateColorPreview();
|
||
}
|
||
}
|
||
picker.addEventListener('input', onPicker);
|
||
picker.addEventListener('change', updateThemeContrastFeedback);
|
||
hex.addEventListener('input', onHex);
|
||
hex.addEventListener('blur', function() {
|
||
if (/^#[0-9a-fA-F]{6}$/.test(this.value)) {
|
||
this.value = normalizeHexColor(this.value);
|
||
picker.value = this.value;
|
||
updateColorPreview();
|
||
} else {
|
||
updateThemeContrastFeedback();
|
||
}
|
||
});
|
||
hex.addEventListener('change', updateThemeContrastFeedback);
|
||
}
|
||
|
||
bindColorPair('primaryColor', false);
|
||
bindColorPair('accentColor', true);
|
||
bindColorPair('sidebarBg', true);
|
||
bindColorPair('darkSidebarBg', true);
|
||
bindColorPair('dangerColor', true);
|
||
bindColorPair('warningColor', true);
|
||
bindColorPair('successColor', true);
|
||
|
||
function applyPresetDefaults(preset) {
|
||
var d = THEME_PRESET_DEFAULTS[preset];
|
||
if (!d) return;
|
||
var fontSelect = document.getElementById('settingsFont');
|
||
if (fontSelect && d.font && THEME_FONTS.indexOf(d.font) !== -1) fontSelect.value = d.font;
|
||
var glow = document.getElementById('settingsButtonGlow');
|
||
var radius = document.getElementById('settingsButtonRadius');
|
||
if (glow && typeof d.buttonGlow === 'boolean') glow.checked = d.buttonGlow;
|
||
if (radius && typeof d.buttonRadius === 'number') {
|
||
radius.value = d.buttonRadius;
|
||
var radiusValue = document.getElementById('settingsButtonRadiusValue');
|
||
if (radiusValue) radiusValue.textContent = d.buttonRadius + 'px';
|
||
}
|
||
// Colours: a preset with its own palette sets them and remembers what
|
||
// they replaced; a preset without one hands those values back as long
|
||
// as the user has not changed them in between
|
||
PRESET_COLOR_KEYS.forEach(function(key) {
|
||
var wanted = d.colors ? d.colors[key] : null;
|
||
var current = readColorField(key);
|
||
if (wanted) {
|
||
if (current.value !== wanted && !(key in presetColorBackup)) {
|
||
presetColorBackup[key] = current;
|
||
}
|
||
writeColorField(key, wanted, false);
|
||
} else if (key in presetColorBackup && isPresetColor(key, current.value)) {
|
||
writeColorField(key, presetColorBackup[key].value, presetColorBackup[key].auto);
|
||
delete presetColorBackup[key];
|
||
}
|
||
});
|
||
refreshAutoDisplays();
|
||
updateThemeContrastFeedback();
|
||
updateBtnStylePreview();
|
||
}
|
||
|
||
function isPresetColor(key, hex) {
|
||
return Object.keys(THEME_PRESET_DEFAULTS).some(function(k) { var c = THEME_PRESET_DEFAULTS[k].colors; return c && c[key] === hex; });
|
||
}
|
||
|
||
function readColorField(key) {
|
||
var input = document.getElementById('settings' + capitalize(key));
|
||
return { value: input ? normalizeHexColor(input.value) : '', auto: key in AUTO_STATE ? AUTO_STATE[key] : false };
|
||
}
|
||
|
||
function writeColorField(key, hex, auto) {
|
||
var input = document.getElementById('settings' + capitalize(key));
|
||
var picker = document.getElementById('settings' + capitalize(key) + 'Picker');
|
||
if (input) input.value = hex;
|
||
if (picker) picker.value = hex;
|
||
if (key in AUTO_STATE) {
|
||
AUTO_STATE[key] = !!auto;
|
||
var badge = document.querySelector('.auto-badge[data-auto-for="' + key + '"]');
|
||
if (badge) badge.hidden = !auto;
|
||
}
|
||
}
|
||
|
||
// Preset cards, density and font: instant preview
|
||
document.querySelectorAll('.preset-card[data-preset]').forEach(function(card) {
|
||
card.addEventListener('click', function() {
|
||
document.getElementById('settingsThemePreset').value = card.dataset.preset;
|
||
document.querySelectorAll('.preset-card').forEach(function(c) {
|
||
var on = c === card;
|
||
c.classList.toggle('selected', on);
|
||
c.setAttribute('aria-checked', on ? 'true' : 'false');
|
||
});
|
||
applyPresetDefaults(card.dataset.preset);
|
||
updateColorPreview();
|
||
});
|
||
});
|
||
document.querySelectorAll('input[name="settingsDensity"], input[name="settingsFontSize"]').forEach(function(radio) {
|
||
radio.addEventListener('change', updateColorPreview);
|
||
});
|
||
document.getElementById('settingsFont')?.addEventListener('change', updateColorPreview);
|
||
|
||
function setLoginColorPair(id, value) {
|
||
var normalized = /^#[0-9a-fA-F]{6}$/.test(value || '') ? normalizeHexColor(value) : '';
|
||
var hex = document.getElementById(id);
|
||
var picker = document.getElementById(id + 'Picker');
|
||
if (hex && normalized) hex.value = normalized;
|
||
if (picker && normalized) picker.value = normalized;
|
||
}
|
||
|
||
function bindLoginColorPair(id) {
|
||
var hex = document.getElementById(id);
|
||
var picker = document.getElementById(id + 'Picker');
|
||
if (!hex || !picker) return;
|
||
|
||
picker.addEventListener('input', function() {
|
||
hex.value = this.value;
|
||
});
|
||
hex.addEventListener('input', function() {
|
||
if (/^#[0-9a-fA-F]{6}$/.test(this.value)) {
|
||
picker.value = this.value;
|
||
}
|
||
});
|
||
hex.addEventListener('blur', function() {
|
||
if (/^#[0-9a-fA-F]{6}$/.test(this.value)) {
|
||
this.value = normalizeHexColor(this.value);
|
||
picker.value = this.value;
|
||
}
|
||
});
|
||
}
|
||
|
||
bindLoginColorPair('loginBoxColor');
|
||
bindLoginColorPair('loginBoxTextColor');
|
||
|
||
// Auto-reset buttons — return a field to "auto" (empty stored, derived display)
|
||
document.querySelectorAll('.auto-reset-btn[data-auto-reset]').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var name = btn.dataset.autoReset;
|
||
AUTO_STATE[name] = true;
|
||
var badge = document.querySelector('.auto-badge[data-auto-for="' + name + '"]');
|
||
if (badge) badge.hidden = false;
|
||
updateColorPreview();
|
||
});
|
||
});
|
||
|
||
// Reset all colors button — restores defaults (without saving)
|
||
document.getElementById('resetThemeBtn').addEventListener('click', function() {
|
||
populateThemeForm(Object.assign({}, THEME_DEFAULTS, {
|
||
adminTheme: document.getElementById('settingsAdminTheme').value
|
||
}));
|
||
document.getElementById('settingsButtonGlow').checked = THEME_DEFAULTS.buttonGlow;
|
||
document.getElementById('settingsButtonRadius').value = THEME_DEFAULTS.buttonRadius;
|
||
document.getElementById('settingsButtonRadiusValue').textContent = THEME_DEFAULTS.buttonRadius + 'px';
|
||
updateColorPreview();
|
||
});
|
||
|
||
// Browse logo button — opens the image manager
|
||
document.getElementById('browseLogoBtn').addEventListener('click', function() {
|
||
var input = document.getElementById('settingsLogo');
|
||
NbImageManager.open(function(path) {
|
||
input.value = path;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
// Browse dark-logo button — opens the image manager
|
||
document.getElementById('browseLogoDarkBtn').addEventListener('click', function() {
|
||
var input = document.getElementById('settingsLogoDark');
|
||
NbImageManager.open(function(path) {
|
||
input.value = path;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
// Browse admin-logo button — opens the image manager
|
||
document.getElementById('browseAdminLogoBtn').addEventListener('click', function() {
|
||
var input = document.getElementById('settingsAdminLogo');
|
||
NbImageManager.open(function(path) {
|
||
input.value = path;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
// Browse favicon button — opens the image manager
|
||
document.getElementById('browseFaviconBtn').addEventListener('click', function() {
|
||
var input = document.getElementById('settingsFavicon');
|
||
NbImageManager.open(function(path) {
|
||
input.value = path;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
// Browse PNG favicon button — opens the image manager
|
||
document.getElementById('browseFaviconPngBtn').addEventListener('click', function() {
|
||
var input = document.getElementById('settingsFaviconPng');
|
||
NbImageManager.open(function(path) {
|
||
input.value = path;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
document.getElementById('browseDefaultOgBtn').addEventListener('click', function() {
|
||
var input = document.getElementById('settingsDefaultOgImage');
|
||
NbImageManager.open(function(path) {
|
||
setOgImageInputValue(input, path);
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
var browseLoginImageBtn = document.getElementById('browseLoginImageBtn');
|
||
if (browseLoginImageBtn) browseLoginImageBtn.addEventListener('click', function() {
|
||
var input = document.getElementById('loginImage');
|
||
NbImageManager.open(function(path) {
|
||
input.value = path;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
var browseMaintenanceImageBtn = document.getElementById('browseMaintenanceImageBtn');
|
||
if (browseMaintenanceImageBtn) browseMaintenanceImageBtn.addEventListener('click', function() {
|
||
var input = document.getElementById('maintenanceImage');
|
||
NbImageManager.open(function(path) {
|
||
input.value = path;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, input ? input.value : null, { types: ['image'], type: 'image' });
|
||
});
|
||
|
||
// Manual edits to the logo path also toggle the 3-way selector
|
||
document.getElementById('settingsLogo').addEventListener('input', function() {
|
||
updateLogoPreview(this.value.trim());
|
||
updateClearButton(this);
|
||
});
|
||
document.getElementById('settingsLogoDark').addEventListener('input', function() {
|
||
updateLogoDarkPreview(this.value.trim());
|
||
updateClearButton(this);
|
||
});
|
||
document.getElementById('settingsAdminLogo').addEventListener('input', function() {
|
||
updateAdminLogoPreview(this.value.trim());
|
||
updateClearButton(this);
|
||
});
|
||
document.getElementById('settingsFavicon').addEventListener('input', function() {
|
||
updateFaviconPreview(this.value.trim());
|
||
updateClearButton(this);
|
||
});
|
||
document.getElementById('settingsFaviconPng').addEventListener('input', function() {
|
||
updateFaviconPngPreview(this.value.trim());
|
||
updateClearButton(this);
|
||
});
|
||
document.getElementById('settingsDefaultOgImage').addEventListener('input', function() {
|
||
updateDefaultOgPreview(this.value.trim());
|
||
updateClearButton(this);
|
||
});
|
||
var loginImageInput = document.getElementById('loginImage');
|
||
if (loginImageInput) loginImageInput.addEventListener('input', function() {
|
||
updateClearButton(this);
|
||
});
|
||
var loginImageLayoutSelect = document.getElementById('loginImageLayout');
|
||
if (loginImageLayoutSelect) loginImageLayoutSelect.addEventListener('change', function() {
|
||
updateVisualOverlayVisibility('loginImageLayout', 'loginOverlayColorGroup');
|
||
});
|
||
var loginOverlayOpacityInput = document.getElementById('loginOverlayOpacity');
|
||
if (loginOverlayOpacityInput) loginOverlayOpacityInput.addEventListener('input', function() {
|
||
syncOverlayOpacity('loginOverlayOpacity', 'loginOverlayOpacityValue');
|
||
});
|
||
var loginBoxStyleSelect = document.getElementById('loginBoxStyle');
|
||
if (loginBoxStyleSelect) loginBoxStyleSelect.addEventListener('change', updateLoginBoxColorVisibility);
|
||
var maintenanceImageInput = document.getElementById('maintenanceImage');
|
||
if (maintenanceImageInput) maintenanceImageInput.addEventListener('input', function() {
|
||
updateClearButton(this);
|
||
});
|
||
var maintenanceImageLayoutSelect = document.getElementById('maintenanceImageLayout');
|
||
if (maintenanceImageLayoutSelect) maintenanceImageLayoutSelect.addEventListener('change', function() {
|
||
updateVisualOverlayVisibility('maintenanceImageLayout', 'maintenanceOverlayColorGroup');
|
||
});
|
||
var maintenanceOverlayOpacityInput = document.getElementById('maintenanceOverlayOpacity');
|
||
if (maintenanceOverlayOpacityInput) maintenanceOverlayOpacityInput.addEventListener('input', function() {
|
||
syncOverlayOpacity('maintenanceOverlayOpacity', 'maintenanceOverlayOpacityValue');
|
||
});
|
||
|
||
document.getElementById('resetBrandingBtn').addEventListener('click', function() {
|
||
document.getElementById('settingsFavicon').value = BRANDING_DEFAULTS.favicon;
|
||
document.getElementById('settingsFaviconPng').value = BRANDING_DEFAULTS.favicon_png;
|
||
document.getElementById('settingsLogo').value = BRANDING_DEFAULTS.logo;
|
||
document.getElementById('settingsLogoDark').value = BRANDING_DEFAULTS.logoDark;
|
||
document.getElementById('settingsAdminLogo').value = BRANDING_DEFAULTS.adminLogo;
|
||
document.getElementById('settingsDefaultOgImage').value = BRANDING_DEFAULTS.defaultOgImage;
|
||
document.getElementById('settingsName').value = BRANDING_DEFAULTS.name;
|
||
document.getElementById('settingsShowBranding').checked = BRANDING_DEFAULTS.showBranding;
|
||
var displayRadio = document.querySelector('input[name="settingsLogoDisplay"][value="' + BRANDING_DEFAULTS.logoDisplay + '"]');
|
||
if (displayRadio) displayRadio.checked = true;
|
||
var sizeRadio = document.querySelector('input[name="settingsLogoSize"][value="' + BRANDING_DEFAULTS.logoSize + '"]');
|
||
if (sizeRadio) sizeRadio.checked = true;
|
||
updateFaviconPreview(BRANDING_DEFAULTS.favicon);
|
||
updateFaviconPngPreview(BRANDING_DEFAULTS.favicon_png);
|
||
updateLogoPreview(BRANDING_DEFAULTS.logo);
|
||
updateLogoDarkPreview(BRANDING_DEFAULTS.logoDark);
|
||
updateAdminLogoPreview(BRANDING_DEFAULTS.adminLogo);
|
||
updateDefaultOgPreview(BRANDING_DEFAULTS.defaultOgImage);
|
||
document.querySelectorAll('.input-clear-btn[data-clear-target]').forEach(function(btn) {
|
||
var input = document.getElementById(btn.dataset.clearTarget);
|
||
if (input) updateClearButton(input);
|
||
});
|
||
});
|
||
|
||
// Generic clear-X handler for image-path inputs
|
||
function updateClearButton(input) {
|
||
var btn = document.querySelector('.input-clear-btn[data-clear-target="' + input.id + '"]');
|
||
if (btn) btn.hidden = !input.value.trim();
|
||
}
|
||
document.querySelectorAll('.input-clear-btn[data-clear-target]').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var input = document.getElementById(btn.dataset.clearTarget);
|
||
if (!input) return;
|
||
input.value = '';
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
input.focus();
|
||
});
|
||
// Initialize visibility
|
||
var input = document.getElementById(btn.dataset.clearTarget);
|
||
if (input) btn.hidden = !input.value.trim();
|
||
});
|
||
|
||
// Save branding
|
||
document.getElementById('brandingForm').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('saveBrandingBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
try {
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
settings.favicon = document.getElementById('settingsFavicon').value.trim();
|
||
settings.favicon_png = document.getElementById('settingsFaviconPng').value.trim();
|
||
var displayRadio = document.querySelector('input[name="settingsLogoDisplay"]:checked');
|
||
var sizeRadio = document.querySelector('input[name="settingsLogoSize"]:checked');
|
||
settings.branding = {
|
||
logo: document.getElementById('settingsLogo').value.trim(),
|
||
logoDark: document.getElementById('settingsLogoDark').value.trim(),
|
||
adminLogo: document.getElementById('settingsAdminLogo').value.trim(),
|
||
name: document.getElementById('settingsName').value.trim(),
|
||
showBranding: document.getElementById('settingsShowBranding').checked,
|
||
logoDisplay: displayRadio ? displayRadio.value : 'both',
|
||
logoSize: sizeRadio ? sizeRadio.value : 'medium'
|
||
};
|
||
settings.seo = Object.assign({}, settings.seo || {}, {
|
||
defaultOgImage: document.getElementById('settingsDefaultOgImage').value.trim(),
|
||
noindexSite: document.getElementById('settingsNoindexSite').checked,
|
||
siteUrl: document.getElementById('settingsSiteUrl').value.trim()
|
||
});
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
showToast(t('toast.branding_saved'), 'success');
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_branding');
|
||
}
|
||
});
|
||
|
||
// Save theme
|
||
document.getElementById('themeForm').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('saveThemeBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
var primaryColor = document.getElementById('settingsPrimaryColor').value;
|
||
var accentColor = AUTO_STATE.accentColor ? primaryColor : document.getElementById('settingsAccentColor').value;
|
||
|
||
if (!HEX_RE.test(primaryColor) || !HEX_RE.test(accentColor)) {
|
||
showToast(t('settings.invalid_color'), 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_theme');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
var contrastResult = sanitizeThemeContrast(readThemeFormState());
|
||
settings.theme = contrastResult.theme;
|
||
settings.dashboard = Object.assign({}, settings.dashboard || {}, {
|
||
itemsPerPage: clampDashboardPageSize(document.getElementById('settingsItemsPerPage')?.value),
|
||
iconManagerItemsPerPage: clampDashboardPageSize(document.getElementById('settingsIconItemsPerPage')?.value),
|
||
mediaItemsPerPage: clampMediaPageSize(document.getElementById('settingsMediaItemsPerPage')?.value)
|
||
});
|
||
syncThemeFormColors(settings.theme);
|
||
updateThemeContrastFeedback();
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
applyTheme(currentSettings.theme);
|
||
if (window.NbImageManager) {
|
||
NbImageManager.init({
|
||
itemsPerPage: clampMediaPageSize(currentSettings?.dashboard?.mediaItemsPerPage)
|
||
});
|
||
if (typeof NbImageManager.refresh === 'function') {
|
||
NbImageManager.refresh();
|
||
}
|
||
}
|
||
var serverTheme = sanitizeThemeContrast(currentSettings.theme || {});
|
||
if (serverTheme.warnings.length) {
|
||
syncThemeFormColors(serverTheme.theme);
|
||
updateThemeContrastFeedback();
|
||
showToast(serverTheme.warnings[0], 'warning');
|
||
} else if (contrastResult.warnings.length) {
|
||
updateThemeContrastFeedback();
|
||
showToast(contrastResult.warnings[0], 'warning');
|
||
} else {
|
||
updateThemeContrastFeedback();
|
||
showToast(t('toast.theme_saved'), 'success');
|
||
}
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_theme');
|
||
}
|
||
});
|
||
|
||
// Apply theme live: the stylesheet derives every colour from --rj-base per
|
||
// preset, so the runtime only sets attributes, the base colour and the
|
||
// optional sidebar overrides.
|
||
function applyTheme(theme) {
|
||
var root = document.documentElement;
|
||
var themeValue = theme.adminTheme || 'light';
|
||
if (themeValue === 'system') {
|
||
themeValue = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
root.setAttribute('data-site-theme', themeValue);
|
||
localStorage.setItem('site-admin-theme', theme.adminTheme);
|
||
root.setAttribute('data-theme-preset', pickOne(theme.preset, THEME_PRESETS, 'soft'));
|
||
root.setAttribute('data-density', pickOne(theme.density, THEME_DENSITIES, 'auto'));
|
||
root.setAttribute('data-font', pickOne(THEME_FONT_ALIASES[theme.font] || theme.font, THEME_FONTS, 'system'));
|
||
root.setAttribute('data-font-size', pickOne(theme.fontSize, THEME_FONT_SIZES, 'default'));
|
||
|
||
var base = HEX_RE.test(theme.primaryColor || '') ? normalizeHexColor(theme.primaryColor) : THEME_DEFAULTS.primaryColor;
|
||
var accent = HEX_RE.test(theme.accentColor || '') ? normalizeHexColor(theme.accentColor) : base;
|
||
root.style.setProperty('--rj-base', base);
|
||
root.style.setProperty('--rj-accent-base', accent);
|
||
[['dangerColor', '--rj-danger-base'], ['warningColor', '--rj-warning-base'], ['successColor', '--rj-success-base']].forEach(function(pair) {
|
||
if (HEX_RE.test(theme[pair[0]] || '')) root.style.setProperty(pair[1], normalizeHexColor(theme[pair[0]]));
|
||
else root.style.removeProperty(pair[1]);
|
||
});
|
||
|
||
var css = '';
|
||
if (HEX_RE.test(theme.sidebarBg || '')) {
|
||
css += ':root, [data-site-theme="light"] { --rj-sidebar-bg: ' + theme.sidebarBg + '; --rj-topbar-bg: ' + theme.sidebarBg + '; }';
|
||
}
|
||
if (HEX_RE.test(theme.darkSidebarBg || '')) {
|
||
css += '[data-site-theme="dark"] { --rj-sidebar-bg: ' + theme.darkSidebarBg + '; --rj-topbar-bg: ' + theme.darkSidebarBg + '; }';
|
||
}
|
||
var serverStyle = document.getElementById('rj-theme-server');
|
||
if (serverStyle) serverStyle.disabled = true;
|
||
var runtime = document.getElementById('rj-theme-runtime');
|
||
if (!runtime) {
|
||
runtime = document.createElement('style');
|
||
runtime.id = 'rj-theme-runtime';
|
||
document.head.appendChild(runtime);
|
||
}
|
||
runtime.textContent = css;
|
||
|
||
// Button radius — affects both admin and frontend editor buttons
|
||
if (theme.buttonRadius != null) {
|
||
root.style.setProperty('--editor-btn-radius', theme.buttonRadius + 'px');
|
||
}
|
||
|
||
// Flat button classes (glow disabled)
|
||
root.classList.toggle('editor-flat', theme.buttonGlow === false);
|
||
root.classList.toggle('rj-flat-buttons', theme.buttonGlow === false);
|
||
|
||
updateAdminBrowserFavicon(themeValue);
|
||
updateBtnStylePreview();
|
||
}
|
||
|
||
function adjustColor(hex, amount) {
|
||
hex = hex.replace('#', '');
|
||
var r = Math.max(0, Math.min(255, parseInt(hex.substring(0, 2), 16) + amount));
|
||
var g = Math.max(0, Math.min(255, parseInt(hex.substring(2, 4), 16) + amount));
|
||
var b = Math.max(0, Math.min(255, parseInt(hex.substring(4, 6), 16) + amount));
|
||
return '#' + [r, g, b].map(function(c) { return c.toString(16).padStart(2, '0'); }).join('');
|
||
}
|
||
|
||
function hexToRgba(hex, alpha) {
|
||
hex = hex.replace('#', '');
|
||
var r = parseInt(hex.substring(0, 2), 16);
|
||
var g = parseInt(hex.substring(2, 4), 16);
|
||
var b = parseInt(hex.substring(4, 6), 16);
|
||
return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')';
|
||
}
|
||
|
||
// Apply saved theme immediately on page load (server-rendered)
|
||
applyTheme(<?php echo json_encode($siteSettings['theme'] ?? []); ?>);
|
||
|
||
var aiSettingsForm = document.getElementById('aiSettingsForm');
|
||
|
||
async function saveAiSettingsForm() {
|
||
var btn = document.getElementById('saveAiSettingsBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
var previous = currentAiSettings;
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-ai-settings');
|
||
formData.append('settings', JSON.stringify(collectAiSettingsForm()));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
var configuredTimeout = parseInt(document.getElementById('aiRequestTimeout')?.value || '300', 10);
|
||
var imageTimeoutMs = Math.max(300000, (Number.isFinite(configuredTimeout) ? configuredTimeout : 300) * 1000 + 30000);
|
||
var result = await fetchJsonWithTimeout('api.php', { method: 'POST', body: formData }, imageTimeoutMs);
|
||
if (!result.success) throw new Error(result.message);
|
||
currentAiSettings = result.data.settings;
|
||
aiLiveModels = {}; // the key or provider may have changed: ask the provider again
|
||
populateAiSettings(currentAiSettings);
|
||
updateAiUsage(result.data.usage);
|
||
showToast(t('ai.settings_saved'), 'success');
|
||
maybeOfferAiAgent(previous, currentAiSettings);
|
||
} catch (error) {
|
||
showToast(error.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('ai.save_settings');
|
||
}
|
||
}
|
||
|
||
if (aiSettingsForm) {
|
||
aiSettingsForm.addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
saveAiSettingsForm();
|
||
});
|
||
}
|
||
|
||
var testAiBtn = document.getElementById('testAiBtn');
|
||
if (testAiBtn) {
|
||
testAiBtn.addEventListener('click', async function() {
|
||
testAiBtn.disabled = true;
|
||
testAiBtn.textContent = t('ai.testing');
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'ai-test');
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message);
|
||
showToast(t('ai.connection_ok'), 'success');
|
||
updateAiUsage(result.data.limits);
|
||
} catch (error) {
|
||
showToast(error.message, 'error');
|
||
} finally {
|
||
testAiBtn.disabled = false;
|
||
testAiBtn.textContent = t('ai.test_connection');
|
||
}
|
||
});
|
||
}
|
||
|
||
var aiChatForm = document.getElementById('aiChatForm');
|
||
if (aiChatForm) {
|
||
var aiChatShortcutHint = document.querySelector('.ai-chat-shortcut-hint');
|
||
if (aiChatShortcutHint) {
|
||
var nav = window.navigator || {};
|
||
var platform = (nav.userAgentData && nav.userAgentData.platform ? nav.userAgentData.platform : nav.platform || '').toLowerCase();
|
||
var isMac = platform.indexOf('mac') !== -1 || platform.indexOf('iphone') !== -1 || platform.indexOf('ipad') !== -1;
|
||
aiChatShortcutHint.textContent = isMac ? aiChatShortcutHint.dataset.macText : aiChatShortcutHint.dataset.otherText;
|
||
}
|
||
var aiChatPrompt = document.getElementById('aiChatPrompt');
|
||
if (aiChatPrompt) {
|
||
aiChatPrompt.addEventListener('keydown', function(e) {
|
||
if (e.key !== 'Enter') return;
|
||
e.preventDefault();
|
||
if (e.metaKey || e.ctrlKey) {
|
||
var start = aiChatPrompt.selectionStart || 0;
|
||
var end = aiChatPrompt.selectionEnd || 0;
|
||
var value = aiChatPrompt.value;
|
||
aiChatPrompt.value = value.slice(0, start) + '\n' + value.slice(end);
|
||
aiChatPrompt.selectionStart = aiChatPrompt.selectionEnd = start + 1;
|
||
return;
|
||
}
|
||
if (aiChatPrompt.value.trim()) {
|
||
aiChatForm.requestSubmit();
|
||
}
|
||
});
|
||
}
|
||
aiChatForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var input = document.getElementById('aiChatPrompt');
|
||
var prompt = input.value.trim();
|
||
if (!prompt) return;
|
||
input.value = '';
|
||
aiChatMessages.push({ role: 'user', content: prompt });
|
||
appendAiChat('user', prompt);
|
||
var btn = aiChatForm.querySelector('button[type="submit"]');
|
||
var indicator = document.getElementById('aiChatIndicator');
|
||
btn.disabled = true;
|
||
if (indicator) indicator.hidden = false;
|
||
var thinking = appendAiThinking();
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'ai-chat');
|
||
formData.append('messages', JSON.stringify(aiChatMessages.slice(-10)));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message);
|
||
aiChatMessages.push({ role: 'assistant', content: result.data.text });
|
||
appendAiChat('assistant', result.data.text);
|
||
updateAiUsage(result.data.limits);
|
||
} catch (error) {
|
||
appendAiChat('error', error.message);
|
||
} finally {
|
||
if (thinking && thinking.parentNode) thinking.parentNode.removeChild(thinking);
|
||
btn.disabled = false;
|
||
if (indicator) indicator.hidden = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
var aiTextForm = document.getElementById('aiTextForm');
|
||
if (aiTextForm) {
|
||
aiTextForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var resultBox = document.getElementById('aiTextResult');
|
||
var prompt = document.getElementById('aiTextPrompt').value.trim();
|
||
if (!prompt) return;
|
||
var btn = aiTextForm.querySelector('button[type="submit"]');
|
||
btn.disabled = true;
|
||
resultBox.value = t('ai.generating');
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'ai-generate-text');
|
||
formData.append('prompt', prompt);
|
||
formData.append('maxOutputTokens', document.getElementById('aiTextMaxTokens').value);
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message);
|
||
resultBox.value = result.data.text;
|
||
updateAiUsage(result.data.limits);
|
||
} catch (error) {
|
||
resultBox.value = error.message;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
var aiAuditRunButton = document.getElementById('aiAuditRun');
|
||
if (aiAuditRunButton) {
|
||
aiAuditRunButton.addEventListener('click', runAiContentAudit);
|
||
}
|
||
|
||
async function aiAuditPost(action, payload) {
|
||
var formData = new FormData();
|
||
formData.append('action', action);
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
Object.keys(payload || {}).forEach(function(key) {
|
||
formData.append(key, payload[key]);
|
||
});
|
||
var response = await fetch('api.php', { method: 'POST', body: formData, cache: 'no-store' });
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message || 'Error');
|
||
return result.data;
|
||
}
|
||
|
||
function aiAuditStatusBadge(row) {
|
||
var key = 'ai.audit_status_' + row.descriptionStatus;
|
||
var cls = row.descriptionStatus === 'ok' ? 'ai-audit-badge--ok' : 'ai-audit-badge--warn';
|
||
return '<span class="ai-audit-badge ' + cls + '">' + escapeHtml(t(key)) + ' (' + row.descriptionLength + ')</span>';
|
||
}
|
||
|
||
async function runAiContentAudit() {
|
||
var results = document.getElementById('aiAuditResults');
|
||
if (!results) return;
|
||
aiAuditRunButton.disabled = true;
|
||
results.textContent = t('ai.audit_running');
|
||
try {
|
||
var data = await aiAuditPost('ai-content-audit', {});
|
||
var pages = Array.isArray(data.pages) ? data.pages : [];
|
||
if (!pages.length) {
|
||
results.textContent = t('ai.audit_empty');
|
||
return;
|
||
}
|
||
var html = '<table class="ai-audit-table"><thead><tr>'
|
||
+ '<th>' + escapeHtml(t('ai.audit_col_page')) + '</th>'
|
||
+ '<th>' + escapeHtml(t('ai.audit_col_description')) + '</th>'
|
||
+ '<th>' + escapeHtml(t('ai.audit_col_alt')) + '</th>'
|
||
+ '<th></th>'
|
||
+ '</tr></thead><tbody>';
|
||
pages.forEach(function(row, index) {
|
||
var needsDescription = row.descriptionStatus !== 'ok';
|
||
html += '<tr data-audit-page="' + escapeHtml(row.contentPage) + '">'
|
||
+ '<td><strong>' + escapeHtml(row.title) + '</strong><br><small>' + escapeHtml(row.contentPage) + '</small></td>'
|
||
+ '<td data-audit-cell="description">' + aiAuditStatusBadge(row) + '</td>'
|
||
+ '<td>' + (row.missingAlt > 0
|
||
? '<span class="ai-audit-badge ai-audit-badge--warn">' + escapeHtml(t('ai.audit_alt_missing', { count: row.missingAlt })) + '</span>'
|
||
: '<span class="ai-audit-badge ai-audit-badge--ok">' + escapeHtml(t('ai.audit_status_ok')) + '</span>') + '</td>'
|
||
+ '<td data-audit-cell="actions">' + (needsDescription
|
||
? '<button type="button" class="btn btn-secondary btn-sm" data-audit-suggest="' + escapeHtml(row.contentPage) + '">' + escapeHtml(t('ai.audit_suggest')) + '</button>'
|
||
: '') + '</td>'
|
||
+ '</tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
results.innerHTML = html;
|
||
results.querySelectorAll('[data-audit-suggest]').forEach(function(button) {
|
||
button.addEventListener('click', function() {
|
||
aiAuditSuggestDescription(button.getAttribute('data-audit-suggest'), button);
|
||
});
|
||
});
|
||
} catch (error) {
|
||
results.textContent = error.message;
|
||
} finally {
|
||
aiAuditRunButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function aiAuditSuggestDescription(contentPage, button) {
|
||
var row = document.querySelector('[data-audit-page="' + (window.CSS && CSS.escape ? CSS.escape(contentPage) : contentPage) + '"]');
|
||
if (!row) return;
|
||
var actionsCell = row.querySelector('[data-audit-cell="actions"]');
|
||
button.disabled = true;
|
||
button.textContent = t('ai.generating');
|
||
try {
|
||
var data = await aiAuditPost('ai-content-audit-suggest', { contentPage: contentPage });
|
||
updateAiUsage(data.limits);
|
||
var descriptionCell = row.querySelector('[data-audit-cell="description"]');
|
||
if (descriptionCell) {
|
||
descriptionCell.innerHTML = '<em>' + escapeHtml(data.description) + '</em>';
|
||
}
|
||
actionsCell.innerHTML = '';
|
||
var applyButton = document.createElement('button');
|
||
applyButton.type = 'button';
|
||
applyButton.className = 'btn btn-primary btn-sm';
|
||
applyButton.textContent = t('ai.audit_apply');
|
||
applyButton.addEventListener('click', function() {
|
||
showModal(t('ai.audit_apply'), t('ai.audit_apply_confirm', { page: contentPage }), async function() {
|
||
closeModal();
|
||
applyButton.disabled = true;
|
||
try {
|
||
await aiAuditPost('ai-content-audit-apply', {
|
||
contentPage: contentPage,
|
||
description: data.description,
|
||
confirmed: '1'
|
||
});
|
||
actionsCell.innerHTML = '<span class="ai-audit-badge ai-audit-badge--ok">' + escapeHtml(t('ai.audit_applied')) + '</span>';
|
||
} catch (error) {
|
||
applyButton.disabled = false;
|
||
showToast(error.message, 'error');
|
||
}
|
||
}, {
|
||
confirmText: t('ai.audit_apply'),
|
||
confirmClass: 'btn btn-primary'
|
||
});
|
||
});
|
||
actionsCell.appendChild(applyButton);
|
||
} catch (error) {
|
||
button.disabled = false;
|
||
button.textContent = t('ai.audit_suggest');
|
||
showToast(error.message, 'error');
|
||
}
|
||
}
|
||
|
||
var aiImageForm = document.getElementById('aiImageForm');
|
||
var aiImageBusy = false;
|
||
var AI_IMAGE_REFERENCE_LIMIT = 16;
|
||
var aiImageReferences = [];
|
||
var aiImageHistoryOffset = 0;
|
||
var AI_IMAGE_HISTORY_LIMIT = 12;
|
||
var aiImageJobPollTimer = null;
|
||
var aiImageKnownFinishedJobs = new Set();
|
||
var aiImageRunningJobs = new Set();
|
||
var AI_IMAGE_JOB_NOTICE_STORAGE_KEY = 'raiju.aiImageJobNotices.v1';
|
||
var AI_IMAGE_JOB_NOTICE_LIMIT = 80;
|
||
|
||
function aiImageJobNoticeKey(job) {
|
||
if (!job || !job.id) return '';
|
||
return [job.id, job.status || '', job.finishedAt || job.updatedAt || ''].join(':');
|
||
}
|
||
|
||
function readAiImageJobNotices() {
|
||
try {
|
||
var raw = window.localStorage ? window.localStorage.getItem(AI_IMAGE_JOB_NOTICE_STORAGE_KEY) : '';
|
||
var notices = raw ? JSON.parse(raw) : {};
|
||
return notices && typeof notices === 'object' ? notices : {};
|
||
} catch (error) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeAiImageJobNotices(notices) {
|
||
if (!window.localStorage) return;
|
||
try {
|
||
var entries = Object.entries(notices || {})
|
||
.sort(function(a, b) { return Number(b[1] || 0) - Number(a[1] || 0); })
|
||
.slice(0, AI_IMAGE_JOB_NOTICE_LIMIT);
|
||
window.localStorage.setItem(AI_IMAGE_JOB_NOTICE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
|
||
} catch (error) {
|
||
// Notification history is only a UI convenience.
|
||
}
|
||
}
|
||
|
||
function aiImageJobNoticeWasShown(job) {
|
||
var key = aiImageJobNoticeKey(job);
|
||
if (!key) return false;
|
||
return !!readAiImageJobNotices()[key];
|
||
}
|
||
|
||
function markAiImageJobNoticeShown(job) {
|
||
var key = aiImageJobNoticeKey(job);
|
||
if (!key) return;
|
||
var notices = readAiImageJobNotices();
|
||
notices[key] = Date.now();
|
||
writeAiImageJobNotices(notices);
|
||
}
|
||
|
||
function setAiImageBusy(isBusy, activeButton, loadingTextKey) {
|
||
aiImageBusy = isBusy;
|
||
var buttons = [
|
||
document.querySelector('#aiImageForm button[type="submit"]'),
|
||
document.getElementById('aiImproveImagePrompt')
|
||
];
|
||
buttons.forEach(function(button) {
|
||
if (!button) return;
|
||
button.disabled = isBusy || !dashboardAiImageUsable;
|
||
if (button === activeButton) {
|
||
if (isBusy) {
|
||
button.dataset.originalHtml = button.dataset.originalHtml || button.innerHTML;
|
||
button.classList.add('is-loading');
|
||
button.setAttribute('aria-busy', 'true');
|
||
button.innerHTML = '<span class="btn-spinner" aria-hidden="true"></span><span>' + escapeHtml(t(loadingTextKey || 'ai.generating')) + '</span>';
|
||
} else {
|
||
button.classList.remove('is-loading');
|
||
button.setAttribute('aria-busy', 'false');
|
||
if (button.dataset.originalHtml) {
|
||
button.innerHTML = button.dataset.originalHtml;
|
||
delete button.dataset.originalHtml;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderAiImageResult(result, target) {
|
||
if (!target || !result) return;
|
||
var paths = Array.isArray(result.paths) ? result.paths : [result.path];
|
||
paths = paths.filter(Boolean);
|
||
if (!paths.length) {
|
||
target.innerHTML = '<div class="ai-image-message ai-image-message--error" role="alert"><strong>Error</strong><span>' + escapeHtml(t('ai.image_history_error')) + '</span></div>';
|
||
return;
|
||
}
|
||
target.innerHTML = '<div class="ai-image-gallery">' + paths.map(function(path) {
|
||
var filename = (path || '').split('/').pop();
|
||
return '<figure class="ai-image-figure"><button type="button" class="ai-image-preview-btn" data-ai-preview="' + escapeHtml(path) + '" data-ai-preview-name="' + escapeHtml(filename) + '"><img src="' + escapeHtml(path) + '" alt=""></button><figcaption><button type="button" class="btn btn-secondary btn-sm" onclick="switchTab("media")">' + escapeHtml(t('nav.media_library')) + '</button></figcaption></figure>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
function setAiImageJobMessage(target, job, messageKey) {
|
||
if (!target) return;
|
||
var prompt = String((job && job.prompt) || '').trim();
|
||
target.innerHTML = '<div class="ai-image-message" role="status">'
|
||
+ '<strong>' + escapeHtml(t(messageKey || 'ai.image_job_queued')) + '</strong>'
|
||
+ (prompt ? '<span class="ai-image-message__prompt">' + escapeHtml(prompt) + '</span>' : '')
|
||
+ '</div>';
|
||
}
|
||
|
||
function setAiImageJobsChecking(isChecking) {
|
||
var button = document.getElementById('aiImageJobsCheck');
|
||
if (!button) return;
|
||
button.disabled = isChecking || !dashboardAiImageUsable;
|
||
button.classList.toggle('is-loading', !!isChecking);
|
||
button.setAttribute('aria-busy', isChecking ? 'true' : 'false');
|
||
}
|
||
|
||
function formatAiImageJobTime(value) {
|
||
if (!value) return '';
|
||
var date = new Date(value);
|
||
return isNaN(date.getTime()) ? '' : rjFormatDate(date, 'datetime');
|
||
}
|
||
|
||
function updateAiImageJobsPanel(jobs, isChecking) {
|
||
var panel = document.getElementById('aiImageJobsPanel');
|
||
var status = document.getElementById('aiImageJobsStatus');
|
||
var meta = document.getElementById('aiImageJobsMeta');
|
||
if (!panel || !status) return;
|
||
panel.hidden = !dashboardAiImageUsable;
|
||
setAiImageJobsChecking(!!isChecking);
|
||
jobs = Array.isArray(jobs) ? jobs : [];
|
||
var openJobs = jobs.filter(function(job) {
|
||
return job && (job.status === 'queued' || job.status === 'running');
|
||
});
|
||
var spinner = document.getElementById('aiImageJobsSpinner');
|
||
if (spinner) spinner.hidden = !openJobs.length;
|
||
panel.setAttribute('aria-busy', openJobs.length ? 'true' : 'false');
|
||
var latest = jobs[0] || null;
|
||
if (openJobs.length) {
|
||
status.textContent = t('ai.image_jobs_open', { count: openJobs.length });
|
||
if (meta) {
|
||
meta.hidden = false;
|
||
meta.textContent = openJobs[0].prompt || '';
|
||
}
|
||
return;
|
||
}
|
||
if (latest && latest.status === 'success') {
|
||
status.textContent = t('ai.image_jobs_finished');
|
||
if (meta) {
|
||
meta.hidden = false;
|
||
meta.textContent = formatAiImageJobTime(latest.finishedAt || latest.updatedAt);
|
||
}
|
||
return;
|
||
}
|
||
if (latest && latest.status === 'error') {
|
||
status.textContent = t('ai.image_jobs_failed');
|
||
if (meta) {
|
||
meta.hidden = false;
|
||
meta.textContent = latest.error || formatAiImageJobTime(latest.finishedAt || latest.updatedAt);
|
||
}
|
||
return;
|
||
}
|
||
status.textContent = t('ai.image_jobs_idle');
|
||
if (meta) {
|
||
meta.hidden = true;
|
||
meta.textContent = '';
|
||
}
|
||
}
|
||
|
||
async function runAiImageJob(job) {
|
||
if (!job || !job.id || aiImageRunningJobs.has(job.id)) return;
|
||
aiImageRunningJobs.add(job.id);
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'ai-image-job-run');
|
||
formData.append('job_id', job.id);
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
await fetch('api.php', { method: 'POST', body: formData, cache: 'no-store' });
|
||
} finally {
|
||
aiImageRunningJobs.delete(job.id);
|
||
}
|
||
}
|
||
|
||
async function pollAiImageJobs(activeJobId, target, options) {
|
||
options = options || {};
|
||
updateAiImageJobsPanel(null, !!options.manual);
|
||
try {
|
||
var params = new URLSearchParams({
|
||
action: 'ai-image-jobs',
|
||
open_only: '0',
|
||
limit: '30',
|
||
csrf_token: CSRF_TOKEN
|
||
});
|
||
var response = await fetch('api.php?' + params.toString(), { cache: 'no-store' });
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message || 'Error');
|
||
var jobs = Array.isArray(result.data.jobs) ? result.data.jobs : [];
|
||
var openJobs = jobs.filter(function(job) {
|
||
return job.status === 'queued' || job.status === 'running';
|
||
});
|
||
updateAiImageJobsPanel(jobs, false);
|
||
var runningJobs = openJobs.filter(function(job) { return job.status === 'running'; });
|
||
var queuedJobs = openJobs.filter(function(job) { return job.status === 'queued'; });
|
||
if (!runningJobs.length && queuedJobs.length) {
|
||
runAiImageJob(queuedJobs[queuedJobs.length - 1]);
|
||
}
|
||
|
||
jobs.forEach(function(job) {
|
||
if (!job || !job.id || (job.status !== 'success' && job.status !== 'error')) return;
|
||
var noticeAlreadyShown = aiImageKnownFinishedJobs.has(job.id) || aiImageJobNoticeWasShown(job);
|
||
var isActiveJob = job.id === activeJobId;
|
||
var isPassivePoll = !activeJobId && !target && !options.manual;
|
||
aiImageKnownFinishedJobs.add(job.id);
|
||
if (job.status === 'success') {
|
||
if (!noticeAlreadyShown && !isPassivePoll) {
|
||
showToast(t('ai.image_job_finished'), 'success');
|
||
}
|
||
if (isActiveJob && target) {
|
||
renderAiImageResult(job.result, target);
|
||
updateAiUsage(job.result && job.result.limits ? job.result.limits : null);
|
||
}
|
||
loadAiImageHistory(true);
|
||
} else {
|
||
if (!noticeAlreadyShown && !isPassivePoll) {
|
||
showToast(job.error || t('ai.image_job_failed'), 'error');
|
||
}
|
||
if (isActiveJob && target) {
|
||
target.innerHTML = '<div class="ai-image-message ai-image-message--error" role="alert"><strong>Error</strong><span>' + escapeHtml(job.error || t('ai.image_job_failed')) + '</span></div>';
|
||
}
|
||
loadAiImageHistory(true);
|
||
}
|
||
markAiImageJobNoticeShown(job);
|
||
});
|
||
|
||
if (openJobs.length) {
|
||
aiImageJobPollTimer = window.setTimeout(function() {
|
||
pollAiImageJobs(activeJobId, target);
|
||
}, 12000);
|
||
} else {
|
||
aiImageJobPollTimer = null;
|
||
}
|
||
} catch (error) {
|
||
setAiImageJobsChecking(false);
|
||
aiImageJobPollTimer = window.setTimeout(function() {
|
||
pollAiImageJobs(activeJobId, target);
|
||
}, 15000);
|
||
}
|
||
}
|
||
|
||
function startAiImageJobPolling(activeJobId, target, options) {
|
||
if (aiImageJobPollTimer) {
|
||
window.clearTimeout(aiImageJobPollTimer);
|
||
aiImageJobPollTimer = null;
|
||
}
|
||
pollAiImageJobs(activeJobId || '', target || null, options || {});
|
||
}
|
||
|
||
document.getElementById('aiImageJobsCheck')?.addEventListener('click', function() {
|
||
startAiImageJobPolling('', null, { manual: true });
|
||
});
|
||
|
||
if (aiImageForm) {
|
||
aiImageForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var prompt = document.getElementById('aiImagePrompt').value.trim();
|
||
if (!prompt || aiImageBusy) return;
|
||
var target = document.getElementById('aiImageResult');
|
||
var btn = aiImageForm.querySelector('button[type="submit"]');
|
||
setAiImageBusy(true, btn, 'ai.generating');
|
||
target.textContent = t('ai.generating');
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'ai-generate-image');
|
||
formData.append('prompt', prompt);
|
||
formData.append('model', document.getElementById('aiImageModelPicker')?.value || document.getElementById('aiImageModel')?.value || '');
|
||
formData.append('size', document.getElementById('aiImageSize').value);
|
||
formData.append('aspectRatio', document.getElementById('aiImageRatio')?.value || 'auto');
|
||
formData.append('imageScale', document.getElementById('aiImageScale')?.value || '2048');
|
||
formData.append('count', document.getElementById('aiImageCount').value);
|
||
formData.append('outputFormat', document.getElementById('aiImageFormat').value);
|
||
formData.append('quality', document.getElementById('aiImageQuality').value);
|
||
formData.append('moderation', document.getElementById('aiImageModeration').value);
|
||
formData.append('outputCompression', document.getElementById('aiImageCompression').value);
|
||
aiImageReferences.slice(0, AI_IMAGE_REFERENCE_LIMIT).forEach(function(reference) {
|
||
if (reference.type === 'file' && reference.file) {
|
||
formData.append('referenceImages[]', reference.file, reference.name || reference.file.name || 'reference-image');
|
||
} else if (reference.type === 'media' && reference.path) {
|
||
formData.append('referenceMediaPaths[]', reference.path);
|
||
}
|
||
});
|
||
formData.append('filenameHint', aiImageFilenameHintValue());
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
var result = await fetchJsonWithTimeout('api.php', { method: 'POST', body: formData }, 45000);
|
||
if (!result.success) throw new Error(result.message);
|
||
if (result.data && result.data.job) {
|
||
setAiImageJobMessage(target, result.data.job, 'ai.image_job_queued');
|
||
runAiImageJob(result.data.job);
|
||
startAiImageJobPolling(result.data.job.id, target);
|
||
} else {
|
||
renderAiImageResult(result.data, target);
|
||
updateAiUsage(result.data.limits);
|
||
loadAiImageHistory(true);
|
||
}
|
||
} catch (error) {
|
||
var message = error && error.name === 'AbortError'
|
||
? t('copilot.image_request_timeout')
|
||
: (error.message || t('toast.error'));
|
||
target.innerHTML = '<div class="ai-image-message ai-image-message--error" role="alert"><strong>Error</strong><span>' + escapeHtml(message) + '</span></div>';
|
||
} finally {
|
||
setAiImageBusy(false, btn);
|
||
}
|
||
});
|
||
}
|
||
|
||
var aiImageResult = document.getElementById('aiImageResult');
|
||
if (aiImageResult) {
|
||
aiImageResult.addEventListener('click', function(e) {
|
||
var trigger = e.target.closest('[data-ai-preview]');
|
||
if (!trigger || !aiImageResult.contains(trigger)) return;
|
||
if (window.NbImageManager && typeof NbImageManager.preview === 'function') {
|
||
NbImageManager.preview(trigger.dataset.aiPreview, trigger.dataset.aiPreviewName || '');
|
||
} else {
|
||
window.open(trigger.dataset.aiPreview, '_blank', 'noopener');
|
||
}
|
||
});
|
||
}
|
||
|
||
function formatAiHistoryDate(value) {
|
||
if (!value) return '';
|
||
var date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return rjFormatDate(date, 'datetime');
|
||
}
|
||
|
||
function renderAiImageHistory(items, append, hasMore) {
|
||
var container = document.getElementById('aiImageHistory');
|
||
var list = document.getElementById('aiImageHistoryList');
|
||
var more = document.getElementById('aiImageHistoryLoadMore');
|
||
if (!container || !list) return;
|
||
var safeItems = Array.isArray(items) ? items : [];
|
||
if (!append) {
|
||
list.innerHTML = '';
|
||
}
|
||
if (!append && !safeItems.length) {
|
||
list.innerHTML = '<p class="ai-image-history-empty">' + escapeHtml(t('ai.image_history_empty')) + '</p>';
|
||
} else {
|
||
safeItems.forEach(function(item) {
|
||
var outputs = Array.isArray(item.outputs) ? item.outputs : [];
|
||
var firstOutput = outputs[0] || '';
|
||
var prompt = item.prompt || '';
|
||
var status = item.status === 'error' ? 'error' : 'success';
|
||
var statusLabel = status === 'error' ? t('ai.image_history_status_error') : t('ai.image_history_status_success');
|
||
var multiThumb = outputs.length > 1;
|
||
var thumb;
|
||
if (multiThumb) {
|
||
thumb = '<div class="ai-image-history-card__thumbs">' +
|
||
outputs.map(function(path) {
|
||
return '<button type="button" class="ai-image-history-card__thumb" data-ai-preview="' + escapeHtml(path) + '" data-ai-preview-name="' + escapeHtml(path.split('/').pop()) + '"><img src="' + escapeHtml(path) + '" alt=""></button>';
|
||
}).join('') +
|
||
'</div>';
|
||
} else if (firstOutput) {
|
||
thumb = '<button type="button" class="ai-image-history-card__thumb" data-ai-preview="' + escapeHtml(firstOutput) + '" data-ai-preview-name="' + escapeHtml(firstOutput.split('/').pop()) + '"><img src="' + escapeHtml(firstOutput) + '" alt=""></button>';
|
||
} else {
|
||
thumb = '<div class="ai-image-history-card__thumb ai-image-history-card__thumb--empty">' + escapeHtml(t('ai.image_history_error')) + '</div>';
|
||
}
|
||
var ratioMeta = item.aspectRatio && item.aspectRatio !== 'auto' ? item.aspectRatio : '';
|
||
// Display pixel dimensions with a proper multiplication sign (e.g. 2560×1440).
|
||
var sizeMeta = /^\d+x\d+$/.test(String(item.size || '')) ? String(item.size).replace('x', '×') : (item.size || '');
|
||
var formatMeta = item.format ? String(item.format).toUpperCase() : '';
|
||
var meta = [item.model, ratioMeta, sizeMeta, formatMeta, item.quality].filter(Boolean).join(' · ');
|
||
var html =
|
||
'<article class="ai-image-history-card ai-image-history-card--' + status + (multiThumb ? ' ai-image-history-card--multi' : '') + '">' +
|
||
thumb +
|
||
'<div class="ai-image-history-card__body">' +
|
||
'<div class="ai-image-history-card__top">' +
|
||
'<span class="ai-image-history-status">' + escapeHtml(statusLabel) + '</span>' +
|
||
'<time>' + escapeHtml(formatAiHistoryDate(item.createdAt)) + '</time>' +
|
||
'</div>' +
|
||
'<p class="ai-image-history-prompt">' + escapeHtml(prompt || (item.error || '')) + '</p>' +
|
||
'<p class="ai-image-history-meta">' + escapeHtml(meta) + '</p>' +
|
||
'<div class="ai-image-history-actions">' +
|
||
'<button type="button" class="btn btn-secondary btn-sm" data-ai-history-prompt="' + escapeHtml(prompt) + '">' + escapeHtml(t('ai.image_history_use_prompt')) + '</button>' +
|
||
(firstOutput ? '<button type="button" class="btn btn-secondary btn-sm" onclick="switchTab("media")">' + escapeHtml(t('nav.media_library')) + '</button>' : '') +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</article>';
|
||
list.insertAdjacentHTML('beforeend', html);
|
||
});
|
||
}
|
||
container.hidden = false;
|
||
if (more) {
|
||
more.hidden = !hasMore;
|
||
}
|
||
}
|
||
|
||
async function loadAiImageHistory(reset) {
|
||
if (!document.getElementById('aiImageHistoryList')) return;
|
||
var offset = reset ? 0 : aiImageHistoryOffset;
|
||
try {
|
||
var params = new URLSearchParams({
|
||
action: 'ai-image-history',
|
||
offset: String(offset),
|
||
limit: String(AI_IMAGE_HISTORY_LIMIT),
|
||
csrf_token: CSRF_TOKEN
|
||
});
|
||
var response = await fetch('api.php?' + params.toString());
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message || 'Error');
|
||
aiImageHistoryOffset = (result.data.offset || 0) + (Array.isArray(result.data.items) ? result.data.items.length : 0);
|
||
renderAiImageHistory(result.data.items || [], !reset, !!result.data.hasMore);
|
||
} catch (error) {
|
||
showToast(error.message, 'error');
|
||
}
|
||
}
|
||
|
||
var aiImageHistoryList = document.getElementById('aiImageHistoryList');
|
||
if (aiImageHistoryList) {
|
||
aiImageHistoryList.addEventListener('click', function(e) {
|
||
var preview = e.target.closest('[data-ai-preview]');
|
||
if (preview && aiImageHistoryList.contains(preview)) {
|
||
if (window.NbImageManager && typeof NbImageManager.preview === 'function') {
|
||
NbImageManager.preview(preview.dataset.aiPreview, preview.dataset.aiPreviewName || '');
|
||
} else {
|
||
window.open(preview.dataset.aiPreview, '_blank', 'noopener');
|
||
}
|
||
return;
|
||
}
|
||
var promptButton = e.target.closest('[data-ai-history-prompt]');
|
||
if (promptButton && aiImageHistoryList.contains(promptButton)) {
|
||
var promptField = document.getElementById('aiImagePrompt');
|
||
if (promptField) {
|
||
promptField.value = promptButton.dataset.aiHistoryPrompt || '';
|
||
promptField.focus();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
var aiImageHistoryLoadMore = document.getElementById('aiImageHistoryLoadMore');
|
||
if (aiImageHistoryLoadMore) {
|
||
aiImageHistoryLoadMore.addEventListener('click', function() {
|
||
loadAiImageHistory(false);
|
||
});
|
||
}
|
||
|
||
var aiImageHistoryClear = document.getElementById('aiImageHistoryClear');
|
||
if (aiImageHistoryClear) {
|
||
aiImageHistoryClear.addEventListener('click', function() {
|
||
showModal(t('ai.image_history_clear'), t('ai.image_history_clear_confirm'), async function() {
|
||
closeModal();
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'clear-ai-image-history');
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message || 'Error');
|
||
aiImageHistoryOffset = 0;
|
||
renderAiImageHistory([], false, false);
|
||
showToast(t('ai.image_history_cleared'), 'success');
|
||
} catch (error) {
|
||
showToast(error.message, 'error');
|
||
}
|
||
}, {
|
||
confirmText: t('ai.image_history_clear'),
|
||
confirmClass: 'btn btn-danger'
|
||
});
|
||
});
|
||
}
|
||
|
||
if (document.getElementById('aiImageHistoryList')) {
|
||
loadAiImageHistory(true);
|
||
startAiImageJobPolling('', null);
|
||
}
|
||
|
||
var aiImageReference = document.getElementById('aiImageReference');
|
||
var aiImageReferenceUpload = document.getElementById('aiImageReferenceUpload');
|
||
var aiImageReferenceLibrary = document.getElementById('aiImageReferenceLibrary');
|
||
var aiImageReferenceClear = document.getElementById('aiImageReferenceClear');
|
||
if (aiImageReferenceUpload && aiImageReference) {
|
||
aiImageReferenceUpload.addEventListener('click', function() {
|
||
aiImageReference.click();
|
||
});
|
||
aiImageReference.addEventListener('change', function() {
|
||
addAiImageReferenceFiles(Array.from(aiImageReference.files || []));
|
||
aiImageReference.value = '';
|
||
});
|
||
}
|
||
if (aiImageReferenceLibrary) {
|
||
aiImageReferenceLibrary.addEventListener('click', function() {
|
||
if (!window.NbImageManager) return;
|
||
NbImageManager.open(function(paths) {
|
||
if (aiImageReference) aiImageReference.value = '';
|
||
addAiImageReferenceMedia(Array.isArray(paths) ? paths : [paths]);
|
||
}, aiImageReferences.filter(function(item) {
|
||
return item.type === 'media';
|
||
}).map(function(item) {
|
||
return item.path;
|
||
}), { types: ['image'], type: 'image', multiple: true });
|
||
});
|
||
}
|
||
if (aiImageReferenceClear) {
|
||
aiImageReferenceClear.addEventListener('click', function() {
|
||
if (aiImageReference) aiImageReference.value = '';
|
||
aiImageReferences = [];
|
||
updateAiImageReferences();
|
||
});
|
||
}
|
||
|
||
function addAiImageReferenceFiles(files) {
|
||
files.forEach(function(file) {
|
||
if (!file || aiImageReferences.length >= AI_IMAGE_REFERENCE_LIMIT) return;
|
||
aiImageReferences.push({
|
||
type: 'file',
|
||
file: file,
|
||
name: file.name || t('ai.image_reference_file')
|
||
});
|
||
});
|
||
updateAiImageReferences();
|
||
}
|
||
|
||
function addAiImageReferenceMedia(paths) {
|
||
paths.forEach(function(path) {
|
||
if (!path || aiImageReferences.length >= AI_IMAGE_REFERENCE_LIMIT) return;
|
||
path = String(path);
|
||
var duplicate = aiImageReferences.some(function(item) {
|
||
return item.type === 'media' && item.path === path;
|
||
});
|
||
if (duplicate) return;
|
||
aiImageReferences.push({
|
||
type: 'media',
|
||
path: path,
|
||
name: path.split('/').pop() || path
|
||
});
|
||
});
|
||
updateAiImageReferences();
|
||
}
|
||
|
||
function removeAiImageReference(index) {
|
||
aiImageReferences.splice(index, 1);
|
||
updateAiImageReferences();
|
||
}
|
||
|
||
function updateAiImageReferences() {
|
||
var label = document.getElementById('aiImageReferenceName');
|
||
var clear = document.getElementById('aiImageReferenceClear');
|
||
var list = document.getElementById('aiImageReferenceList');
|
||
var count = aiImageReferences.length;
|
||
if (label) {
|
||
label.textContent = count
|
||
? t('ai.image_reference_count', { count: count, max: AI_IMAGE_REFERENCE_LIMIT })
|
||
: t('ai.image_reference_none');
|
||
}
|
||
if (clear) clear.hidden = count === 0;
|
||
if (!list) return;
|
||
list.hidden = count === 0;
|
||
list.innerHTML = aiImageReferences.map(function(reference, index) {
|
||
var preview = '';
|
||
if (reference.type === 'file' && reference.file) {
|
||
preview = URL.createObjectURL(reference.file);
|
||
} else if (reference.type === 'media') {
|
||
preview = reference.path;
|
||
}
|
||
return '<span class="ai-reference-chip">' +
|
||
(preview ? '<span class="ai-reference-chip__thumb" style="background-image:url("' + escapeHtml(preview) + '")"></span>' : '') +
|
||
'<span class="ai-reference-chip__name">' + escapeHtml(reference.name || t('ai.image_reference_file')) + '</span>' +
|
||
'<button type="button" class="ai-reference-chip__remove" data-reference-index="' + index + '" aria-label="' + escapeHtml(t('ai.image_reference_remove')) + '">×</button>' +
|
||
'</span>';
|
||
}).join('');
|
||
}
|
||
|
||
document.getElementById('aiImageReferenceList')?.addEventListener('click', function(e) {
|
||
var button = e.target.closest('[data-reference-index]');
|
||
if (!button) return;
|
||
removeAiImageReference(parseInt(button.dataset.referenceIndex, 10));
|
||
});
|
||
|
||
updateAiImageReferences();
|
||
|
||
// Curated per-image prices (cents) for OpenAI image models, since the
|
||
// OpenAI-compatible endpoint has no model price API. Declared before the
|
||
// size picker initialises (which computes the estimated cost on load).
|
||
var AI_OPENAI_IMAGE_COST_CENTS = {
|
||
'gpt-image-2': 4
|
||
};
|
||
|
||
initAiImageSizePicker();
|
||
document.getElementById('aiImageSize')?.addEventListener('change', updateAiImageRatioIcon);
|
||
updateAiImageRatioIcon();
|
||
|
||
var aiCompressionSlider = document.getElementById('aiImageCompression');
|
||
var aiCompressionValue = document.getElementById('aiImageCompressionValue');
|
||
var aiCompressionFill = document.getElementById('aiImageCompressionFill');
|
||
if (aiCompressionSlider && aiCompressionValue && aiCompressionFill) {
|
||
var syncAiCompression = function() {
|
||
var pct = Math.max(0, Math.min(100, parseInt(aiCompressionSlider.value || '0', 10)));
|
||
aiCompressionFill.style.width = pct + '%';
|
||
aiCompressionValue.textContent = pct + '%';
|
||
// Value sits inside the filled area; below 40% the fill is too
|
||
// narrow, so flip the label to the empty (right) side.
|
||
aiCompressionValue.classList.toggle('fill-slider__value--right', pct < 40);
|
||
};
|
||
aiCompressionSlider.addEventListener('input', syncAiCompression);
|
||
syncAiCompression();
|
||
}
|
||
|
||
function initAiImageSizePicker() {
|
||
var select = document.getElementById('aiImageSize');
|
||
var ratioInput = document.getElementById('aiImageRatio');
|
||
var trigger = document.getElementById('aiImageSizeTrigger');
|
||
var menu = document.getElementById('aiImageSizeMenu');
|
||
if (!select || !trigger || !menu) return;
|
||
var options = [
|
||
{ group: t('ai.image_group_auto'), value: 'auto', ratio: 'auto', name: t('ai.image_size_auto') },
|
||
{ group: t('ai.image_group_square'), value: '1:1', ratio: '1:1', name: 'Square' },
|
||
{ group: t('ai.image_group_landscape'), value: '5:4', ratio: '5:4', name: 'Classic' },
|
||
{ group: t('ai.image_group_landscape'), value: '4:3', ratio: '4:3', name: 'Classic' },
|
||
{ group: t('ai.image_group_landscape'), value: '3:2', ratio: '3:2', name: 'Standard' },
|
||
{ group: t('ai.image_group_landscape'), value: '16:9', ratio: '16:9', name: 'Widescreen' },
|
||
{ group: t('ai.image_group_landscape'), value: '21:9', ratio: '21:9', name: 'Ultrawide' },
|
||
{ group: t('ai.image_group_portrait'), value: '4:5', ratio: '4:5', name: 'Classic' },
|
||
{ group: t('ai.image_group_portrait'), value: '3:4', ratio: '3:4', name: 'Traditional' },
|
||
{ group: t('ai.image_group_portrait'), value: '2:3', ratio: '2:3', name: 'Portrait' },
|
||
{ group: t('ai.image_group_portrait'), value: '9:16', ratio: '9:16', name: 'Social story' }
|
||
];
|
||
var currentGroup = '';
|
||
menu.innerHTML = options.map(function(option) {
|
||
var groupHtml = '';
|
||
if (option.group !== currentGroup) {
|
||
currentGroup = option.group;
|
||
groupHtml = '<div class="ai-size-group">' + escapeHtml(option.group) + '</div>';
|
||
}
|
||
return groupHtml + '<button type="button" class="ai-size-option" role="option" data-ratio="' + escapeHtml(option.value) + '">' +
|
||
'<span class="ai-size-option-icon" data-ratio="' + escapeHtml(option.value) + '" aria-hidden="true"></span>' +
|
||
'<span class="ai-size-option-ratio">' + escapeHtml(option.ratio) + '</span>' +
|
||
'<span class="ai-size-option-name">' + escapeHtml(option.name) + '</span>' +
|
||
'</button>';
|
||
}).join('');
|
||
menu.querySelectorAll('.ai-size-option-icon').forEach(function(iconEl) {
|
||
applyAiRatioIcon(iconEl, iconEl.dataset.ratio || 'auto');
|
||
});
|
||
trigger.addEventListener('click', function() {
|
||
if (trigger.disabled) return;
|
||
var isOpen = !menu.hidden;
|
||
menu.hidden = isOpen;
|
||
trigger.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||
});
|
||
menu.addEventListener('click', function(e) {
|
||
var option = e.target.closest('.ai-size-option');
|
||
if (!option) return;
|
||
if (ratioInput) ratioInput.value = option.dataset.ratio || 'auto';
|
||
menu.hidden = true;
|
||
trigger.setAttribute('aria-expanded', 'false');
|
||
updateAiImageRatioIcon();
|
||
});
|
||
document.addEventListener('click', function(e) {
|
||
if (!menu.hidden && !document.getElementById('aiImageSizePicker')?.contains(e.target)) {
|
||
menu.hidden = true;
|
||
trigger.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
updateAiImageRatioIcon();
|
||
}
|
||
|
||
function updateAiImageRatioIcon() {
|
||
var select = document.getElementById('aiImageSize');
|
||
var ratioInput = document.getElementById('aiImageRatio');
|
||
var icon = document.getElementById('aiImageRatioIcon');
|
||
var label = document.getElementById('aiImageSizeLabel');
|
||
var menu = document.getElementById('aiImageSizeMenu');
|
||
var scale = document.getElementById('aiImageScale');
|
||
var note = document.getElementById('aiImageSizeNote');
|
||
if (!select || !icon) return;
|
||
var ratioValue = ratioInput ? ratioInput.value : 'auto';
|
||
var computedSize = computeAiImageSize(ratioValue, scale ? scale.value : '2048');
|
||
select.value = computedSize.size;
|
||
if (label) {
|
||
var selected = null;
|
||
if (menu) {
|
||
menu.querySelectorAll('.ai-size-option').forEach(function(option) {
|
||
if (option.dataset.ratio === ratioValue) selected = option;
|
||
});
|
||
}
|
||
if (selected) {
|
||
var ratioText = selected.querySelector('.ai-size-option-ratio')?.textContent || '';
|
||
var nameText = selected.querySelector('.ai-size-option-name')?.textContent || '';
|
||
label.innerHTML = ratioText === 'auto'
|
||
? '<span>' + escapeHtml(nameText) + '</span>'
|
||
: '<span>' + escapeHtml(ratioText) + '</span><span>' + escapeHtml(nameText) + '</span>';
|
||
} else {
|
||
label.textContent = t('ai.image_size_auto');
|
||
}
|
||
}
|
||
if (menu) {
|
||
menu.querySelectorAll('.ai-size-option').forEach(function(option) {
|
||
option.setAttribute('aria-selected', option.dataset.ratio === ratioValue ? 'true' : 'false');
|
||
});
|
||
}
|
||
if (note) {
|
||
var sizeText = computedSize.size === 'auto'
|
||
? t('ai.image_size_note_auto')
|
||
: t('ai.image_size_note').replace('{size}', computedSize.size.replace('x', ' × '));
|
||
note.textContent = sizeText + aiEstimatedImageCostSuffix();
|
||
}
|
||
applyAiRatioIcon(icon, computedSize.size === 'auto' ? ratioValue : computedSize.size);
|
||
}
|
||
|
||
// Detect the active provider from the saved settings only (not the
|
||
// settings-form DOM), since cost resolution runs in the image generator
|
||
// where the relevant provider is the configured one.
|
||
function aiSavedProviderKey() {
|
||
var settings = currentAiSettings || {};
|
||
var provider = String(settings.provider || '').trim();
|
||
var baseUrl = String(settings.baseUrl || '').trim();
|
||
if (provider === 'anthropic' || baseUrl.indexOf('api.anthropic.com') !== -1) return 'anthropic';
|
||
if (provider === 'kie' || baseUrl.indexOf('api.kie.ai') !== -1) return 'kie';
|
||
if (provider === 'openrouter' || baseUrl.indexOf('openrouter.ai') !== -1) return 'openrouter';
|
||
return 'openai-compatible';
|
||
}
|
||
|
||
// Resolve the estimated per-image cost for the selected provider/model.
|
||
// Returns { cents, estimated } or null when no figure is available.
|
||
function aiResolveImageCost() {
|
||
var settings = currentAiSettings || {};
|
||
var providerKey = aiSavedProviderKey();
|
||
var ownService = !!(settings.imageService && settings.imageService.provider && settings.imageService.provider !== 'inherit');
|
||
if (providerKey === 'anthropic' && !ownService) {
|
||
return null; // Anthropic does not generate images; an own image service does.
|
||
}
|
||
var model = String(document.getElementById('aiImageModelPicker')?.value
|
||
|| document.getElementById('aiImageModel')?.value
|
||
|| settings.imageModel || '').trim();
|
||
var configuredCents = parseInt((settings.pricing && settings.pricing.imageCentsPerRequest) || 0, 10);
|
||
|
||
// A price the admin or the registry set for this model (server side, in euro cents), by resolution or quality.
|
||
var active = (settings.imageServiceStatus && settings.imageServiceStatus.active) || {};
|
||
var table = active.priceTable;
|
||
if (table && table.source !== 'default' && (!model || model === active.model)) {
|
||
var scale = parseInt(document.getElementById('aiImageScale')?.value || '2048', 10) || 2048;
|
||
var step = scale >= 3072 ? '4K' : (scale >= 1536 ? '2K' : '1K');
|
||
var quality = String(document.getElementById('aiImageQuality')?.value || 'auto');
|
||
var tableCents = (table.byResolution && table.byResolution[step] != null) ? table.byResolution[step]
|
||
: ((table.byQuality && table.byQuality[quality] != null) ? table.byQuality[quality] : table.default);
|
||
if (tableCents != null) return { cents: tableCents, estimated: false };
|
||
}
|
||
if (ownService) {
|
||
return configuredCents ? { cents: configuredCents, estimated: true } : null;
|
||
}
|
||
|
||
if (providerKey === 'openrouter') {
|
||
var entry = aiOpenRouterModelsCache && Array.isArray(aiOpenRouterModelsCache.imageModels)
|
||
? aiOpenRouterModelsCache.imageModels.find(function(m) { return m.id === model; })
|
||
: null;
|
||
if (entry && entry.imageCostCents != null) {
|
||
return { cents: entry.imageCostCents, estimated: !!entry.imageCostEstimated };
|
||
}
|
||
// Catalog not loaded yet or model not found: fall back to settings.
|
||
return configuredCents ? { cents: configuredCents, estimated: true } : null;
|
||
}
|
||
|
||
if (providerKey === 'kie') {
|
||
// The price tables of the Kie.ai models come from the registry (above); without one the default price applies.
|
||
return configuredCents ? { cents: configuredCents, estimated: true } : null;
|
||
}
|
||
|
||
// OpenAI-compatible: curated default for known models, else settings.
|
||
if (AI_OPENAI_IMAGE_COST_CENTS[model] != null) {
|
||
return { cents: AI_OPENAI_IMAGE_COST_CENTS[model], estimated: false };
|
||
}
|
||
return configuredCents ? { cents: configuredCents, estimated: false } : null;
|
||
}
|
||
|
||
// Format a (possibly fractional) cents value as EUR with enough precision
|
||
// for sub-cent image prices: 2 decimals normally, more when very small.
|
||
function formatAiImageCents(cents) {
|
||
var eur = (parseFloat(cents) || 0) / 100;
|
||
var decimals = eur >= 0.01 ? 2 : (eur >= 0.001 ? 3 : 4);
|
||
return eur.toFixed(decimals) + ' EUR';
|
||
}
|
||
|
||
// " · Estimated cost per image: 0.05 EUR" (with a leading ~ when the figure
|
||
// is an estimate). Empty when no figure is available.
|
||
function aiEstimatedImageCostSuffix() {
|
||
var cost = aiResolveImageCost();
|
||
if (!cost || !cost.cents) return '';
|
||
var amount = (cost.estimated ? '~' : '') + formatAiImageCents(cost.cents);
|
||
return ' · ' + t('ai.image_cost_per_image').replace('{cost}', amount);
|
||
}
|
||
|
||
function applyAiRatioIcon(icon, value) {
|
||
var match = String(value || '').match(/^(\d+)x(\d+)$/);
|
||
if (!match) {
|
||
match = String(value || '').match(/^(\d+):(\d+)$/);
|
||
}
|
||
if (!match) {
|
||
icon.style.setProperty('--ratio-w', '1');
|
||
icon.style.setProperty('--ratio-h', '1');
|
||
icon.classList.add('ai-ratio-icon--auto');
|
||
return;
|
||
}
|
||
icon.classList.remove('ai-ratio-icon--auto');
|
||
var w = parseInt(match[1], 10);
|
||
var h = parseInt(match[2], 10);
|
||
var ratio = w / h;
|
||
icon.style.setProperty('--ratio-w', ratio >= 1 ? Math.min(2.4, ratio) : 1);
|
||
icon.style.setProperty('--ratio-h', ratio >= 1 ? 1 : Math.min(2.4, 1 / ratio));
|
||
}
|
||
|
||
function computeAiImageSize(ratioValue, scaleValue) {
|
||
if (!ratioValue || ratioValue === 'auto') {
|
||
return { size: 'auto' };
|
||
}
|
||
var parts = ratioValue.split(':').map(function(part) { return parseInt(part, 10); });
|
||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||
return { size: 'auto' };
|
||
}
|
||
var rw = parts[0];
|
||
var rh = parts[1];
|
||
var targetLong = parseInt(scaleValue, 10) || 2048;
|
||
var ratio = Math.max(rw, rh) / Math.min(rw, rh);
|
||
var minPixels = 655360;
|
||
var maxPixels = 8294400;
|
||
var longEdge = Math.min(3840, Math.max(16, targetLong));
|
||
var minLongForPixels = Math.ceil(Math.sqrt(minPixels * ratio) / 16) * 16;
|
||
longEdge = Math.max(longEdge, minLongForPixels);
|
||
var shortEdge = Math.round((longEdge / ratio) / 16) * 16;
|
||
if (shortEdge < 16) shortEdge = 16;
|
||
while (longEdge * shortEdge > maxPixels && longEdge > 16) {
|
||
longEdge -= 16;
|
||
shortEdge = Math.round((longEdge / ratio) / 16) * 16;
|
||
}
|
||
while (longEdge * shortEdge < minPixels && longEdge < 3840) {
|
||
longEdge += 16;
|
||
shortEdge = Math.round((longEdge / ratio) / 16) * 16;
|
||
}
|
||
var width = rw >= rh ? longEdge : shortEdge;
|
||
var height = rw >= rh ? shortEdge : longEdge;
|
||
return { size: width + 'x' + height };
|
||
}
|
||
|
||
document.getElementById('aiImageScale')?.addEventListener('change', updateAiImageRatioIcon);
|
||
|
||
var aiImageCountInput = document.getElementById('aiImageCount');
|
||
var aiImageCountUp = document.getElementById('aiImageCountUp');
|
||
var aiImageCountDown = document.getElementById('aiImageCountDown');
|
||
if (aiImageCountInput) {
|
||
aiImageCountInput.addEventListener('change', function() {
|
||
updateAiImageCount(0);
|
||
});
|
||
aiImageCountInput.addEventListener('input', function() {
|
||
updateAiImageCount(0);
|
||
});
|
||
updateAiImageCount(0);
|
||
}
|
||
if (aiImageForm) {
|
||
aiImageForm.addEventListener('click', function(e) {
|
||
if (e.target.closest('#aiImageCountUp')) {
|
||
e.preventDefault();
|
||
updateAiImageCount(1);
|
||
} else if (e.target.closest('#aiImageCountDown')) {
|
||
e.preventDefault();
|
||
updateAiImageCount(-1);
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateAiImageCount(delta) {
|
||
var input = document.getElementById('aiImageCount');
|
||
var button = document.getElementById('aiGenerateImageButton');
|
||
if (!input) return;
|
||
var value = parseInt(input.value, 10) || 1;
|
||
value = Math.max(1, Math.min(10, value + delta));
|
||
input.value = String(value);
|
||
if (button && !button.dataset.originalHtml) {
|
||
button.textContent = value === 1 ? t('ai.generate_image') : t('ai.generate_images');
|
||
}
|
||
}
|
||
|
||
function aiImageFilenameSlug(value) {
|
||
value = String(value || '').trim();
|
||
if (!value) return '';
|
||
value = value
|
||
.replace(/[Ä]/g, 'Ae').replace(/[Ö]/g, 'Oe').replace(/[Ü]/g, 'Ue')
|
||
.replace(/[ä]/g, 'ae').replace(/[ö]/g, 'oe').replace(/[ü]/g, 'ue')
|
||
.replace(/[ß]/g, 'ss');
|
||
try {
|
||
value = value.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||
} catch (error) {}
|
||
value = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||
return (value || 'ai-image').substring(0, 72).replace(/-+$/g, '') || 'ai-image';
|
||
}
|
||
|
||
function aiImageFilenameHintValue() {
|
||
return String(document.getElementById('aiImageFilenameHint')?.value || '')
|
||
.trim()
|
||
.replace(/\.(png|jpe?g|webp)$/i, '');
|
||
}
|
||
|
||
document.getElementById('aiSuggestImageFilename')?.addEventListener('click', function() {
|
||
var prompt = document.getElementById('aiImagePrompt')?.value || '';
|
||
var input = document.getElementById('aiImageFilenameHint');
|
||
if (!input) return;
|
||
input.value = aiImageFilenameSlug(prompt);
|
||
input.focus();
|
||
input.select();
|
||
});
|
||
|
||
document.getElementById('aiImproveImagePrompt')?.addEventListener('click', async function() {
|
||
var promptEl = document.getElementById('aiImagePrompt');
|
||
var prompt = promptEl.value.trim();
|
||
if (!prompt || aiImageBusy) return;
|
||
var btn = this;
|
||
setAiImageBusy(true, btn, 'ai.improving_prompt');
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'ai-generate-text');
|
||
formData.append('prompt', 'Improve this image generation prompt. Return only the improved prompt, no intro, no markdown. Keep the user intent and make it specific, visual, and concise:\\n\\n' + prompt);
|
||
formData.append('maxOutputTokens', '350');
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
if (!result.success) throw new Error(result.message);
|
||
promptEl.value = String(result.data.text || '').trim();
|
||
updateAiUsage(result.data.limits);
|
||
} catch (error) {
|
||
showToast(error.message, 'error');
|
||
} finally {
|
||
setAiImageBusy(false, btn);
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// SAVE LANGUAGE
|
||
// ============================================================
|
||
|
||
document.getElementById('languageForm').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('saveLanguageBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
try {
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
if (!settings.general) settings.general = {};
|
||
settings.general.adminLanguage = document.getElementById('settingsAdminLanguage').value;
|
||
var aiLabelArtworkEl = document.getElementById('settingsAiLabelArtwork');
|
||
if (aiLabelArtworkEl) settings.general.aiLabelArtwork = aiLabelArtworkEl.value === 'page' ? 'page' : 'en';
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
showToast(t('toast.language_saved'), 'success');
|
||
// Reload to apply new language
|
||
setTimeout(function() { location.reload(); }, 800);
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_language');
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// SAVE LOGIN BEHAVIOUR
|
||
// ============================================================
|
||
|
||
var loginForm = document.getElementById('loginForm');
|
||
if (loginForm) {
|
||
loginForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('saveLoginBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
try {
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
if (!settings.general) settings.general = {};
|
||
var modeRadio = document.querySelector('input[name="frontendLoginRedirect"]:checked');
|
||
settings.general.frontendLoginRedirect = modeRadio ? modeRadio.value : 'auto';
|
||
settings.login = {
|
||
brandAsset: document.getElementById('loginBrandAsset').value,
|
||
image: document.getElementById('loginImage').value.trim(),
|
||
imageLayout: document.getElementById('loginImageLayout').value,
|
||
overlayColor: document.getElementById('loginImageLayout').value === 'background'
|
||
? document.getElementById('loginOverlayColor').value
|
||
: '',
|
||
overlayOpacity: document.getElementById('loginImageLayout').value === 'background'
|
||
? parseInt(document.getElementById('loginOverlayOpacity').value, 10)
|
||
: 86,
|
||
boxStyle: document.getElementById('loginBoxStyle').value,
|
||
boxColor: document.getElementById('loginBoxStyle').value === 'card'
|
||
? document.getElementById('loginBoxColor').value
|
||
: '',
|
||
boxTextColor: document.getElementById('loginBoxTextColor').value
|
||
};
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
showToast(t('toast.login_saved'), 'success');
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_login');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// ACCESS SETTINGS
|
||
// ============================================================
|
||
|
||
var accessForm = document.getElementById('accessForm');
|
||
if (accessForm) {
|
||
accessForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('saveAccessBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
try {
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
settings.access = settings.access || {};
|
||
settings.access.maintenance = {
|
||
enabled: document.getElementById('maintenanceEnabled').checked,
|
||
mode: document.getElementById('maintenanceMode').value,
|
||
title: document.getElementById('maintenanceTitle').value.trim(),
|
||
text: document.getElementById('maintenanceText').value.trim(),
|
||
until: document.getElementById('maintenanceUntil').value,
|
||
showCountdown: document.getElementById('maintenanceCountdown').checked,
|
||
brandAsset: document.getElementById('maintenanceBrandAsset').value,
|
||
image: document.getElementById('maintenanceImage').value.trim(),
|
||
imageLayout: document.getElementById('maintenanceImageLayout').value,
|
||
overlayColor: document.getElementById('maintenanceImageLayout').value === 'background'
|
||
? document.getElementById('maintenanceOverlayColor').value
|
||
: '',
|
||
overlayOpacity: document.getElementById('maintenanceImageLayout').value === 'background'
|
||
? parseInt(document.getElementById('maintenanceOverlayOpacity').value, 10)
|
||
: 88,
|
||
bypassParam: collectBypassLinks().firstParam,
|
||
bypassLinks: collectBypassLinks().links,
|
||
// the visible links are the whole truth once the form is saved
|
||
clearBypassKey: true
|
||
};
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
populateSettings(currentSettings);
|
||
showToast(t('toast.access_saved'), 'success');
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_access');
|
||
}
|
||
});
|
||
}
|
||
|
||
var moduleForm = document.getElementById('moduleForm');
|
||
if (moduleForm) {
|
||
moduleForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('saveModulesBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
try {
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
var previousModules = JSON.stringify(settings.modules || {});
|
||
settings.modules = settings.modules || {};
|
||
settings.modules.ai = document.getElementById('moduleAi').checked;
|
||
settings.modules.news = document.getElementById('moduleNews').checked;
|
||
settings.modules.events = document.getElementById('moduleEvents').checked;
|
||
settings.modules.messages = document.getElementById('moduleMessages').checked;
|
||
settings.modules.iconManager = document.getElementById('moduleIconManager').checked;
|
||
var clampInt = function(id, min, max, fallback) {
|
||
var el = document.getElementById(id);
|
||
var value = el ? parseInt(el.value, 10) : NaN;
|
||
return isNaN(value) ? fallback : Math.max(min, Math.min(max, value));
|
||
};
|
||
settings.media = Object.assign({}, settings.media || {}, {
|
||
maxUploadMb: clampInt('settingsMediaMaxUpload', 1, 200, 20),
|
||
webMaxSide: clampInt('settingsMediaWebMaxSide', 800, 6000, 2000),
|
||
webQuality: clampInt('settingsMediaWebQuality', 50, 95, 82),
|
||
webVersion: !!(document.getElementById('settingsMediaWebVersion') || {}).checked
|
||
});
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
populateSettings(currentSettings);
|
||
showToast(t('toast.modules_saved'), 'success');
|
||
if (JSON.stringify(currentSettings.modules || {}) !== previousModules) {
|
||
setTimeout(function() { location.reload(); }, 800);
|
||
}
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_modules');
|
||
}
|
||
});
|
||
}
|
||
|
||
var privacyForm = document.getElementById('privacyForm');
|
||
if (privacyForm) {
|
||
privacyForm.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('savePrivacyBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
try {
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
settings.privacy = settings.privacy || {};
|
||
settings.privacy.analyticsEnabled = document.getElementById('analyticsEnabled').checked;
|
||
settings.privacy.emailObfuscation = document.getElementById('emailObfuscation').checked;
|
||
settings.privacy.rememberPublicTheme = document.getElementById('rememberPublicTheme').checked;
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
populateSettings(currentSettings);
|
||
showToast(t('toast.privacy_saved'), 'success');
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_privacy');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// EMAIL SETTINGS
|
||
// ============================================================
|
||
|
||
function toggleSmtpFields(method) {
|
||
var smtpFields = document.getElementById('smtpFields');
|
||
var sendmailHint = document.getElementById('sendmailHint');
|
||
var emailFields = document.querySelectorAll('#settingsRecipientEmail, #settingsFromEmail, #settingsFromName');
|
||
var inactiveHint = document.getElementById('emailInactiveHint');
|
||
|
||
smtpFields.style.display = 'none';
|
||
sendmailHint.style.display = 'none';
|
||
if (inactiveHint) inactiveHint.style.display = 'none';
|
||
|
||
// Show/hide all email config fields
|
||
var fieldGroups = smtpFields.parentElement.querySelectorAll('.form-group');
|
||
for (var i = 1; i < fieldGroups.length; i++) { // skip method dropdown
|
||
fieldGroups[i].style.display = (method === 'inactive') ? 'none' : '';
|
||
}
|
||
smtpFields.style.display = (method === 'smtp') ? '' : 'none';
|
||
|
||
if (method === 'sendmail') {
|
||
sendmailHint.style.display = '';
|
||
} else if (method === 'inactive') {
|
||
if (inactiveHint) inactiveHint.style.display = '';
|
||
}
|
||
}
|
||
|
||
document.getElementById('settingsEmailMethod').addEventListener('change', function() {
|
||
toggleSmtpFields(this.value);
|
||
});
|
||
|
||
document.getElementById('settingsSmtpEncryption').addEventListener('change', function() {
|
||
var portField = document.getElementById('settingsSmtpPort');
|
||
if (this.value === 'ssl') portField.value = 465;
|
||
else if (this.value === 'tls') portField.value = 587;
|
||
else portField.value = 25;
|
||
});
|
||
|
||
function getEmailFormData() {
|
||
return {
|
||
method: document.getElementById('settingsEmailMethod').value,
|
||
recipientEmail: document.getElementById('settingsRecipientEmail').value.trim(),
|
||
bccEmail: document.getElementById('settingsBccEmail').value.trim(),
|
||
fromEmail: document.getElementById('settingsFromEmail').value.trim(),
|
||
fromName: document.getElementById('settingsFromName').value.trim(),
|
||
smtpHost: document.getElementById('settingsSmtpHost').value.trim(),
|
||
smtpPort: parseInt(document.getElementById('settingsSmtpPort').value, 10) || 587,
|
||
smtpUsername: document.getElementById('settingsSmtpUsername').value.trim(),
|
||
smtpPassword: document.getElementById('settingsSmtpPassword').value,
|
||
smtpEncryption: document.getElementById('settingsSmtpEncryption').value
|
||
};
|
||
}
|
||
|
||
document.getElementById('emailForm').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
var btn = document.getElementById('saveEmailBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('btn.saving');
|
||
|
||
try {
|
||
var emailData = getEmailFormData();
|
||
var settings = Object.assign({}, currentSettings || {});
|
||
settings.email = emailData;
|
||
|
||
var formData = new FormData();
|
||
formData.append('action', 'save-settings');
|
||
formData.append('settings', JSON.stringify(settings));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
if (result.success) {
|
||
currentSettings = result.data;
|
||
showToast(t('toast.email_saved'), 'success');
|
||
// Update password placeholder to indicate saved
|
||
if (emailData.smtpPassword || currentSettings.email?.smtpPassword) {
|
||
document.getElementById('settingsSmtpPassword').value = '';
|
||
document.getElementById('settingsSmtpPassword').placeholder = '••••••••';
|
||
}
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(t('toast.error_generic', {message: error.message}), 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.save_email');
|
||
}
|
||
});
|
||
|
||
document.getElementById('testEmailBtn').addEventListener('click', async function() {
|
||
var btn = this;
|
||
var resultEl = document.getElementById('emailTestResult');
|
||
btn.disabled = true;
|
||
btn.textContent = t('settings.testing_email');
|
||
resultEl.style.display = 'none';
|
||
|
||
var emailData = getEmailFormData();
|
||
if (!emailData.recipientEmail) {
|
||
showToast(t('settings.recipient_required'), 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.test_email');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
var formData = new FormData();
|
||
formData.append('action', 'test-email');
|
||
formData.append('emailConfig', JSON.stringify(emailData));
|
||
formData.append('csrf_token', CSRF_TOKEN);
|
||
|
||
var response = await fetch('api.php', { method: 'POST', body: formData });
|
||
var result = await response.json();
|
||
|
||
resultEl.style.display = '';
|
||
if (result.success) {
|
||
resultEl.className = 'settings-test-result settings-test-result--success';
|
||
resultEl.textContent = t('settings.test_email_success');
|
||
} else {
|
||
resultEl.className = 'settings-test-result settings-test-result--error';
|
||
resultEl.textContent = result.message || t('settings.test_email_error');
|
||
}
|
||
} catch (error) {
|
||
resultEl.style.display = '';
|
||
resultEl.className = 'settings-test-result settings-test-result--error';
|
||
resultEl.textContent = error.message;
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('settings.test_email');
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// ACTIONS OF THE AI ASSISTANT
|
||
// Cards the assistant offers (open a section, change the dashboard look) run here after the
|
||
// person clicked. Settings go through the same forms and the same save as a manual change.
|
||
// ============================================================
|
||
|
||
window.RaijuDashboardActions = {
|
||
run: async function(action) {
|
||
action = action || {};
|
||
if (action.type === 'open') {
|
||
var section = String(action.section || '');
|
||
var allowed = Array.prototype.map.call(document.querySelectorAll('.sidebar-nav-item[data-tab]'), function(item) { return item.getAttribute('data-tab'); });
|
||
if (allowed.indexOf(section) === -1) throw new Error(t('copilot.dash_failed'));
|
||
var tab = String(action.settingsTab || '');
|
||
if (section === 'settings' && tab && document.querySelector('.settings-tab-btn[data-settings-tab="' + tab.replace(/[^a-z-]/g, '') + '"]')) {
|
||
switchTab('settings', { settingsTab: tab });
|
||
} else {
|
||
switchTab(section);
|
||
}
|
||
return { message: t('copilot.dash_opened') };
|
||
}
|
||
if (action.type !== 'setting') throw new Error(t('copilot.dash_failed'));
|
||
|
||
if (!settingsLoaded) await loadSettings();
|
||
if (!settingsLoaded || !currentSettings) throw new Error(t('copilot.dash_failed'));
|
||
var key = String(action.setting || '');
|
||
var value = String(action.value || '');
|
||
var waitForSave = async function(button) {
|
||
// The form handler disables its button while it saves; wait until it is done.
|
||
for (var i = 0; i < 100; i++) {
|
||
await new Promise(function(resolve) { setTimeout(resolve, 100); });
|
||
if (!button.disabled) return;
|
||
}
|
||
};
|
||
|
||
if (key === 'adminLanguage') {
|
||
var languageSelect = document.getElementById('settingsAdminLanguage');
|
||
var languageForm = document.getElementById('languageForm');
|
||
if (!languageSelect || !languageForm || !Array.prototype.some.call(languageSelect.options, function(option) { return option.value === value; })) throw new Error(t('copilot.dash_failed'));
|
||
languageSelect.value = value;
|
||
window.rjSelectSync?.(languageSelect);
|
||
languageForm.requestSubmit();
|
||
return { message: t('copilot.dash_language_reload') }; // the page reloads itself after saving
|
||
}
|
||
|
||
var themeForm = document.getElementById('themeForm');
|
||
var saveButton = document.getElementById('saveThemeBtn');
|
||
if (!themeForm || !saveButton) throw new Error(t('copilot.dash_failed'));
|
||
if (key === 'adminTheme') {
|
||
if (['light', 'dark', 'system'].indexOf(value) === -1) throw new Error(t('copilot.dash_failed'));
|
||
document.getElementById('settingsAdminTheme').value = value;
|
||
document.querySelectorAll('.theme-option').forEach(function(option) { option.classList.toggle('selected', option.dataset.theme === value); });
|
||
} else if (key === 'primaryColor' || key === 'accentColor') {
|
||
if (!/^#[0-9a-fA-F]{6}$/.test(value)) throw new Error(t('copilot.dash_failed'));
|
||
var field = document.getElementById('settings' + key.charAt(0).toUpperCase() + key.slice(1));
|
||
var picker = document.getElementById('settings' + key.charAt(0).toUpperCase() + key.slice(1) + 'Picker');
|
||
if (!field) throw new Error(t('copilot.dash_failed'));
|
||
field.value = value;
|
||
if (picker) picker.value = value;
|
||
if (key === 'accentColor') {
|
||
AUTO_STATE.accentColor = false;
|
||
var accentBadge = document.querySelector('.auto-badge[data-auto-for="accentColor"]');
|
||
if (accentBadge) accentBadge.hidden = true;
|
||
}
|
||
} else {
|
||
throw new Error(t('copilot.dash_failed'));
|
||
}
|
||
themeForm.requestSubmit();
|
||
await waitForSave(saveButton);
|
||
var saved = currentSettings && currentSettings.theme ? String(currentSettings.theme[key] || '').toLowerCase() : '';
|
||
if (saved === '') throw new Error(t('copilot.dash_failed'));
|
||
if (saved !== value.toLowerCase()) return { message: t('copilot.dash_adjusted') };
|
||
return { message: t('copilot.dash_saved') };
|
||
}
|
||
};
|