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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ 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.32.0] - 2026-04-17

### Added
- Profile picture library: upload multiple pictures, reuse any of them from a picker grid, and delete unused entries. Mirrors the company-logo library pattern.
- "Apply this picture to all datasets" toggle in the profile modal (enabled by default). When on, uploading or selecting a picture mirrors the change into every saved dataset snapshot; when off, each dataset keeps its own picture and uploading/selecting/removing a picture while editing a localized dataset also syncs every language sibling in the same `language_group` (unrelated datasets remain untouched).
- New backend endpoints: `GET /api/profile-pictures`, `DELETE /api/profile-pictures/:filename`, `PUT /api/profile/picture/select`, `POST /api/profile-pictures/apply-global`. Each picture mutation accepts an optional `current_dataset_id` so the server can resolve siblings.
- Legacy `uploads/picture.jpeg` is promoted into the new library format on first run so existing installs keep their picture.

### Fixed
- Public site now honors the "Show profile picture" toggle. The picture was previously always rendered on `/`, `/<lang>`, and `/v/<slug>` regardless of the setting. Public `/api/cv`, `/api/profile`, and the dataset-load endpoint now carry the toggle and filename, and the public renderer respects both.
- Turning off "Show profile picture" now truly hides the profile picture circle on both admin and public views, even when "Open to Work" is enabled. The OTW overlay used to force the container visible (which shows the colored gradient circle) to give the badge a host; the badge is now suppressed together with the picture so disabling the picture actually removes the whole header circle.

## [1.31.0] - 2026-04-17

### Added
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.31.0",
"version": "1.32.0",
"description": "Professional CV Management System",
"main": "src/server.js",
"scripts": {
Expand Down
29 changes: 20 additions & 9 deletions public-readonly/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -385,19 +385,30 @@ <h2 class="section-title" data-i18n="section.projects">Featured Projects</h2>

const pic = document.getElementById('profilePicture');
const initials = document.getElementById('profileInitials');
pic.onload = () => { pic.style.display = 'block'; initials.style.display = 'none'; };
pic.onerror = () => { pic.style.display = 'none'; initials.style.display = 'block'; };
pic.src = '/uploads/picture.jpeg?' + new Date().getTime();

// Open to Work overlay on profile picture
const profileImg = document.getElementById('profileImage');
if (p.profile_picture_enabled == 1) {
const fname = p.picture_filename || 'picture.jpeg';
pic.onload = () => { pic.style.display = 'block'; initials.style.display = 'none'; };
pic.onerror = () => { pic.style.display = 'none'; initials.style.display = 'block'; };
pic.src = '/uploads/' + encodeURIComponent(fname) + '?' + new Date().getTime();
profileImg.style.display = 'flex';
} else {
pic.onload = null;
pic.onerror = null;
pic.removeAttribute('src');
pic.style.display = 'none';
initials.style.display = 'block';
profileImg.style.display = 'none';
}

// Open to Work overlay on profile picture — only when the picture is shown,
// since the overlay has no host circle to sit on otherwise.
const existingOtw = document.querySelector('.open-to-work-overlay');
if (existingOtw) existingOtw.remove();
if (p.open_to_work == 1) {
const profileEl = document.getElementById('profileImage');
if (p.open_to_work == 1 && p.profile_picture_enabled == 1) {
const overlay = document.createElement('div');
overlay.className = 'open-to-work-overlay';
profileEl.appendChild(overlay);
if (p.profile_picture_enabled != 1) profileEl.style.display = 'flex';
profileImg.appendChild(overlay);
}

const badges = [];
Expand Down
101 changes: 96 additions & 5 deletions public/shared/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,7 @@ function profileForm(d) {
<label class="form-label">${t('form.profile_picture')}</label>
<div class="profile-upload-container">
<div class="profile-upload-preview" id="profileUploadPreview">
<img src="/uploads/picture.jpeg?${Date.now()}" alt="" id="profilePreviewImg" onerror="this.style.display='none';document.getElementById('profilePreviewInitials').style.display='flex';">
<img src="${d.picture_filename ? '/uploads/' + encodeURIComponent(d.picture_filename) : '/uploads/picture.jpeg'}?${Date.now()}" alt="" id="profilePreviewImg" onerror="this.style.display='none';document.getElementById('profilePreviewInitials').style.display='flex';">
<div class="profile-preview-initials" id="profilePreviewInitials" style="display:none;">${escapeHtml(d.initials || 'CV')}</div>
</div>
<div class="profile-upload-actions">
Expand All @@ -1279,12 +1279,24 @@ function profileForm(d) {
<span class="material-symbols-outlined" style="font-size:14px">image</span>
${t('form.choose_image')}
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick="showPicturePicker()">
<span class="material-symbols-outlined" style="font-size:14px">inventory_2</span>
${t('form.use_existing')}
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick="removeProfilePicture()">
<span class="material-symbols-outlined" style="font-size:14px">delete</span>
${t('form.remove')}
</button>
</div>
</div>
<div class="logo-picker-grid" id="picturePickerGrid" style="display:none;"></div>
<div style="display:flex;align-items:center;gap:10px;margin-top:8px;">
<label class="toggle-switch">
<input type="checkbox" id="f-picturePropagate" ${d.picture_propagate == 0 ? '' : 'checked'}>
<span class="toggle-slider"></span>
</label>
<span class="form-hint" style="margin:0">${t('form.apply_picture_globally')}</span>
</div>
<div class="form-hint">${t('form.picture_hint')}</div>
</div>
<div class="form-group">
Expand Down Expand Up @@ -1605,7 +1617,8 @@ async function saveItem() {
let data = {};

switch (type) {
case 'profile':
case 'profile': {
const propagate = checked('f-picturePropagate');
data = {
name: val('f-name'),
initials: val('f-initials'),
Expand All @@ -1619,12 +1632,19 @@ async function saveItem() {
phone: val('f-phone'),
visible: true,
profile_picture_enabled: checked('f-profilePictureEnabled'),
picture_propagate: propagate,
open_to_work: checked('f-openToWork')
};
await api('/api/profile', { method: 'PUT', body: data });
await uploadProfilePicture();
const pictureResult = await uploadProfilePicture();
if (propagate) {
// After the picture save, mirror the current picture filename across all datasets.
const current = await api('/api/profile');
await api('/api/profile-pictures/apply-global', { method: 'POST', body: { picture_filename: current.picture_filename || null } });
}
await loadProfile(true);
break;
}

case 'experience':
// Normalize dates
Expand Down Expand Up @@ -2016,22 +2036,93 @@ function removeProfilePicture() {
}

async function uploadProfilePicture() {
// Siblings of a localized dataset should share the picture even when "Apply to all datasets" is off.
// The server uses this to look up the language_group and sync the siblings.
const ctxId = activeDatasetId || '';
if (pendingProfilePicture === 'remove') {
try { await fetch('/api/profile/picture', { method: 'DELETE' }); } catch (err) {}
const url = ctxId ? `/api/profile/picture?current_dataset_id=${encodeURIComponent(ctxId)}` : '/api/profile/picture';
try { await fetch(url, { method: 'DELETE' }); } catch (err) {}
pendingProfilePicture = null;
return;
return null;
}
if (pendingProfilePicture && typeof pendingProfilePicture === 'object' && pendingProfilePicture.reuse) {
const filename = pendingProfilePicture.reuse;
try {
await api('/api/profile/picture/select', { method: 'PUT', body: { filename, current_dataset_id: ctxId || undefined } });
} catch (err) {
toast(t('toast.upload_failed'), 'error');
}
pendingProfilePicture = null;
return filename;
}
if (pendingProfilePicture && pendingProfilePicture instanceof File) {
const formData = new FormData();
formData.append('picture', pendingProfilePicture);
if (ctxId) formData.append('current_dataset_id', String(ctxId));
try {
const response = await fetch('/api/profile/picture', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
pendingProfilePicture = null;
return result.filename || null;
} catch (err) {
toast(t('toast.upload_failed'), 'error');
}
pendingProfilePicture = null;
}
return null;
}

async function showPicturePicker() {
const grid = document.getElementById('picturePickerGrid');
if (!grid) return;
if (grid.style.display !== 'none') { grid.style.display = 'none'; return; }
try {
const pictures = await api('/api/profile-pictures');
if (!pictures.length) { toast(t('form.no_pictures_available'), 'info'); return; }
grid.innerHTML = pictures.map(p => {
const label = p.in_use ? `<span class="logo-picker-in-use">${t('form.in_use')}</span>` : '';
const del = !p.in_use ? `<button type="button" class="logo-picker-delete" onclick="event.stopPropagation();deleteUnusedPicture('${escapeHtml(p.filename)}')" title="${t('form.delete_picture')}">×</button>` : '';
return `<div class="logo-picker-item">
<div class="logo-picker-img" onclick="selectExistingPicture('${escapeHtml(p.filename)}')">
<img src="/uploads/${encodeURIComponent(p.filename)}?${Date.now()}" alt="">
</div>
${label}${del}
</div>`;
}).join('');
grid.style.display = 'flex';
} catch (err) {
toast(t('toast.upload_failed'), 'error');
}
}

async function deleteUnusedPicture(filename) {
if (!confirm(t('confirm.delete_picture'))) return;
try {
const res = await api(`/api/profile-pictures/${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (res.error) { toast(t('toast.picture_in_use'), 'error'); return; }
toast(t('toast.picture_deleted'), 'success');
const grid = document.getElementById('picturePickerGrid');
if (grid) grid.style.display = 'none';
showPicturePicker();
} catch (err) {
toast(t('toast.cannot_delete_picture'), 'error');
}
}

function selectExistingPicture(filename) {
pendingProfilePicture = { reuse: filename };
const img = document.getElementById('profilePreviewImg');
const initials = document.getElementById('profilePreviewInitials');
if (img) {
img.src = `/uploads/${encodeURIComponent(filename)}?${Date.now()}`;
img.style.display = 'block';
}
if (initials) initials.style.display = 'none';
const grid = document.getElementById('picturePickerGrid');
if (grid) grid.style.display = 'none';
const fileInput = document.getElementById('f-picture');
if (fileInput) fileInput.value = '';
}

// Company logo upload
Expand Down
7 changes: 7 additions & 0 deletions public/shared/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
"form.choose_image": "Bild auswählen",
"form.remove": "Entfernen",
"form.picture_hint": "Empfohlen: Quadratisches Bild, mindestens 200x200 Pixel. JPEG, PNG oder WebP.",
"form.apply_picture_globally": "Dieses Bild auf alle Datensätze anwenden",
"form.delete_picture": "Bild löschen",
"form.no_pictures_available": "Noch keine Bilder in der Bibliothek. Laden Sie zuerst eines hoch.",
"form.company_logo": "Firmenlogo",
"form.logo_hint": "Ein kleines quadratisches Bild ist ideal. JPEG, PNG oder WebP, max. 5MB.",
"form.use_existing": "Vorhandenes verwenden",
Expand Down Expand Up @@ -318,6 +321,9 @@
"toast.logo_upload_failed": "Logo-Upload fehlgeschlagen",
"toast.no_existing_logos": "Keine vorhandenen Logos gefunden. Laden Sie zuerst eines hoch.",
"toast.logo_deleted": "Logo gelöscht",
"toast.picture_deleted": "Bild gelöscht",
"toast.picture_in_use": "Bild wird noch verwendet und kann nicht gelöscht werden.",
"toast.cannot_delete_picture": "Bild konnte nicht gelöscht werden",
"toast.order_saved": "Reihenfolge gespeichert",
"toast.order_failed": "Reihenfolge konnte nicht gespeichert werden",
"toast.setting_saved": "Einstellung gespeichert",
Expand Down Expand Up @@ -368,6 +374,7 @@
"confirm.delete_section": "Diesen benutzerdefinierten Abschnitt und alle seine Einträge löschen? Dies kann nicht rückgängig gemacht werden.",
"confirm.delete_custom_item": "Diesen Eintrag löschen?",
"confirm.delete_logo": "Dieses unbenutzte Logo löschen? Dies kann nicht rückgängig gemacht werden.",
"confirm.delete_picture": "Dieses unbenutzte Bild löschen? Dies kann nicht rückgängig gemacht werden.",
"confirm.overwrite_dataset": "\"{{name}}\" überschreiben? Der vorhandene Lebenslauf wird durch die aktuellen Daten ersetzt.",
"duration.years": "{{count}} J.",
"duration.months": "{{count}} Mon.",
Expand Down
7 changes: 7 additions & 0 deletions public/shared/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@
"form.choose_image": "Choose Image",
"form.remove": "Remove",
"form.picture_hint": "Recommended: Square image, at least 200x200 pixels. JPEG, PNG or WebP.",
"form.apply_picture_globally": "Apply this picture to all datasets",
"form.delete_picture": "Delete picture",
"form.no_pictures_available": "No pictures in the library yet. Upload one first.",
"form.company_logo": "Company Logo",
"form.logo_hint": "Small square image works best. JPEG, PNG or WebP, max 5MB.",
"form.use_existing": "Use Existing",
Expand Down Expand Up @@ -347,6 +350,9 @@
"toast.logo_upload_failed": "Failed to upload logo",
"toast.no_existing_logos": "No existing logos found. Upload one first.",
"toast.logo_deleted": "Logo deleted",
"toast.picture_deleted": "Picture deleted",
"toast.picture_in_use": "Picture is still in use and cannot be deleted.",
"toast.cannot_delete_picture": "Failed to delete picture",
"toast.order_saved": "Order saved",
"toast.order_failed": "Failed to save order",
"toast.setting_saved": "Setting saved",
Expand Down Expand Up @@ -398,6 +404,7 @@
"confirm.delete_section": "Delete this custom section and all its items? This cannot be undone.",
"confirm.delete_custom_item": "Delete this item?",
"confirm.delete_logo": "Delete this unused logo? This cannot be undone.",
"confirm.delete_picture": "Delete this unused picture? This cannot be undone.",
"confirm.overwrite_dataset": "Overwrite \"{{name}}\"? The existing CV will be replaced with the current data.",

"duration.years": "{{count}} yr",
Expand Down
7 changes: 7 additions & 0 deletions public/shared/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
"form.choose_image": "Elegir imagen",
"form.remove": "Eliminar",
"form.picture_hint": "Recomendado: imagen cuadrada, al menos 200x200 píxeles. JPEG, PNG o WebP.",
"form.apply_picture_globally": "Aplicar esta foto a todos los conjuntos de datos",
"form.delete_picture": "Eliminar foto",
"form.no_pictures_available": "Aún no hay fotos en la biblioteca. Sube una primero.",
"form.company_logo": "Logo de la empresa",
"form.logo_hint": "Una imagen cuadrada pequeña funciona mejor. JPEG, PNG o WebP, máx. 5MB.",
"form.use_existing": "Usar existente",
Expand Down Expand Up @@ -318,6 +321,9 @@
"toast.logo_upload_failed": "Error al subir el logo",
"toast.no_existing_logos": "No se encontraron logos existentes. Sube uno primero.",
"toast.logo_deleted": "Logo eliminado",
"toast.picture_deleted": "Foto eliminada",
"toast.picture_in_use": "La foto aún está en uso y no se puede eliminar.",
"toast.cannot_delete_picture": "No se pudo eliminar la foto",
"toast.order_saved": "Orden guardado",
"toast.order_failed": "Error al guardar el orden",
"toast.setting_saved": "Configuración guardada",
Expand Down Expand Up @@ -368,6 +374,7 @@
"confirm.delete_section": "¿Eliminar esta sección personalizada y todos sus elementos? Esta acción no se puede deshacer.",
"confirm.delete_custom_item": "¿Eliminar este elemento?",
"confirm.delete_logo": "¿Eliminar este logo no utilizado? Esta acción no se puede deshacer.",
"confirm.delete_picture": "¿Eliminar esta foto no utilizada? Esta acción no se puede deshacer.",
"confirm.overwrite_dataset": "¿Sobrescribir \"{{name}}\"? El CV existente se reemplazará con los datos actuales.",
"duration.years": "{{count}} año",
"duration.months": "{{count}} meses",
Expand Down
7 changes: 7 additions & 0 deletions public/shared/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
"form.choose_image": "Choisir une image",
"form.remove": "Supprimer",
"form.picture_hint": "Recommandé : Image carrée, au moins 200x200 pixels. JPEG, PNG ou WebP.",
"form.apply_picture_globally": "Appliquer cette photo à tous les jeux de données",
"form.delete_picture": "Supprimer la photo",
"form.no_pictures_available": "Aucune photo dans la bibliothèque. Téléchargez-en une d'abord.",
"form.company_logo": "Logo de l'entreprise",
"form.logo_hint": "Une image carrée fonctionne mieux. JPEG, PNG ou WebP, max 5 Mo.",
"form.use_existing": "Utiliser existant",
Expand Down Expand Up @@ -318,6 +321,9 @@
"toast.logo_upload_failed": "Échec du téléchargement du logo",
"toast.no_existing_logos": "Aucun logo existant trouvé. Téléchargez-en un d'abord.",
"toast.logo_deleted": "Logo supprimé",
"toast.picture_deleted": "Photo supprimée",
"toast.picture_in_use": "La photo est encore utilisée et ne peut pas être supprimée.",
"toast.cannot_delete_picture": "Échec de la suppression de la photo",
"toast.order_saved": "Ordre enregistré",
"toast.order_failed": "Échec de l'enregistrement de l'ordre",
"toast.setting_saved": "Paramètre enregistré",
Expand Down Expand Up @@ -368,6 +374,7 @@
"confirm.delete_section": "Supprimer cette section personnalisée et tous ses éléments ? Cette action est irréversible.",
"confirm.delete_custom_item": "Supprimer cet élément ?",
"confirm.delete_logo": "Supprimer ce logo inutilisé ? Cette action est irréversible.",
"confirm.delete_picture": "Supprimer cette photo inutilisée ? Cette action est irréversible.",
"confirm.overwrite_dataset": "Écraser « {{name}} » ? Le CV existant sera remplacé par les données actuelles.",
"duration.years": "{{count}} an",
"duration.months": "{{count}} mois",
Expand Down
Loading
Loading