Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
61 changes: 50 additions & 11 deletions public-readonly/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -866,28 +866,67 @@ <h3 class="project-title" itemprop="name">${escapeHtml(proj.title)}</h3>
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) {
Expand Down
74 changes: 73 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<div class="color-preview" id="colorPreview"></div>
<input type="text" class="color-hex-input" id="colorHexInput" value="#0066ff" maxlength="7">
</div>
<div class="color-presets">
<div class="color-presets" id="primaryPresets">
<div class="color-preset" style="background: #0066ff" data-color="#0066ff" data-i18n-title="theme.blue" title="Blue (Default)"></div>
<div class="color-preset" style="background: #059669" data-color="#059669" data-i18n-title="theme.emerald" title="Emerald"></div>
<div class="color-preset" style="background: #7c3aed" data-color="#7c3aed" data-i18n-title="theme.purple" title="Purple"></div>
Expand All @@ -50,7 +50,79 @@
<div class="color-preset" style="background: #0891b2" data-color="#0891b2" data-i18n-title="theme.cyan" title="Cyan"></div>
<div class="color-preset" style="background: #4f46e5" data-color="#4f46e5" data-i18n-title="theme.indigo" title="Indigo"></div>
<div class="color-preset" style="background: #be185d" data-color="#be185d" data-i18n-title="theme.pink" title="Pink"></div>
<div class="color-preset" style="background: #111827" data-color="#111827" data-i18n-title="theme.slate" title="Slate"></div>
<div class="color-preset" style="background: #6b7280" data-color="#6b7280" data-i18n-title="theme.gray" title="Gray"></div>
<div class="color-preset" style="background: #d97706" data-color="#d97706" data-i18n-title="theme.amber" title="Amber"></div>
<div class="color-preset" style="background: #65a30d" data-color="#65a30d" data-i18n-title="theme.lime" title="Lime"></div>
<div class="color-preset" style="background: #16a34a" data-color="#16a34a" data-i18n-title="theme.green" title="Green"></div>
<div class="color-preset" style="background: #0d9488" data-color="#0d9488" data-i18n-title="theme.teal" title="Teal"></div>
<div class="color-preset" style="background: #8b5cf6" data-color="#8b5cf6" data-i18n-title="theme.violet" title="Violet"></div>
<div class="color-preset" style="background: #f43f5e" data-color="#f43f5e" data-i18n-title="theme.rose" title="Rose"></div>
</div>

<div class="theme-section">
<label class="theme-section-label" data-i18n="theme.font">Font</label>
<div class="theme-font-picker">
<button type="button" class="theme-font-trigger" id="themeFontTrigger">
<span id="themeFontLabel">Inter</span>
<span class="material-symbols-outlined">expand_more</span>
</button>
<div class="theme-font-list" id="themeFontList"></div>
</div>
</div>

<div class="theme-section">
<label class="theme-section-row">
<input type="checkbox" id="themeUseGradient">
<span data-i18n="theme.use_custom_gradient">Use custom gradient end color</span>
</label>
<div class="theme-gradient-picker" id="gradientPickerWrapper" style="display:none;">
<div class="gradient-endpoint-tabs">
<button type="button" class="gradient-endpoint-tab" data-endpoint="start">
<span class="gradient-endpoint-swatch" id="gradientStartSwatch"></span>
<span data-i18n="theme.gradient_start">Start</span>
</button>
<button type="button" class="gradient-endpoint-tab active" data-endpoint="end">
<span class="gradient-endpoint-swatch" id="gradientEndSwatch"></span>
<span data-i18n="theme.gradient_end">End</span>
</button>
</div>
<div class="color-wheel-container">
<canvas id="gradientWheel" width="180" height="180"></canvas>
<div class="color-wheel-cursor" id="gradientWheelCursor"></div>
</div>
<div class="color-brightness">
<label data-i18n="theme.brightness">Brightness</label>
<input type="range" id="gradientBrightness" min="30" max="70" value="35">
</div>
<div class="color-preview-row">
<div class="color-preview" id="gradientPreview"></div>
<input type="text" class="color-hex-input" id="gradientHexInput" value="#003c99" maxlength="7">
</div>
<div class="gradient-pair-presets" id="gradientPairPresets">
<div class="gradient-pair-preset" data-start="#0066ff" data-end="#001a4d" style="background: linear-gradient(135deg, #0066ff, #001a4d)" title="Ocean Deep"></div>
<div class="gradient-pair-preset" data-start="#1e3a8a" data-end="#0f172a" style="background: linear-gradient(135deg, #1e3a8a, #0f172a)" title="Midnight"></div>
<div class="gradient-pair-preset" data-start="#10b981" data-end="#064e3b" style="background: linear-gradient(135deg, #10b981, #064e3b)" title="Emerald Fade"></div>
<div class="gradient-pair-preset" data-start="#16a34a" data-end="#14532d" style="background: linear-gradient(135deg, #16a34a, #14532d)" title="Forest"></div>
<div class="gradient-pair-preset" data-start="#8b5cf6" data-end="#1e1b4b" style="background: linear-gradient(135deg, #8b5cf6, #1e1b4b)" title="Aurora"></div>
<div class="gradient-pair-preset" data-start="#4f46e5" data-end="#1e1b4b" style="background: linear-gradient(135deg, #4f46e5, #1e1b4b)" title="Royal"></div>
<div class="gradient-pair-preset" data-start="#0891b2" data-end="#1e1b4b" style="background: linear-gradient(135deg, #0891b2, #1e1b4b)" title="Twilight"></div>
<div class="gradient-pair-preset" data-start="#14b8a6" data-end="#134e4a" style="background: linear-gradient(135deg, #14b8a6, #134e4a)" title="Teal"></div>
<div class="gradient-pair-preset" data-start="#f97316" data-end="#be185d" style="background: linear-gradient(135deg, #f97316, #be185d)" title="Sunset"></div>
<div class="gradient-pair-preset" data-start="#f43f5e" data-end="#881337" style="background: linear-gradient(135deg, #f43f5e, #881337)" title="Rose"></div>
<div class="gradient-pair-preset" data-start="#dc2626" data-end="#7f1d1d" style="background: linear-gradient(135deg, #dc2626, #7f1d1d)" title="Crimson"></div>
<div class="gradient-pair-preset" data-start="#475569" data-end="#0f172a" style="background: linear-gradient(135deg, #475569, #0f172a)" title="Graphite"></div>
</div>
</div>
</div>

<div class="theme-section">
<label class="theme-section-row">
<input type="checkbox" id="themeApplyToAll" checked>
<span data-i18n="theme.apply_to_all">Apply to all saved datasets</span>
</label>
</div>

<div class="color-picker-actions">
<button class="btn btn-ghost btn-sm" onclick="resetThemeColor()" data-i18n="theme.reset">Reset</button>
<button class="btn btn-primary btn-sm" onclick="applyThemeColor()" data-i18n="theme.apply">Apply</button>
Expand Down
100 changes: 99 additions & 1 deletion public/shared/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
=========================== */
Expand Down
Loading
Loading