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: , favicon_png: '', logo: '', logoDark: '', adminLogo: '', name: , 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 ), 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(/); 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 '' + escapeHtml(t(key)) + ' (' + row.descriptionLength + ')'; } 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 = '' + '' + '' + '' + '' + ''; pages.forEach(function(row, index) { var needsDescription = row.descriptionStatus !== 'ok'; html += '' + '' + '' + '' + '' + ''; }); html += '
' + escapeHtml(t('ai.audit_col_page')) + '' + escapeHtml(t('ai.audit_col_description')) + '' + escapeHtml(t('ai.audit_col_alt')) + '
' + escapeHtml(row.title) + '
' + escapeHtml(row.contentPage) + '
' + aiAuditStatusBadge(row) + '' + (row.missingAlt > 0 ? '' + escapeHtml(t('ai.audit_alt_missing', { count: row.missingAlt })) + '' : '' + escapeHtml(t('ai.audit_status_ok')) + '') + '' + (needsDescription ? '' : '') + '
'; 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 = '' + escapeHtml(data.description) + ''; } 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 = '' + escapeHtml(t('ai.audit_applied')) + ''; } 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 = '' + escapeHtml(t(loadingTextKey || 'ai.generating')) + ''; } 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 = ''; return; } target.innerHTML = ''; } function setAiImageJobMessage(target, job, messageKey) { if (!target) return; var prompt = String((job && job.prompt) || '').trim(); target.innerHTML = '
' + '' + escapeHtml(t(messageKey || 'ai.image_job_queued')) + '' + (prompt ? '' + escapeHtml(prompt) + '' : '') + '
'; } 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 = ''; } 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 = ''; } 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 = '

' + escapeHtml(t('ai.image_history_empty')) + '

'; } 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 = '
' + outputs.map(function(path) { return ''; }).join('') + '
'; } else if (firstOutput) { thumb = ''; } else { thumb = '
' + escapeHtml(t('ai.image_history_error')) + '
'; } 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 = '
' + thumb + '
' + '
' + '' + escapeHtml(statusLabel) + '' + '' + '
' + '

' + escapeHtml(prompt || (item.error || '')) + '

' + '

' + escapeHtml(meta) + '

' + '
' + '' + (firstOutput ? '' : '') + '
' + '
' + '
'; 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 '' + (preview ? '' : '') + '' + escapeHtml(reference.name || t('ai.image_reference_file')) + '' + '' + ''; }).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 = '
' + escapeHtml(option.group) + '
'; } return groupHtml + ''; }).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' ? '' + escapeHtml(nameText) + '' : '' + escapeHtml(ratioText) + '' + escapeHtml(nameText) + ''; } 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') }; } };