Reset
Apply
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"
}