diff --git a/CHANGELOG.md b/CHANGELOG.md index c01d11d0..008c0a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to CV Manager will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/). +## [1.38.0] - 2026-04-19 + +### Added +- Theme picker now exposes a font selection of seven Google Fonts (Inter, Roboto, Open Sans, Lato, Merriweather, Playfair Display, JetBrains Mono). Each option is rendered in its own face inside the dropdown so the choice can be made by sight. The selected font drives a new `--font-family` CSS variable that the CV content (admin preview and public site) uses; the admin chrome stays in Inter. +- Theme picker now exposes a "Use custom gradient" toggle. When enabled, a second color wheel with a Start/End tab switcher lets you pick BOTH endpoints of the gradient explicitly — fully decoupled from the primary color. When disabled, the gradient continues to be auto-derived (start = primary, end = +15° hue rotation) so existing behavior is preserved. New `--gradient-start` and `--gradient-end` CSS variables power the profile-image avatar gradient and the timeline track gradient. +- Per-dataset theme changes also propagate to language siblings (datasets in the same `language_group`), so the visual identity stays consistent across language variants of the same CV regardless of the "Apply to all saved datasets" toggle state. +- Eight extra preset color swatches in the theme picker (slate, gray, amber, lime, green, teal, violet, rose) bringing the total from 8 to 16, arranged in two rows. +- Per-dataset theme: each saved dataset now stores its own `{ primary, gradientEnd, fontFamily }` snapshot inside `data.theme`. Loading a dataset applies its theme. A new "Apply to all saved datasets" toggle in the theme picker (on by default) controls whether changing the theme bulk-updates every saved dataset and the global fallback, or only the currently-loaded dataset. New endpoint `PUT /api/theme` performs the atomic write. + +### Fixed +- Theme picker: the cursor dot on the color wheel now sits at the position of the currently-selected color whenever the picker opens. Previously the cursor stayed at its last click position (or at the wheel origin on first open), making the indicator misleading. +- Theme picker: moving the brightness slider now updates the live preview swatch, the hex input, and the theme being edited — not just the wheel background. The new color is recomputed directly from the stored hue and saturation, avoiding canvas resampling drift, so the brightness slider behaves like a true lightness control. + ## [1.37.1] - 2026-04-18 ### Fixed diff --git a/package-lock.json b/package-lock.json index 939de189..9b5bf3e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.37.1", + "version": "1.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.37.1", + "version": "1.38.0", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index a25f2807..004807a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.37.1", + "version": "1.38.0", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/public-readonly/index.html b/public-readonly/index.html index 212b22d9..2ecd46dc 100644 --- a/public-readonly/index.html +++ b/public-readonly/index.html @@ -866,28 +866,67 @@

${escapeHtml(proj.title)}

applySectionTitles(sectionOrder); } + const loadedFontSetPublic = new Set(['Inter']); + function ensureFontLoadedPublic(family) { + if (!family || loadedFontSetPublic.has(family)) return; + const slug = family.replace(/ /g, '+'); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://fonts.googleapis.com/css2?family=' + slug + ':wght@300;400;500;600;700&display=swap'; + document.head.appendChild(link); + loadedFontSetPublic.add(family); + } + async function loadThemeColorPublic(allSettings) { - try { - const value = (allSettings && allSettings.themeColor !== undefined) ? allSettings.themeColor : (await api('/api/settings/themeColor')).value; - if (value) { - applyColorToCSSPublic(value); - } - } catch (err) { - // Ignore errors, use default theme + // Server-side injection wins (no flicker). Fallback to settings API. + if (window.DATASET_THEME && typeof window.DATASET_THEME === 'object') { + applyThemePublic(window.DATASET_THEME); + return; } + try { + const primary = (allSettings && allSettings.themeColor !== undefined) ? allSettings.themeColor : (await api('/api/settings/themeColor')).value; + const fontFamily = (allSettings && allSettings.themeFontFamily !== undefined) ? allSettings.themeFontFamily : (await api('/api/settings/themeFontFamily')).value; + const gradientStart = (allSettings && allSettings.themeGradientStart !== undefined) ? allSettings.themeGradientStart : (await api('/api/settings/themeGradientStart')).value; + const gradientEnd = (allSettings && allSettings.themeGradientEnd !== undefined) ? allSettings.themeGradientEnd : (await api('/api/settings/themeGradientEnd')).value; + applyThemePublic({ primary: primary || '#0066ff', gradientStart: gradientStart || null, gradientEnd: gradientEnd || null, fontFamily: fontFamily || 'Inter' }); + } catch (err) { /* Ignore — defaults stand */ } } - - function applyColorToCSSPublic(hex) { + + function applyThemePublic(theme) { + applyColorToCSSPublic(theme.primary || '#0066ff', theme.gradientStart || null, theme.gradientEnd || null, theme.fontFamily || 'Inter'); + } + + function applyColorToCSSPublic(hex, gradientStart, gradientEnd, fontFamily) { const root = document.documentElement; const hsl = hexToHSLPublic(hex); - + root.style.setProperty('--primary', hex); root.style.setProperty('--primary-dark', hslToHexPublic(hsl.h, hsl.s, Math.max(hsl.l - 15, 10))); root.style.setProperty('--primary-light', hslToHexPublic(hsl.h, Math.min(hsl.s + 10, 100), Math.min(hsl.l + 15, 80))); - root.style.setProperty('--accent', hslToHexPublic((hsl.h + 15) % 360, hsl.s, hsl.l)); + const accent = hslToHexPublic((hsl.h + 15) % 360, hsl.s, hsl.l); + root.style.setProperty('--accent', accent); root.style.setProperty('--dark', hslToHexPublic(hsl.h, hsl.s, 15)); root.style.setProperty('--light', hslToHexPublic(hsl.h, 30, 90)); root.style.setProperty('--very-light', hslToHexPublic(hsl.h, 20, 97)); + // Default --gradient-start to primary and --gradient-end to accent + // so existing themes look identical when no custom values are set. + // When a custom gradient IS set, it also drives the header gradient + // so the whole theme feels consistent. + const hasCustomGradient = !!(gradientStart || gradientEnd); + const gs = gradientStart || hex; + const ge = gradientEnd || accent; + root.style.setProperty('--gradient-start', gs); + root.style.setProperty('--gradient-end', ge); + if (hasCustomGradient) { + root.style.setProperty('--header-gradient-start', gs); + root.style.setProperty('--header-gradient-end', ge); + } else { + root.style.setProperty('--header-gradient-start', hslToHexPublic(hsl.h, hsl.s, Math.max(hsl.l - 15, 10))); + root.style.setProperty('--header-gradient-end', hslToHexPublic(hsl.h, hsl.s, 15)); + } + const family = fontFamily || 'Inter'; + if (family !== 'Inter') ensureFontLoadedPublic(family); + root.style.setProperty('--font-family', "'" + family + "', var(--font-family-default)"); } function hexToHSLPublic(hex) { diff --git a/public/index.html b/public/index.html index aeaa9995..995c6de8 100644 --- a/public/index.html +++ b/public/index.html @@ -41,7 +41,7 @@
-
+
@@ -50,7 +50,79 @@
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+ +
+ + +
+ +
+ +
+
diff --git a/public/shared/admin.css b/public/shared/admin.css index 59f47fbf..435182f8 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -1433,7 +1433,9 @@ border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 16px; - width: 220px; + width: 240px; + max-height: calc(100vh - 80px); + overflow-y: auto; z-index: 1001; display: none; } @@ -1505,6 +1507,102 @@ .color-preset.active { border-color: var(--gray-700); } .color-picker-actions { display: flex; justify-content: space-between; gap: 8px; } +/* Theme picker — font, gradient, apply-to-all sub-sections */ +.theme-section { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--gray-100); } +.theme-section-label { display: block; font-size: 11px; color: var(--gray-500); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; } +.theme-section-row { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--gray-700); } +.theme-section-row input[type="checkbox"] { cursor: pointer; } + +.theme-font-picker { position: relative; } +.theme-font-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 10px; + background: var(--white); + border: 1px solid var(--gray-300); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; + color: var(--gray-700); + text-align: left; +} +.theme-font-trigger:hover { border-color: var(--gray-500); } +.theme-font-trigger .material-symbols-outlined { font-size: 18px; } +.theme-font-list { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--white); + border: 1px solid var(--gray-300); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + max-height: 220px; + overflow-y: auto; + z-index: 5; + display: none; +} +.theme-font-list.active { display: block; } +.theme-font-option { + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + color: var(--gray-900); +} +.theme-font-option:hover { background: var(--gray-50); } +.theme-font-option.active { background: var(--light); color: var(--primary); } + +.theme-gradient-picker { margin-top: 10px; padding: 10px; background: var(--gray-50); border-radius: var(--radius-sm); } + +/* Gradient pair presets — each swatch shows the actual gradient, click sets + both start and end on the theme at once. */ +.gradient-pair-presets { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin-bottom: 4px; +} +.gradient-pair-preset { + width: 100%; + height: 28px; + border-radius: 4px; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.15s, border-color 0.15s; +} +.gradient-pair-preset:hover { transform: scale(1.08); border-color: var(--gray-300); } +.gradient-pair-preset.active { border-color: var(--gray-700); } + +.gradient-endpoint-tabs { + display: flex; + gap: 6px; + margin-bottom: 10px; +} +.gradient-endpoint-tab { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--white); + border: 1px solid var(--gray-300); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 12px; + color: var(--gray-700); +} +.gradient-endpoint-tab:hover { border-color: var(--gray-500); } +.gradient-endpoint-tab.active { border-color: var(--primary); color: var(--primary); background: var(--very-light); } +.gradient-endpoint-swatch { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--gray-300); + flex-shrink: 0; +} + /* =========================== Settings Modal Styles =========================== */ diff --git a/public/shared/admin.js b/public/shared/admin.js index 6444dbe6..8843575b 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -3639,6 +3639,10 @@ async function loadDataset(id, name) { if (typeof I18n !== 'undefined' && activeDatasetLanguage && I18n.locale !== activeDatasetLanguage) { await I18n.setLocale(activeDatasetLanguage); } + // Apply the loaded dataset's theme (server already wrote it to settings) + if (result.theme && typeof loadTheme === 'function') { + try { await loadTheme(); } catch (e) { /* non-fatal */ } + } closeDatasetsModal(); await initAdmin(); toast(t('toast.dataset_loaded', { name: result.name })); @@ -3677,53 +3681,115 @@ function formatDateTime(dateStr) { } // =========================== -// Theme Color Picker +// Theme Picker — primary color, gradient endpoint, font, apply-to-all // =========================== -let currentColor = '#0066ff'; -let colorWheelCtx = null; -let isDraggingWheel = false; +const FONT_OPTIONS = [ + { family: 'Inter', label: 'Inter' }, + { family: 'Roboto', label: 'Roboto' }, + { family: 'Open Sans', label: 'Open Sans' }, + { family: 'Lato', label: 'Lato' }, + { family: 'Merriweather', label: 'Merriweather' }, + { family: 'Playfair Display', label: 'Playfair Display' }, + { family: 'JetBrains Mono', label: 'JetBrains Mono' } +]; + +const THEME_DEFAULTS = { primary: '#0066ff', gradientStart: null, gradientEnd: null, fontFamily: 'Inter' }; + +let themeState = { ...THEME_DEFAULTS }; +let applyToAllDatasets = true; +// The gradient sub-picker has one wheel that edits whichever endpoint is +// currently active ('start' or 'end'). We keep HSL state for each endpoint +// so switching tabs restores the wheel to the stored color/brightness. +let activeGradientEndpoint = 'end'; +let pickerHSL = { primary: { h: 214, s: 100, l: 50 }, gradient: { h: 214, s: 100, l: 35 } }; +let wheelDragState = { primary: false, gradient: false }; +let wheelCtx = { primary: null, gradient: null }; +const loadedFontSet = new Set(['Inter']); + +// Preset grid is scoped by container ID so the selector for target=primary +// doesn't match .color-preset elements elsewhere. The gradient sub-picker +// uses a different preset style (pair swatches) handled separately. +const PRESET_CONTAINER = { primary: 'primaryPresets' }; + +// Backward-compat alias kept because other parts of admin.js may still read currentColor +let currentColor = themeState.primary; + +function ensureFontLoaded(family) { + if (!family || loadedFontSet.has(family)) return; + const slug = family.replace(/ /g, '+'); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?family=${slug}:wght@300;400;500;600;700&display=swap`; + document.head.appendChild(link); + loadedFontSet.add(family); +} + +function preloadAllPickerFonts() { + FONT_OPTIONS.forEach(f => ensureFontLoaded(f.family)); +} function initColorPicker() { const canvas = document.getElementById('colorWheel'); if (!canvas) return; - - colorWheelCtx = canvas.getContext('2d'); - drawColorWheel(); - loadThemeColor(); - - canvas.addEventListener('mousedown', startColorPick); - canvas.addEventListener('mousemove', pickColorOnDrag); - canvas.addEventListener('mouseup', stopColorPick); - canvas.addEventListener('mouseleave', stopColorPick); - - canvas.addEventListener('touchstart', (e) => { e.preventDefault(); startColorPick(e.touches[0]); }); - canvas.addEventListener('touchmove', (e) => { e.preventDefault(); pickColorOnDrag(e.touches[0]); }); - canvas.addEventListener('touchend', stopColorPick); - - document.getElementById('colorBrightness').addEventListener('input', () => { drawColorWheel(); }); - - document.getElementById('colorHexInput').addEventListener('change', (e) => { - const hex = e.target.value; - if (/^#[0-9A-Fa-f]{6}$/.test(hex)) { - currentColor = hex; - updateColorPickerUI(hex); - } - }); - - document.querySelectorAll('.color-preset').forEach(preset => { - preset.addEventListener('click', () => { - const color = preset.dataset.color; - currentColor = color; - updateColorPickerUI(color); + + wheelCtx.primary = canvas.getContext('2d'); + drawColorWheel('primary'); + setupWheelEvents('primary'); + setupHexInput('primary'); + setupPresets('primary'); + setupBrightness('primary'); + + // Optional gradient sub-picker (built when user enables custom gradient) + const gradCanvas = document.getElementById('gradientWheel'); + if (gradCanvas) { + wheelCtx.gradient = gradCanvas.getContext('2d'); + drawColorWheel('gradient'); + setupWheelEvents('gradient'); + setupHexInput('gradient'); + setupGradientPairPresets(); + setupBrightness('gradient'); + } + + const useGradientToggle = document.getElementById('themeUseGradient'); + if (useGradientToggle) { + useGradientToggle.addEventListener('change', () => { + const wrapper = document.getElementById('gradientPickerWrapper'); + if (useGradientToggle.checked) { + seedCustomGradient(); + if (wrapper) wrapper.style.display = 'block'; + activateGradientEndpoint(activeGradientEndpoint); + } else { + themeState.gradientStart = null; + themeState.gradientEnd = null; + if (wrapper) wrapper.style.display = 'none'; + } + applyThemeToCSS(themeState); + }); + } + + // Endpoint tabs swap which color the single gradient wheel is editing. + document.querySelectorAll('.gradient-endpoint-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + e.stopPropagation(); + activateGradientEndpoint(tab.dataset.endpoint); }); }); - + + initFontDropdown(); + + const applyAllToggle = document.getElementById('themeApplyToAll'); + if (applyAllToggle) { + applyAllToggle.addEventListener('change', () => { applyToAllDatasets = applyAllToggle.checked; }); + } + document.addEventListener('click', (e) => { const dropdown = document.getElementById('colorPickerDropdown'); const wrapper = document.querySelector('.color-picker-wrapper'); if (dropdown.classList.contains('active') && !wrapper.contains(e.target)) { dropdown.classList.remove('active'); + const fontList = document.getElementById('themeFontList'); + if (fontList) fontList.classList.remove('active'); } const langDropdown = document.getElementById('languagePickerDropdown'); const langWrapper = document.querySelector('.language-picker-wrapper'); @@ -3731,130 +3797,408 @@ function initColorPicker() { langDropdown.classList.remove('active'); } }); + + loadTheme(); } -async function loadThemeColor() { - try { - const result = await api('/api/settings/themeColor'); - if (result.value) { - currentColor = result.value; - applyColorToCSS(currentColor); - updateColorPickerUI(currentColor); - } - } catch (err) { - const savedColor = localStorage.getItem('cvThemeColor'); - if (savedColor) { - currentColor = savedColor; - applyColorToCSS(currentColor); - updateColorPickerUI(currentColor); - } +function setupWheelEvents(target) { + const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel'; + const canvas = document.getElementById(canvasId); + if (!canvas) return; + const start = (e) => { wheelDragState[target] = true; pickColorAt(target, e); }; + const move = (e) => { if (wheelDragState[target]) pickColorAt(target, e); }; + const stop = () => { wheelDragState[target] = false; }; + canvas.addEventListener('mousedown', start); + canvas.addEventListener('mousemove', move); + canvas.addEventListener('mouseup', stop); + canvas.addEventListener('mouseleave', stop); + canvas.addEventListener('touchstart', (e) => { e.preventDefault(); start(e.touches[0]); }); + canvas.addEventListener('touchmove', (e) => { e.preventDefault(); move(e.touches[0]); }); + canvas.addEventListener('touchend', stop); +} + +// Write `hex` back into the correct field on themeState. For target='gradient' +// this respects which endpoint tab is currently active. +function commitTargetColor(target, hex) { + if (target === 'primary') { themeState.primary = hex; currentColor = hex; return; } + if (activeGradientEndpoint === 'start') themeState.gradientStart = hex; + else themeState.gradientEnd = hex; +} + +function setupBrightness(target) { + const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness'; + const slider = document.getElementById(sliderId); + if (!slider) return; + slider.addEventListener('input', () => { + const newL = parseInt(slider.value, 10); + pickerHSL[target].l = newL; + drawColorWheel(target); + // Recompute color from stored (h,s,l) — avoids canvas-resampling drift + const hex = hslToHex(pickerHSL[target].h, pickerHSL[target].s, newL); + commitTargetColor(target, hex); + updateColorPickerUI(target, hex); + applyThemeToCSS(themeState); + }); +} + +function setupHexInput(target) { + const id = target === 'primary' ? 'colorHexInput' : 'gradientHexInput'; + const input = document.getElementById(id); + if (!input) return; + input.addEventListener('change', (e) => { + const hex = e.target.value; + if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return; + commitTargetColor(target, hex.toLowerCase()); + pickerHSL[target] = hexToHSL(hex); + const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness'; + const slider = document.getElementById(sliderId); + if (slider) slider.value = pickerHSL[target].l; + drawColorWheel(target); + updateColorPickerUI(target, hex); + applyThemeToCSS(themeState); + }); +} + +function setupPresets(target) { + // Scope by container ID so clicks on gradient swatches don't also fire + // the primary handler (their shared .color-preset class would otherwise + // match both selectors). + const container = document.getElementById(PRESET_CONTAINER[target]); + if (!container) return; + container.querySelectorAll('.color-preset').forEach(preset => { + preset.addEventListener('click', () => { + const color = preset.dataset.color; + commitTargetColor(target, color); + pickerHSL[target] = hexToHSL(color); + const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness'; + const slider = document.getElementById(sliderId); + if (slider) slider.value = pickerHSL[target].l; + drawColorWheel(target); + updateColorPickerUI(target, color); + applyThemeToCSS(themeState); + }); + }); +} + +function pickColorAt(target, e) { + const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel'; + const canvas = document.getElementById(canvasId); + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX || e.pageX) - rect.left; + const y = (e.clientY || e.pageY) - rect.top; + const cx = canvas.width / 2, cy = canvas.height / 2; + const dx = x - cx, dy = y - cy; + const distance = Math.sqrt(dx * dx + dy * dy); + const radius = Math.min(cx, cy); + if (distance > radius) return; + + // Compute HSL directly from geometry — keeps brightness slider authoritative + let h = Math.atan2(dy, dx) * 180 / Math.PI; + if (h < 0) h += 360; + const s = Math.min(100, Math.round((distance / radius) * 100)); + const l = pickerHSL[target].l; + const hex = hslToHex(h, s, l); + + pickerHSL[target] = { h: Math.round(h), s, l }; + commitTargetColor(target, hex); + updateColorPickerUI(target, hex); + applyThemeToCSS(themeState); +} + +// Auto-derivation helpers: when no custom gradient is set we want the colors +// to feel like the "nice calculated gradient" the theme used to produce. +function autoGradientStart(primary) { + // Keep identical to --primary so the legacy behavior is preserved. + return primary; +} +function autoGradientEnd(primary) { + const hsl = hexToHSL(primary); + // +15° hue rotation, same S/L — matches the pre-1.38 --accent value. + return hslToHex((hsl.h + 15) % 360, hsl.s, hsl.l); +} + +// First time the user flips "Use custom gradient" on, seed both endpoints +// from the auto-derived values so there's no jarring flash and so the sub- +// picker starts on the current gradient (which they can then tweak). +function seedCustomGradient() { + if (!themeState.gradientStart) themeState.gradientStart = autoGradientStart(themeState.primary); + if (!themeState.gradientEnd) themeState.gradientEnd = autoGradientEnd(themeState.primary); +} + +// Point the single gradient wheel at `which` endpoint ('start' or 'end'). +function activateGradientEndpoint(which) { + activeGradientEndpoint = which; + document.querySelectorAll('.gradient-endpoint-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.endpoint === which); + }); + const color = which === 'start' ? themeState.gradientStart : themeState.gradientEnd; + if (!color) return; + pickerHSL.gradient = hexToHSL(color); + const slider = document.getElementById('gradientBrightness'); + if (slider) slider.value = pickerHSL.gradient.l; + drawColorWheel('gradient'); + updateColorPickerUI('gradient', color); +} + +function updateGradientSwatches() { + const primary = themeState.primary || '#0066ff'; + const startColor = themeState.gradientStart || autoGradientStart(primary); + const endColor = themeState.gradientEnd || autoGradientEnd(primary); + const startSwatch = document.getElementById('gradientStartSwatch'); + const endSwatch = document.getElementById('gradientEndSwatch'); + if (startSwatch) startSwatch.style.backgroundColor = startColor; + if (endSwatch) endSwatch.style.backgroundColor = endColor; + refreshGradientPairActiveState(); +} + +// Curated gradient pair presets set BOTH endpoints on click so the user gets +// a coherent start/end combination (darker variant or complementary hue) +// rather than picking a flat single color per endpoint. +function setupGradientPairPresets() { + const container = document.getElementById('gradientPairPresets'); + if (!container) return; + container.querySelectorAll('.gradient-pair-preset').forEach(preset => { + preset.addEventListener('click', () => { + const start = preset.dataset.start; + const end = preset.dataset.end; + if (!start || !end) return; + themeState.gradientStart = start; + themeState.gradientEnd = end; + // Sync the wheel to whichever endpoint is currently active so the + // user can keep tuning from the preset they just picked. + const activeColor = activeGradientEndpoint === 'start' ? start : end; + pickerHSL.gradient = hexToHSL(activeColor); + const slider = document.getElementById('gradientBrightness'); + if (slider) slider.value = pickerHSL.gradient.l; + drawColorWheel('gradient'); + updateColorPickerUI('gradient', activeColor); + applyThemeToCSS(themeState); + }); + }); +} + +function refreshGradientPairActiveState() { + const start = (themeState.gradientStart || '').toLowerCase(); + const end = (themeState.gradientEnd || '').toLowerCase(); + document.querySelectorAll('.gradient-pair-preset').forEach(preset => { + const match = preset.dataset.start.toLowerCase() === start && preset.dataset.end.toLowerCase() === end; + preset.classList.toggle('active', match); + }); +} + +function positionCursorFromHex(target, hex) { + const cursorId = target === 'primary' ? 'colorWheelCursor' : 'gradientWheelCursor'; + const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel'; + const cursor = document.getElementById(cursorId); + const canvas = document.getElementById(canvasId); + if (!cursor || !canvas) return; + const cx = canvas.width / 2, cy = canvas.height / 2; + const R = Math.min(cx, cy); + const hsl = hexToHSL(hex); + const theta = hsl.h * Math.PI / 180; + const r = (hsl.s / 100) * R; + cursor.style.left = (cx + r * Math.cos(theta)) + 'px'; + cursor.style.top = (cy + r * Math.sin(theta)) + 'px'; +} + +function updateColorPickerUI(target, hex) { + if (typeof target !== 'string') { + // Backward-compat: original signature was updateColorPickerUI(hex) + hex = target; + target = 'primary'; + } + const previewId = target === 'primary' ? 'colorPreview' : 'gradientPreview'; + const inputId = target === 'primary' ? 'colorHexInput' : 'gradientHexInput'; + const preview = document.getElementById(previewId); + const input = document.getElementById(inputId); + if (preview) preview.style.backgroundColor = hex; + if (input) input.value = hex.toUpperCase(); + const presetContainer = document.getElementById(PRESET_CONTAINER[target]); + if (presetContainer) { + presetContainer.querySelectorAll('.color-preset').forEach(preset => { + preset.classList.toggle('active', preset.dataset.color.toLowerCase() === hex.toLowerCase()); + }); } + positionCursorFromHex(target, hex); + updateGradientSwatches(); } -function drawColorWheel() { - const canvas = document.getElementById('colorWheel'); - const ctx = colorWheelCtx; - const centerX = canvas.width / 2; - const centerY = canvas.height / 2; - const radius = Math.min(centerX, centerY); - const brightness = document.getElementById('colorBrightness')?.value || 50; - +function drawColorWheel(target) { + target = target || 'primary'; + const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel'; + const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness'; + const canvas = document.getElementById(canvasId); + const ctx = wheelCtx[target]; + if (!canvas || !ctx) return; + const cx = canvas.width / 2, cy = canvas.height / 2; + const radius = Math.min(cx, cy); + const brightness = document.getElementById(sliderId)?.value || 50; + for (let angle = 0; angle < 360; angle++) { const startAngle = (angle - 1) * Math.PI / 180; const endAngle = (angle + 1) * Math.PI / 180; - ctx.beginPath(); - ctx.moveTo(centerX, centerY); - ctx.arc(centerX, centerY, radius, startAngle, endAngle); + ctx.moveTo(cx, cy); + ctx.arc(cx, cy, radius, startAngle, endAngle); ctx.closePath(); - - const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius); - const hslColor = `hsl(${angle}, 100%, ${brightness}%)`; - const hslWhite = `hsl(${angle}, 0%, ${brightness}%)`; - gradient.addColorStop(0, hslWhite); - gradient.addColorStop(1, hslColor); - + const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + gradient.addColorStop(0, `hsl(${angle}, 0%, ${brightness}%)`); + gradient.addColorStop(1, `hsl(${angle}, 100%, ${brightness}%)`); ctx.fillStyle = gradient; ctx.fill(); } } -function startColorPick(e) { isDraggingWheel = true; pickColor(e); } -function pickColorOnDrag(e) { if (isDraggingWheel) pickColor(e); } -function stopColorPick() { isDraggingWheel = false; } +function initFontDropdown() { + const trigger = document.getElementById('themeFontTrigger'); + const list = document.getElementById('themeFontList'); + if (!trigger || !list) return; + list.innerHTML = FONT_OPTIONS.map(f => + `
${f.label}
` + ).join(''); + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + list.classList.toggle('active'); + if (list.classList.contains('active')) preloadAllPickerFonts(); + }); + list.addEventListener('click', (e) => { + const opt = e.target.closest('.theme-font-option'); + if (!opt) return; + const family = opt.dataset.font; + themeState.fontFamily = family; + ensureFontLoaded(family); + updateFontTrigger(family); + list.classList.remove('active'); + applyThemeToCSS(themeState); + }); +} -function pickColor(e) { - const canvas = document.getElementById('colorWheel'); - const rect = canvas.getBoundingClientRect(); - const x = (e.clientX || e.pageX) - rect.left; - const y = (e.clientY || e.pageY) - rect.top; - - const centerX = canvas.width / 2; - const centerY = canvas.height / 2; - const dx = x - centerX; - const dy = y - centerY; - const distance = Math.sqrt(dx * dx + dy * dy); - const radius = Math.min(centerX, centerY); - - if (distance <= radius) { - const imageData = colorWheelCtx.getImageData(x, y, 1, 1).data; - const hex = rgbToHex(imageData[0], imageData[1], imageData[2]); - currentColor = hex; - updateColorPickerUI(hex); - - const cursor = document.getElementById('colorWheelCursor'); - cursor.style.left = x + 'px'; - cursor.style.top = y + 'px'; +function updateFontTrigger(family) { + const label = document.getElementById('themeFontLabel'); + if (label) { + label.textContent = family; + label.style.fontFamily = `'${family}', sans-serif`; } + document.querySelectorAll('.theme-font-option').forEach(opt => { + opt.classList.toggle('active', opt.dataset.font === family); + }); } -function updateColorPickerUI(hex) { - document.getElementById('colorPreview').style.backgroundColor = hex; - document.getElementById('colorHexInput').value = hex.toUpperCase(); - - document.querySelectorAll('.color-preset').forEach(preset => { - preset.classList.toggle('active', preset.dataset.color.toLowerCase() === hex.toLowerCase()); - }); +async function loadTheme() { + try { + const theme = await api('/api/theme'); + if (theme) { + themeState.primary = theme.primary || THEME_DEFAULTS.primary; + themeState.gradientStart = theme.gradientStart || null; + themeState.gradientEnd = theme.gradientEnd || null; + themeState.fontFamily = theme.fontFamily || THEME_DEFAULTS.fontFamily; + } + try { + const allSetting = await api('/api/settings/applyThemeToAllDatasets'); + if (allSetting && allSetting.value !== null) applyToAllDatasets = allSetting.value === 'true'; + } catch (e) { /* default true */ } + } catch (err) { + const saved = localStorage.getItem('cvThemeColor'); + if (saved) themeState.primary = saved; + } + currentColor = themeState.primary; + pickerHSL.primary = hexToHSL(themeState.primary); + + const slider = document.getElementById('colorBrightness'); + if (slider) slider.value = pickerHSL.primary.l; + drawColorWheel('primary'); + updateColorPickerUI('primary', themeState.primary); + + const hasCustomGradient = !!(themeState.gradientStart || themeState.gradientEnd); + const useGradientToggle = document.getElementById('themeUseGradient'); + const gradWrapper = document.getElementById('gradientPickerWrapper'); + if (useGradientToggle) useGradientToggle.checked = hasCustomGradient; + if (gradWrapper) gradWrapper.style.display = hasCustomGradient ? 'block' : 'none'; + if (hasCustomGradient) { + // Fill in whichever endpoint is missing so both tabs have a color + seedCustomGradient(); + activateGradientEndpoint(activeGradientEndpoint); + } + + updateFontTrigger(themeState.fontFamily); + if (themeState.fontFamily !== 'Inter') ensureFontLoaded(themeState.fontFamily); + + const applyAllToggle = document.getElementById('themeApplyToAll'); + if (applyAllToggle) applyAllToggle.checked = applyToAllDatasets; + + applyThemeToCSS(themeState); + updateGradientSwatches(); } function toggleColorPicker() { const dropdown = document.getElementById('colorPickerDropdown'); - // Close language picker if open document.getElementById('languagePickerDropdown').classList.remove('active'); dropdown.classList.toggle('active'); if (dropdown.classList.contains('active')) { - updateColorPickerUI(currentColor); + // BUG A fix: re-position cursor to current color when opening + updateColorPickerUI('primary', themeState.primary); + if (themeState.gradientStart || themeState.gradientEnd) { + seedCustomGradient(); + activateGradientEndpoint(activeGradientEndpoint); + } + updateGradientSwatches(); + preloadAllPickerFonts(); } } async function applyThemeColor() { - applyColorToCSS(currentColor); + applyThemeToCSS(themeState); try { - await api('/api/settings/themeColor', { method: 'PUT', body: { value: currentColor } }); + await api('/api/theme', { method: 'PUT', body: { + primary: themeState.primary, + gradientStart: themeState.gradientStart, + gradientEnd: themeState.gradientEnd, + fontFamily: themeState.fontFamily, + applyToAll: applyToAllDatasets, + currentDatasetId: (typeof activeDatasetId !== 'undefined') ? activeDatasetId : null + }}); } catch (err) { - localStorage.setItem('cvThemeColor', currentColor); + localStorage.setItem('cvThemeColor', themeState.primary); } document.getElementById('colorPickerDropdown').classList.remove('active'); toast(t('toast.theme_applied')); } async function resetThemeColor() { - currentColor = '#0066ff'; - applyColorToCSS(currentColor); + themeState = { ...THEME_DEFAULTS }; + currentColor = themeState.primary; + pickerHSL.primary = hexToHSL(themeState.primary); + applyThemeToCSS(themeState); try { - await api('/api/settings/themeColor', { method: 'PUT', body: { value: null } }); + await api('/api/theme', { method: 'PUT', body: { + primary: themeState.primary, + gradientStart: null, + gradientEnd: null, + fontFamily: 'Inter', + applyToAll: applyToAllDatasets, + currentDatasetId: (typeof activeDatasetId !== 'undefined') ? activeDatasetId : null + }}); } catch (err) { localStorage.removeItem('cvThemeColor'); } - updateColorPickerUI(currentColor); + const slider = document.getElementById('colorBrightness'); + if (slider) slider.value = pickerHSL.primary.l; + drawColorWheel('primary'); + updateColorPickerUI('primary', themeState.primary); + const useGradientToggle = document.getElementById('themeUseGradient'); + if (useGradientToggle) useGradientToggle.checked = false; + const gradWrapper = document.getElementById('gradientPickerWrapper'); + if (gradWrapper) gradWrapper.style.display = 'none'; + updateFontTrigger('Inter'); document.getElementById('colorPickerDropdown').classList.remove('active'); toast(t('toast.theme_reset')); } -function applyColorToCSS(hex) { +function applyThemeToCSS(theme) { const root = document.documentElement; + const hex = (theme && theme.primary) || '#0066ff'; const hsl = hexToHSL(hex); - + root.style.setProperty('--primary', hex); root.style.setProperty('--primary-dark', hslToHex(hsl.h, hsl.s, Math.max(hsl.l - 15, 10))); root.style.setProperty('--primary-light', hslToHex(hsl.h, Math.min(hsl.s + 10, 100), Math.min(hsl.l + 15, 80))); @@ -3862,6 +4206,32 @@ function applyColorToCSS(hex) { root.style.setProperty('--dark', hslToHex(hsl.h, hsl.s, 15)); root.style.setProperty('--light', hslToHex(hsl.h, 30, 90)); root.style.setProperty('--very-light', hslToHex(hsl.h, 20, 97)); + + // Gradient endpoints: explicit overrides OR auto-derived. Defaults match the + // pre-1.38 calculated gradient (primary → accent for body gradients, primary-dark → dark + // for the CV header) so existing themes are visually unchanged when the user hasn't + // customized the gradient. When a custom gradient IS set, both the body-level and + // header gradients adopt the user's chosen colors so the whole theme feels consistent. + const hasCustomGradient = !!(theme && (theme.gradientStart || theme.gradientEnd)); + const gradientStart = (theme && theme.gradientStart) || autoGradientStart(hex); + const gradientEnd = (theme && theme.gradientEnd) || autoGradientEnd(hex); + root.style.setProperty('--gradient-start', gradientStart); + root.style.setProperty('--gradient-end', gradientEnd); + if (hasCustomGradient) { + root.style.setProperty('--header-gradient-start', gradientStart); + root.style.setProperty('--header-gradient-end', gradientEnd); + } else { + root.style.setProperty('--header-gradient-start', hslToHex(hsl.h, hsl.s, Math.max(hsl.l - 15, 10))); + root.style.setProperty('--header-gradient-end', hslToHex(hsl.h, hsl.s, 15)); + } + + const family = (theme && theme.fontFamily) || 'Inter'; + root.style.setProperty('--font-family', `'${family}', var(--font-family-default)`); +} + +// Backward-compat alias for any caller that still hands us a single hex +function applyColorToCSS(hex) { + applyThemeToCSS({ primary: hex, gradientEnd: themeState.gradientEnd, fontFamily: themeState.fontFamily }); } function rgbToHex(r, g, b) { diff --git a/public/shared/i18n/de.json b/public/shared/i18n/de.json index 69a6048c..f85f717b 100644 --- a/public/shared/i18n/de.json +++ b/public/shared/i18n/de.json @@ -22,6 +22,20 @@ "theme.cyan": "Cyan", "theme.indigo": "Indigo", "theme.pink": "Rosa", + "theme.slate": "Schiefer", + "theme.gray": "Grau", + "theme.amber": "Bernstein", + "theme.lime": "Limette", + "theme.green": "Grün", + "theme.teal": "Petrol", + "theme.violet": "Violett", + "theme.rose": "Rosé", + "theme.font": "Schriftart", + "theme.use_custom_gradient": "Eigenen Gradient verwenden", + "theme.gradient_start": "Start", + "theme.gradient_end": "Ende", + "theme.apply_to_all": "Auf alle gespeicherten Datensätze anwenden", + "theme.apply_to_all_desc": "Wenn aktiviert, gilt die Designänderung für jeden gespeicherten Datensatz.", "banner.update": "Update verfügbar: v{{latest}} (Sie verwenden v{{current}})", "banner.whats_new": "Was ist neu", "banner.dismiss": "Ausblenden", diff --git a/public/shared/i18n/en.json b/public/shared/i18n/en.json index caf6c010..ab308cf1 100644 --- a/public/shared/i18n/en.json +++ b/public/shared/i18n/en.json @@ -23,6 +23,20 @@ "theme.cyan": "Cyan", "theme.indigo": "Indigo", "theme.pink": "Pink", + "theme.slate": "Slate", + "theme.gray": "Gray", + "theme.amber": "Amber", + "theme.lime": "Lime", + "theme.green": "Green", + "theme.teal": "Teal", + "theme.violet": "Violet", + "theme.rose": "Rose", + "theme.font": "Font", + "theme.use_custom_gradient": "Use custom gradient", + "theme.gradient_start": "Start", + "theme.gradient_end": "End", + "theme.apply_to_all": "Apply to all saved datasets", + "theme.apply_to_all_desc": "When enabled, changing the theme applies to every saved dataset.", "banner.update": "Update available: v{{latest}} (you're on v{{current}})", "banner.whats_new": "What's new", "banner.dismiss": "Dismiss", diff --git a/public/shared/i18n/es.json b/public/shared/i18n/es.json index 63d55ccc..e6681991 100644 --- a/public/shared/i18n/es.json +++ b/public/shared/i18n/es.json @@ -22,6 +22,20 @@ "theme.cyan": "Cian", "theme.indigo": "Índigo", "theme.pink": "Rosa", + "theme.slate": "Pizarra", + "theme.gray": "Gris", + "theme.amber": "Ámbar", + "theme.lime": "Lima", + "theme.green": "Verde", + "theme.teal": "Verde azulado", + "theme.violet": "Violeta", + "theme.rose": "Rosa pálido", + "theme.font": "Fuente", + "theme.use_custom_gradient": "Usar degradado personalizado", + "theme.gradient_start": "Inicio", + "theme.gradient_end": "Fin", + "theme.apply_to_all": "Aplicar a todos los conjuntos de datos guardados", + "theme.apply_to_all_desc": "Cuando está activado, el cambio de tema se aplica a cada conjunto de datos guardado.", "banner.update": "Actualización disponible: v{{latest}} (estás en v{{current}})", "banner.whats_new": "Novedades", "banner.dismiss": "Descartar", diff --git a/public/shared/i18n/fr.json b/public/shared/i18n/fr.json index 4a2b61e9..3365ba63 100644 --- a/public/shared/i18n/fr.json +++ b/public/shared/i18n/fr.json @@ -22,6 +22,20 @@ "theme.cyan": "Cyan", "theme.indigo": "Indigo", "theme.pink": "Rose", + "theme.slate": "Ardoise", + "theme.gray": "Gris", + "theme.amber": "Ambre", + "theme.lime": "Citron vert", + "theme.green": "Vert", + "theme.teal": "Sarcelle", + "theme.violet": "Violet", + "theme.rose": "Rose pâle", + "theme.font": "Police", + "theme.use_custom_gradient": "Utiliser un dégradé personnalisé", + "theme.gradient_start": "Début", + "theme.gradient_end": "Fin", + "theme.apply_to_all": "Appliquer à tous les jeux de données enregistrés", + "theme.apply_to_all_desc": "Lorsque cette option est activée, la modification du thème s'applique à chaque jeu de données enregistré.", "banner.update": "Mise à jour disponible : v{{latest}} (vous utilisez la v{{current}})", "banner.whats_new": "Nouveautés", "banner.dismiss": "Fermer", diff --git a/public/shared/i18n/it.json b/public/shared/i18n/it.json index b9f5168a..d47a23c1 100644 --- a/public/shared/i18n/it.json +++ b/public/shared/i18n/it.json @@ -22,6 +22,20 @@ "theme.cyan": "Ciano", "theme.indigo": "Indaco", "theme.pink": "Rosa", + "theme.slate": "Ardesia", + "theme.gray": "Grigio", + "theme.amber": "Ambra", + "theme.lime": "Lime", + "theme.green": "Verde", + "theme.teal": "Verde acqua", + "theme.violet": "Viola", + "theme.rose": "Rosato", + "theme.font": "Carattere", + "theme.use_custom_gradient": "Usa gradiente personalizzato", + "theme.gradient_start": "Inizio", + "theme.gradient_end": "Fine", + "theme.apply_to_all": "Applica a tutti i dataset salvati", + "theme.apply_to_all_desc": "Se attivo, la modifica del tema si applica a ogni dataset salvato.", "banner.update": "Aggiornamento disponibile: v{{latest}} (versione attuale v{{current}})", "banner.whats_new": "Novità", "banner.dismiss": "Ignora", diff --git a/public/shared/i18n/nl.json b/public/shared/i18n/nl.json index 56c95e60..75e4fcaa 100644 --- a/public/shared/i18n/nl.json +++ b/public/shared/i18n/nl.json @@ -22,6 +22,20 @@ "theme.cyan": "Cyaan", "theme.indigo": "Indigo", "theme.pink": "Roze", + "theme.slate": "Leisteen", + "theme.gray": "Grijs", + "theme.amber": "Amber", + "theme.lime": "Limoen", + "theme.green": "Groen", + "theme.teal": "Petrol", + "theme.violet": "Violet", + "theme.rose": "Rozé", + "theme.font": "Lettertype", + "theme.use_custom_gradient": "Gebruik aangepast verloop", + "theme.gradient_start": "Begin", + "theme.gradient_end": "Einde", + "theme.apply_to_all": "Toepassen op alle opgeslagen datasets", + "theme.apply_to_all_desc": "Indien ingeschakeld wordt het thema toegepast op elke opgeslagen dataset.", "banner.update": "Update beschikbaar: v{{latest}} (je gebruikt v{{current}})", "banner.whats_new": "Wat is nieuw", "banner.dismiss": "Sluiten", diff --git a/public/shared/i18n/pt.json b/public/shared/i18n/pt.json index f8cf7a21..bb1d5b7d 100644 --- a/public/shared/i18n/pt.json +++ b/public/shared/i18n/pt.json @@ -22,6 +22,20 @@ "theme.cyan": "Ciano", "theme.indigo": "Índigo", "theme.pink": "Rosa", + "theme.slate": "Ardósia", + "theme.gray": "Cinza", + "theme.amber": "Âmbar", + "theme.lime": "Lima", + "theme.green": "Verde", + "theme.teal": "Azul-petróleo", + "theme.violet": "Violeta", + "theme.rose": "Rosa claro", + "theme.font": "Tipo de letra", + "theme.use_custom_gradient": "Usar degradê personalizado", + "theme.gradient_start": "Início", + "theme.gradient_end": "Fim", + "theme.apply_to_all": "Aplicar a todos os conjuntos de dados guardados", + "theme.apply_to_all_desc": "Quando ativado, a alteração do tema aplica-se a cada conjunto de dados guardado.", "banner.update": "Atualização disponível: v{{latest}} (está na v{{current}})", "banner.whats_new": "Novidades", "banner.dismiss": "Dispensar", diff --git a/public/shared/i18n/zh.json b/public/shared/i18n/zh.json index 94454b31..fe32c4e1 100644 --- a/public/shared/i18n/zh.json +++ b/public/shared/i18n/zh.json @@ -22,6 +22,20 @@ "theme.cyan": "青色", "theme.indigo": "靛蓝色", "theme.pink": "粉色", + "theme.slate": "石板色", + "theme.gray": "灰色", + "theme.amber": "琥珀色", + "theme.lime": "酸橙色", + "theme.green": "绿色", + "theme.teal": "青绿色", + "theme.violet": "紫罗兰色", + "theme.rose": "玫瑰色", + "theme.font": "字体", + "theme.use_custom_gradient": "使用自定义渐变", + "theme.gradient_start": "起点", + "theme.gradient_end": "终点", + "theme.apply_to_all": "应用到所有保存的数据集", + "theme.apply_to_all_desc": "启用后,主题更改将应用于每个保存的数据集。", "banner.update": "有新版本可用:v{{latest}}(当前版本 v{{current}})", "banner.whats_new": "更新内容", "banner.dismiss": "忽略", diff --git a/public/shared/styles.css b/public/shared/styles.css index 3fe7924e..4bfc6495 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -5,6 +5,10 @@ --primary-dark: #0052cc; --primary-light: #3385ff; --accent: #00a3ff; + --gradient-start: var(--primary); + --gradient-end: var(--accent); + --header-gradient-start: var(--primary-dark); + --header-gradient-end: var(--dark); --dark: #001a4d; --light: #e6f2ff; --very-light: #f5f9ff; @@ -17,6 +21,8 @@ --gray-900: #1a1a1a; --danger: #ef4444; --success: #10b981; + --font-family-default: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --font-family: var(--font-family-default); --radius-sm: 6px; --radius-md: 10px; --radius-lg: 16px; @@ -29,7 +35,7 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + font-family: var(--font-family-default); font-size: 14px; line-height: 1.6; color: var(--gray-900); @@ -37,6 +43,10 @@ body { min-height: 100vh; } +/* CV content adopts the user-selected font; admin chrome (toolbar, modals, + settings) keeps the default Inter for brand consistency. */ +.container { font-family: var(--font-family); } + .material-symbols-outlined { font-size: inherit; vertical-align: middle; @@ -52,7 +62,7 @@ body { /* Header Section */ .header { - background: linear-gradient(135deg, var(--primary-dark), var(--dark)); + background: linear-gradient(135deg, var(--header-gradient-start), var(--header-gradient-end)); border-radius: var(--radius-xl); padding: 28px; margin-bottom: 20px; @@ -84,7 +94,12 @@ body { width: 110px; height: 110px; border-radius: 50%; - background: linear-gradient(135deg, var(--primary), var(--accent)); + background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); + /* Clip the gradient to the padding-box so it doesn't bleed into the 4px + translucent border. Otherwise a thin rim of the gradient's far-end color + peeks out around the profile picture, producing a visible sharp edge + against the header. */ + background-clip: padding-box; border: 4px solid rgba(255,255,255,0.25); display: flex; align-items: center; @@ -306,7 +321,7 @@ body { right: 10px; top: 50%; height: 5px; - background: linear-gradient(90deg, var(--primary), var(--accent)); + background: linear-gradient(90deg, var(--gradient-start), var(--gradient-end)); transform: translateY(-50%); border-radius: 2.5px; } diff --git a/src/server.js b/src/server.js index d8529b6b..02d25773 100644 --- a/src/server.js +++ b/src/server.js @@ -236,7 +236,8 @@ function servePublicIndex(req, res) { // Inject default dataset ID and language info (no DATASET_PREVIEW = no preview banner) const siblings = getDatasetSiblings(defaultDataset); - const datasetScript = ``; + const datasetTheme = data.theme || gatherTheme(); + const datasetScript = ``; html = html.replace('', `${datasetScript}`); return res.type('html').send(html); @@ -266,6 +267,11 @@ function servePublicIndex(req, res) { html = html.replace('', `\n${trackingCode}`); } + // Inject theme so the public page can apply font/gradient before paint + const fallbackTheme = gatherTheme(); + const themeScript = ``; + html = html.replace('', `${themeScript}`); + res.type('html').send(html); } catch (err) { res.sendFile(path.join(__dirname, '../public-readonly/index.html')); } } @@ -292,7 +298,8 @@ function serveDatasetPage(req, res, lang) { // Inject dataset context with language info and exact ID const siblings = getDatasetSiblings(dataset); - const datasetScript = ``; + const datasetTheme = data.theme || gatherTheme(); + const datasetScript = ``; html = html.replace('', `${datasetScript}`); // Apply noindex if slugsIndex setting is not enabled @@ -1229,7 +1236,21 @@ function gatherCvData(options = {}) { projects: projects.map(p => ({ ...p, technologies: p.technologies ? JSON.parse(p.technologies) : [], visible: !!p.visible })), sectionVisibility, sectionOrder: sectionOrderData, - customSections + customSections, + theme: gatherTheme() + }; +} + +// Read the theme settings into a normalized object. Used by gatherCvData (so +// every dataset snapshot embeds its own theme) and by SSR HTML injection when +// no default dataset exists. +function gatherTheme() { + const get = (key) => db.prepare('SELECT value FROM settings WHERE key = ?').get(key)?.value || null; + return { + primary: get('themeColor') || '#0066ff', + gradientStart: get('themeGradientStart') || null, + gradientEnd: get('themeGradientEnd') || null, + fontFamily: get('themeFontFamily') || 'Inter' }; } @@ -1993,6 +2014,59 @@ if (PUBLIC_ONLY) { app.get('/api/settings/:key', (req, res) => { const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get(req.params.key); res.json({ value: setting?.value || null }); }); app.put('/api/settings/:key', (req, res) => { db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(req.params.key, req.body.value); res.json({ success: true }); }); + // Composite theme endpoints. The legacy /api/settings/themeColor still works + // for backward compatibility, but the picker now uses these to write the + // three theme fields together and (optionally) propagate to all datasets. + app.get('/api/theme', (req, res) => { + try { res.json(gatherTheme()); } catch (err) { res.status(500).json({ error: err.message }); } + }); + app.put('/api/theme', (req, res) => { + const { primary, gradientStart, gradientEnd, fontFamily, applyToAll, currentDatasetId } = req.body || {}; + if (!primary || !/^#[0-9a-fA-F]{6}$/.test(primary)) return res.status(400).json({ error: 'Invalid primary color' }); + if (gradientStart !== null && gradientStart !== undefined && !/^#[0-9a-fA-F]{6}$/.test(gradientStart)) return res.status(400).json({ error: 'Invalid gradient start color' }); + if (gradientEnd !== null && gradientEnd !== undefined && !/^#[0-9a-fA-F]{6}$/.test(gradientEnd)) return res.status(400).json({ error: 'Invalid gradient end color' }); + const font = typeof fontFamily === 'string' && fontFamily.trim() ? fontFamily.trim() : 'Inter'; + const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + try { + const writeAll = db.transaction(() => { + upsert.run('themeColor', primary); + upsert.run('themeFontFamily', font); + if (gradientStart) upsert.run('themeGradientStart', gradientStart); + else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientStart'); + if (gradientEnd) upsert.run('themeGradientEnd', gradientEnd); + else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientEnd'); + upsert.run('applyThemeToAllDatasets', applyToAll ? 'true' : 'false'); + const themeBlob = { primary, gradientStart: gradientStart || null, gradientEnd: gradientEnd || null, fontFamily: font }; + const update = db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); + const writeTheme = (id, dataStr) => { + try { + const parsed = JSON.parse(dataStr); + parsed.theme = themeBlob; + update.run(JSON.stringify(parsed), id); + } catch (e) { /* skip malformed */ } + }; + if (applyToAll) { + db.prepare('SELECT id, data FROM saved_datasets').all().forEach(row => writeTheme(row.id, row.data)); + } else if (currentDatasetId) { + // Always include language siblings: a CV's language variants + // share visual identity, so a per-dataset theme change should + // propagate across the language_group. + const current = db.prepare('SELECT id, data, language_group FROM saved_datasets WHERE id = ?').get(currentDatasetId); + if (current) { + writeTheme(current.id, current.data); + if (current.language_group) { + db.prepare('SELECT id, data FROM saved_datasets WHERE language_group = ? AND id != ?') + .all(current.language_group, current.id) + .forEach(row => writeTheme(row.id, row.data)); + } + } + } + }); + writeAll(); + res.json({ success: true, theme: gatherTheme() }); + } catch (err) { res.status(500).json({ error: err.message }); } + }); + // Version check endpoint (admin only) app.get('/api/version', async (req, res) => { const cache = await checkLatestVersion(); @@ -2555,7 +2629,7 @@ if (PUBLIC_ONLY) { res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public }); } catch (err) { res.status(500).json({ error: err.message }); } }); - app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.profile) { const p = data.profile; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, profile_picture_enabled = ?, picture_filename = ?, picture_propagate = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages, p.profile_picture_enabled == null ? 1 : (p.profile_picture_enabled ? 1 : 0), p.picture_filename || null, p.picture_propagate == null ? 1 : (p.picture_propagate ? 1 : 0)); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public }); } catch (err) { res.status(500).json({ error: err.message }); } }); + app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.theme && typeof data.theme === 'object') { const t = data.theme; const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); if (t.primary && /^#[0-9a-fA-F]{6}$/.test(t.primary)) upsert.run('themeColor', t.primary); if (t.fontFamily) upsert.run('themeFontFamily', t.fontFamily); if (t.gradientStart && /^#[0-9a-fA-F]{6}$/.test(t.gradientStart)) upsert.run('themeGradientStart', t.gradientStart); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientStart'); if (t.gradientEnd && /^#[0-9a-fA-F]{6}$/.test(t.gradientEnd)) upsert.run('themeGradientEnd', t.gradientEnd); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientEnd'); } if (data.profile) { const p = data.profile; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, profile_picture_enabled = ?, picture_filename = ?, picture_propagate = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages, p.profile_picture_enabled == null ? 1 : (p.profile_picture_enabled ? 1 : 0), p.picture_filename || null, p.picture_propagate == null ? 1 : (p.picture_propagate ? 1 : 0)); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public, theme: data.theme || null }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.delete('/api/datasets/:id', (req, res) => { try { const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); diff --git a/tests/backend.test.js b/tests/backend.test.js index ad23207a..948e21b4 100644 --- a/tests/backend.test.js +++ b/tests/backend.test.js @@ -1489,6 +1489,125 @@ describe('Backend API', () => { }); }); + describe('Theme management', () => { + async function getJson(url) { const r = await fetch(url); return r.json(); } + async function putJson(url, body) { + const r = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + return r.json(); + } + async function createDs(name) { + const r = await fetch(`${BASE_URL}/api/datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, language: 'en' }) }); + return r.json(); + } + async function delDs(id) { await fetch(`${BASE_URL}/api/datasets/${id}`, { method: 'DELETE' }); } + + it('GET /api/theme returns defaults when nothing has been set', async () => { + // Reset known keys first + await fetch(`${BASE_URL}/api/settings/themeColor`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: null }) }); + const theme = await getJson(`${BASE_URL}/api/theme`); + assert.strictEqual(typeof theme, 'object'); + assert.ok(/^#[0-9a-fA-F]{6}$/.test(theme.primary), 'primary is a hex color'); + assert.strictEqual(theme.fontFamily, 'Inter'); + }); + + it('PUT /api/theme rejects invalid primary color', async () => { + const r = await fetch(`${BASE_URL}/api/theme`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ primary: 'not-a-color' }) }); + assert.strictEqual(r.status, 400); + }); + + it('PUT /api/theme with applyToAll=true writes theme into every dataset.data.theme', async () => { + const a = await createDs('Theme Bulk A'); + const b = await createDs('Theme Bulk B'); + const result = await putJson(`${BASE_URL}/api/theme`, { + primary: '#ff0000', gradientEnd: '#440000', fontFamily: 'Roboto', applyToAll: true + }); + assert.strictEqual(result.success, true); + // Both datasets should now have the new theme embedded + const aData = await getJson(`${BASE_URL}/api/datasets/id/${a.id}`); + const bData = await getJson(`${BASE_URL}/api/datasets/id/${b.id}`); + assert.strictEqual(aData.theme.primary, '#ff0000'); + assert.strictEqual(aData.theme.gradientEnd, '#440000'); + assert.strictEqual(aData.theme.fontFamily, 'Roboto'); + assert.strictEqual(bData.theme.primary, '#ff0000'); + assert.strictEqual(bData.theme.fontFamily, 'Roboto'); + // Settings also reflect the new theme + const settingsTheme = await getJson(`${BASE_URL}/api/theme`); + assert.strictEqual(settingsTheme.primary, '#ff0000'); + await delDs(a.id); await delDs(b.id); + }); + + it('PUT /api/theme with applyToAll=false only updates currentDatasetId (and its language siblings), not unrelated datasets', async () => { + const a = await createDs('Theme Solo A'); + const b = await createDs('Theme Solo B'); + // Seed both with one theme via applyToAll + await putJson(`${BASE_URL}/api/theme`, { primary: '#00ff00', applyToAll: true }); + // Now change only `a` with applyToAll=false + await putJson(`${BASE_URL}/api/theme`, { primary: '#0000ff', fontFamily: 'Lato', applyToAll: false, currentDatasetId: a.id }); + const aData = await getJson(`${BASE_URL}/api/datasets/id/${a.id}`); + const bData = await getJson(`${BASE_URL}/api/datasets/id/${b.id}`); + assert.strictEqual(aData.theme.primary, '#0000ff', 'A picks up the new theme'); + assert.strictEqual(aData.theme.fontFamily, 'Lato'); + assert.strictEqual(bData.theme.primary, '#00ff00', 'B (no shared language_group) retains the prior bulk-applied theme'); + await delDs(a.id); await delDs(b.id); + }); + + it('PUT /api/theme with applyToAll=false also propagates to language siblings', async () => { + // Two datasets in the same language_group + const en = await createDs('Theme Sibling Group'); + const frRes = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Theme Sibling Group', language: 'fr', language_group: en.language_group }) + }); + const fr = await frRes.json(); + // Apply a per-dataset theme (toggle off) on the English one + await putJson(`${BASE_URL}/api/theme`, { + primary: '#aabbcc', gradientStart: '#112233', gradientEnd: '#445566', + fontFamily: 'Roboto', applyToAll: false, currentDatasetId: en.id + }); + const enData = await getJson(`${BASE_URL}/api/datasets/id/${en.id}`); + const frData = await getJson(`${BASE_URL}/api/datasets/id/${fr.id}`); + assert.strictEqual(enData.theme.primary, '#aabbcc'); + assert.strictEqual(frData.theme.primary, '#aabbcc', 'sibling FR variant inherits the new theme'); + assert.strictEqual(frData.theme.gradientStart, '#112233'); + assert.strictEqual(frData.theme.gradientEnd, '#445566'); + assert.strictEqual(frData.theme.fontFamily, 'Roboto'); + await delDs(en.id); await delDs(fr.id); + }); + + it('PUT /api/theme persists both gradientStart and gradientEnd; clears them when null', async () => { + await putJson(`${BASE_URL}/api/theme`, { primary: '#abcdef', gradientStart: '#222222', gradientEnd: '#123456', applyToAll: false }); + let theme = await getJson(`${BASE_URL}/api/theme`); + assert.strictEqual(theme.gradientStart, '#222222'); + assert.strictEqual(theme.gradientEnd, '#123456'); + await putJson(`${BASE_URL}/api/theme`, { primary: '#abcdef', gradientStart: null, gradientEnd: null, applyToAll: false }); + theme = await getJson(`${BASE_URL}/api/theme`); + assert.strictEqual(theme.gradientStart, null); + assert.strictEqual(theme.gradientEnd, null); + }); + + it('Dataset load with embedded theme writes theme into settings', async () => { + // Create a dataset, manually set its data.theme, then load it + await putJson(`${BASE_URL}/api/theme`, { primary: '#aaaaaa', fontFamily: 'Inter', applyToAll: false }); + const a = await createDs('Theme Load Source'); + // Bulk-apply a custom theme so the dataset stores it + await putJson(`${BASE_URL}/api/theme`, { primary: '#cc00cc', fontFamily: 'Merriweather', applyToAll: true }); + // Reset settings to a different theme + await putJson(`${BASE_URL}/api/theme`, { primary: '#aaaaaa', fontFamily: 'Inter', applyToAll: false }); + const beforeLoad = await getJson(`${BASE_URL}/api/theme`); + assert.strictEqual(beforeLoad.primary, '#aaaaaa'); + // Load the dataset — server should re-write the dataset's theme into settings + const loadRes = await fetch(`${BASE_URL}/api/datasets/${a.id}/load`, { method: 'POST' }); + const loadJson = await loadRes.json(); + assert.strictEqual(loadJson.success, true); + assert.ok(loadJson.theme, 'load response includes theme'); + assert.strictEqual(loadJson.theme.primary, '#cc00cc'); + const afterLoad = await getJson(`${BASE_URL}/api/theme`); + assert.strictEqual(afterLoad.primary, '#cc00cc'); + assert.strictEqual(afterLoad.fontFamily, 'Merriweather'); + await delDs(a.id); + }); + }); + describe('Public API (port)', () => { it('GET /api/profile returns 200', async () => { const res = await fetch(`${PUBLIC_URL}/api/profile`); diff --git a/version.json b/version.json index 38629a29..44f7ae7a 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.37.1", + "version": "1.38.0", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" }