diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9da3084..8ff5da99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ 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.47.1] - 2026-04-21
+
+### Fixed
+- **Profile-picture cropper modal no longer bleeds through to the form behind it.** The outer dialog was named `.cropper-modal`, which collides with Cropper.js's own `.cropper-modal` class — the library's internal dark overlay (the mask outside the crop box) was inheriting our `position: fixed; inset: 0;` rules and getting stretched across the viewport, pushing the form and the original modal backdrop through the dialog area. Renamed the outer wrapper + inner panel + header to `.pp-crop-modal*` so Cropper.js's internal elements keep their intended sizing and our dialog chrome stays opaque.
+
+## [1.47.0] - 2026-04-21
+
+### Added
+- **LinkedIn-style profile-picture adjustment (zoom + pan).** Uploading or re-opening a profile picture now opens a circular cropper (powered by [Cropper.js](https://github.com/fengyuanchen/cropperjs), loaded via CDN) with a drag-to-reposition stage and a zoom slider (1× to 4×). The original upload is preserved untouched on disk; only normalized crop metadata (`offsetX`, `offsetY`, `zoom`) is stored, applied on every render via CSS `object-position` + `transform: scale()`, and honoured by both the admin page and the public read-only page. A new **Adjust** button in the profile modal lets users re-frame the saved picture at any time without re-uploading. New `picture_crop TEXT` column on `profile` with auto-migration; new `PUT /api/profile/picture/crop` endpoint (values are clamped server-side); uploading, selecting, or removing a picture resets the crop to defaults. Crop propagation follows the existing `picture_propagate` flag semantics: ON mirrors into every dataset snapshot, OFF mirrors only into language siblings of the active dataset, exactly mirroring how the filename already propagates. Seven new i18n keys (`form.adjust_picture`, `form.adjust_hint`, `form.zoom`, `modal.adjust_picture`, `btn.reset_crop`, `toast.crop_saved`, `toast.no_picture`) translated across all 8 supported locales. Existing installs render exactly as before because the default crop `{offsetX:0, offsetY:0, zoom:1}` is visually identical to today's centered `object-fit: cover` baseline.
+
## [1.46.0] - 2026-04-21
### Added
diff --git a/package-lock.json b/package-lock.json
index 3b604c00..8b652f71 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cv-manager",
- "version": "1.46.0",
+ "version": "1.47.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cv-manager",
- "version": "1.46.0",
+ "version": "1.47.1",
"dependencies": {
"archiver": "^7.0.1",
"better-sqlite3": "^9.4.3",
diff --git a/package.json b/package.json
index 4365d6cc..6c6d9448 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cv-manager",
- "version": "1.46.0",
+ "version": "1.47.1",
"description": "Professional CV Management System",
"main": "src/server.js",
"scripts": {
diff --git a/public/index.html b/public/index.html
index e347dc70..6710da70 100644
--- a/public/index.html
+++ b/public/index.html
@@ -12,6 +12,9 @@
+
+
+
@@ -500,6 +503,33 @@ Featured Projects
+
+
+
+
+
+
+
+
Drag to reposition, use the slider or pinch to zoom.
+
+ Zoom
+
+
+
+ Reset
+ Cancel
+ Save
+
+
+
+
diff --git a/public/shared/admin.css b/public/shared/admin.css
index 0c93944a..1012683f 100644
--- a/public/shared/admin.css
+++ b/public/shared/admin.css
@@ -613,6 +613,68 @@
.profile-preview-initials { font-size: 24px; font-weight: 700; color: var(--white); display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.profile-upload-actions { display: flex; flex-direction: column; gap: 8px; }
+/* Profile picture cropper modal (LinkedIn-style zoom + pan).
+ Cropper.js CSS lives on the CDN; these rules style the surrounding chrome
+ and force the crop box into a circle to match the live profile circle.
+ The outer classes are prefixed `pp-crop-` to avoid colliding with
+ Cropper.js's own `.cropper-modal` class (which is its internal dark
+ overlay outside the crop box). */
+.pp-crop-modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+.pp-crop-modal-inner {
+ background: var(--white);
+ border-radius: 12px;
+ padding: 20px;
+ width: min(520px, 94vw);
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
+}
+.pp-crop-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+.pp-crop-modal-header h3 { margin: 0; font-size: 18px; }
+.cropper-stage {
+ width: 100%;
+ height: 360px;
+ background: #000;
+ border-radius: 8px;
+ overflow: hidden;
+}
+.cropper-stage img { max-width: 100%; display: block; }
+.cropper-hint {
+ margin: 10px 0 0;
+ font-size: 12px;
+ color: var(--muted, #666);
+}
+.cropper-controls {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-top: 12px;
+}
+.cropper-controls label { font-size: 13px; color: var(--muted, #666); }
+.cropper-controls input[type=range] { flex: 1; }
+.cropper-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 16px;
+}
+/* Circular crop overlay so the preview matches the 110px profile circle.
+ These target Cropper.js's own internal elements — keep the class names. */
+.cropper-view-box,
+.cropper-face { border-radius: 50%; }
+
/* Company Logo Upload */
.logo-upload-container { display: flex; align-items: center; gap: 16px; }
.logo-upload-preview {
diff --git a/public/shared/admin.js b/public/shared/admin.js
index a848f906..89f38ee7 100644
--- a/public/shared/admin.js
+++ b/public/shared/admin.js
@@ -1381,7 +1381,7 @@ function profileForm(d) {
${t('form.profile_picture')}
-
+
${escapeHtml(d.initials || 'CV')}
@@ -1397,6 +1397,10 @@ function profileForm(d) {
image
${t('form.choose_image')}
+
+ crop
+ ${t('form.adjust_picture')}
+
inventory_2
${t('form.use_existing')}
@@ -2160,6 +2164,9 @@ function checked(id) {
// Profile Picture Functions
let pendingProfilePicture = null;
+// Crop metadata queued alongside a pending upload/reuse; applied server-side after the
+// file is saved. null means "no change"; { offsetX, offsetY, zoom } overrides.
+let pendingProfilePictureCrop = null;
function previewProfilePicture(input) {
if (input.files && input.files[0]) {
@@ -2170,6 +2177,7 @@ function previewProfilePicture(input) {
return;
}
pendingProfilePicture = file;
+ pendingProfilePictureCrop = null;
const reader = new FileReader();
reader.onload = function(e) {
const img = document.getElementById('profilePreviewImg');
@@ -2177,6 +2185,11 @@ function previewProfilePicture(input) {
img.src = e.target.result;
img.style.display = 'block';
initials.style.display = 'none';
+ applyProfilePictureCrop(img, null);
+ // Open the cropper automatically so the user can frame the new image
+ // (LinkedIn parity: uploading opens the adjuster; they can Cancel to
+ // accept the default centered cover-fit).
+ openCropperForNewUpload(e.target.result);
};
reader.readAsDataURL(file);
}
@@ -2184,17 +2197,26 @@ function previewProfilePicture(input) {
function removeProfilePicture() {
pendingProfilePicture = 'remove';
+ pendingProfilePictureCrop = null;
const img = document.getElementById('profilePreviewImg');
const initials = document.getElementById('profilePreviewInitials');
img.style.display = 'none';
initials.style.display = 'flex';
+ applyProfilePictureCrop(img, null);
document.getElementById('f-picture').value = '';
+ const adjustBtn = document.getElementById('adjustPictureBtn');
+ if (adjustBtn) adjustBtn.disabled = true;
}
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 || '';
+ // Snapshot + clear the queued crop up-front so an early return (remove path)
+ // doesn't leave a stale crop behind to be applied on the next save.
+ const queuedCrop = pendingProfilePictureCrop;
+ pendingProfilePictureCrop = null;
+ let filenameResult = null;
if (pendingProfilePicture === 'remove') {
const url = ctxId ? `/api/profile/picture?current_dataset_id=${encodeURIComponent(ctxId)}` : '/api/profile/picture';
try { await fetch(url, { method: 'DELETE' }); } catch (err) {}
@@ -2205,13 +2227,12 @@ async function uploadProfilePicture() {
const filename = pendingProfilePicture.reuse;
try {
await api('/api/profile/picture/select', { method: 'PUT', body: { filename, current_dataset_id: ctxId || undefined } });
+ filenameResult = filename;
} catch (err) {
toast(t('toast.upload_failed'), 'error');
}
pendingProfilePicture = null;
- return filename;
- }
- if (pendingProfilePicture && pendingProfilePicture instanceof File) {
+ } else if (pendingProfilePicture && pendingProfilePicture instanceof File) {
const formData = new FormData();
formData.append('picture', pendingProfilePicture);
if (ctxId) formData.append('current_dataset_id', String(ctxId));
@@ -2219,14 +2240,25 @@ async function uploadProfilePicture() {
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;
+ filenameResult = result.filename || null;
} catch (err) {
toast(t('toast.upload_failed'), 'error');
}
pendingProfilePicture = null;
}
- return null;
+ // Apply queued crop after the file is live on the server. The server resets
+ // picture_crop on upload/select, so we always PUT our queued value (even
+ // defaults) to replace the server's NULL with the user's intent.
+ if (filenameResult && queuedCrop) {
+ try {
+ const propagate = checked('f-picturePropagate');
+ await api('/api/profile/picture/crop', {
+ method: 'PUT',
+ body: { offsetX: queuedCrop.offsetX, offsetY: queuedCrop.offsetY, zoom: queuedCrop.zoom, applyToAll: propagate, current_dataset_id: ctxId || undefined }
+ });
+ } catch (err) { /* non-fatal: default crop remains */ }
+ }
+ return filenameResult;
}
async function showPicturePicker() {
@@ -2268,17 +2300,193 @@ async function deleteUnusedPicture(filename) {
function selectExistingPicture(filename) {
pendingProfilePicture = { reuse: filename };
+ pendingProfilePictureCrop = null;
const img = document.getElementById('profilePreviewImg');
const initials = document.getElementById('profilePreviewInitials');
if (img) {
img.src = `/uploads/${encodeURIComponent(filename)}?${Date.now()}`;
img.style.display = 'block';
+ applyProfilePictureCrop(img, null);
}
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 = '';
+ const adjustBtn = document.getElementById('adjustPictureBtn');
+ if (adjustBtn) adjustBtn.disabled = false;
+}
+
+// --- LinkedIn-style profile picture cropper (zoom + pan) ---
+//
+// The math:
+// Let W, H = natural image size, L = min(W, H). The display circle uses
+// object-fit: cover, so the image is scaled to side length L before any
+// crop is applied. Our stored zoom is relative to that cover-fit baseline,
+// so zoom = L / cropBoxWidth (Cropper returns crop-box dims in natural px).
+// Offsets are the difference between the crop-box centre and the image centre
+// as a percentage of the natural dimensions — they become the object-position
+// CSS value on the display img.
+//
+// readCropFromCropper and seedCropperFromCrop are kept pure so tests can
+// round-trip them without instantiating Cropper.
+const DEFAULT_CROP = { offsetX: 0, offsetY: 0, zoom: 1 };
+let cropperInstance = null;
+let cropperMode = null; // 'new' (file pending) | 'existing'
+let cropperNaturalSize = { w: 0, h: 0 };
+let cropperInitialCrop = null;
+
+function readCropFromCropper(cropperData, naturalSize) {
+ const W = naturalSize.w, H = naturalSize.h;
+ if (!W || !H || !cropperData || !cropperData.width) return { ...DEFAULT_CROP };
+ const L = Math.min(W, H);
+ const zoomRaw = L / cropperData.width;
+ const zoom = Math.max(1, Math.min(4, zoomRaw));
+ const percentX = ((cropperData.x + cropperData.width / 2) / W) * 100;
+ const percentY = ((cropperData.y + cropperData.height / 2) / H) * 100;
+ return {
+ offsetX: Math.max(-100, Math.min(100, percentX - 50)),
+ offsetY: Math.max(-100, Math.min(100, percentY - 50)),
+ zoom
+ };
+}
+
+function cropToCropperData(crop, naturalSize) {
+ const W = naturalSize.w, H = naturalSize.h;
+ const L = Math.min(W, H);
+ const width = L / (crop.zoom || 1);
+ const percentX = 50 + (crop.offsetX || 0);
+ const percentY = 50 + (crop.offsetY || 0);
+ const cx = (percentX / 100) * W;
+ const cy = (percentY / 100) * H;
+ return {
+ x: cx - width / 2,
+ y: cy - width / 2,
+ width,
+ height: width,
+ rotate: 0,
+ scaleX: 1,
+ scaleY: 1
+ };
+}
+
+function seedCropperFromCrop(crop) {
+ if (!cropperInstance) return;
+ cropperInstance.setData(cropToCropperData(crop, cropperNaturalSize));
+}
+
+function openCropperForNewUpload(dataUrl) {
+ _openCropper(dataUrl, 'new', { ...DEFAULT_CROP });
+}
+
+async function openCropperForExisting() {
+ try {
+ const profile = await api('/api/profile');
+ if (!profile || !profile.picture_filename) {
+ toast(t('toast.no_picture'), 'info');
+ return;
+ }
+ let crop = { ...DEFAULT_CROP };
+ if (profile.picture_crop) {
+ try { crop = { ...crop, ...JSON.parse(profile.picture_crop) }; } catch {}
+ }
+ const src = '/uploads/' + encodeURIComponent(profile.picture_filename) + '?t=' + Date.now();
+ _openCropper(src, 'existing', crop);
+ } catch (err) {
+ toast(t('toast.upload_failed'), 'error');
+ }
+}
+
+function _openCropper(src, mode, initialCrop) {
+ if (typeof Cropper === 'undefined') {
+ toast(t('toast.upload_failed'), 'error');
+ return;
+ }
+ cropperMode = mode;
+ cropperInitialCrop = initialCrop;
+ const modal = document.getElementById('cropperModal');
+ const img = document.getElementById('cropperImage');
+ if (!modal || !img) return;
+ if (cropperInstance) { cropperInstance.destroy(); cropperInstance = null; }
+ img.onload = () => {
+ cropperNaturalSize = { w: img.naturalWidth, h: img.naturalHeight };
+ cropperInstance = new Cropper(img, {
+ aspectRatio: 1,
+ viewMode: 1,
+ dragMode: 'move',
+ background: false,
+ autoCropArea: 1,
+ guides: false,
+ center: false,
+ cropBoxMovable: false,
+ cropBoxResizable: false,
+ toggleDragModeOnDblclick: false,
+ ready: () => seedCropperFromCrop(initialCrop)
+ });
+ };
+ img.src = src;
+ modal.style.display = 'flex';
+ const zoomSlider = document.getElementById('cropperZoom');
+ if (zoomSlider) {
+ zoomSlider.value = String(initialCrop.zoom || 1);
+ zoomSlider.oninput = () => {
+ if (!cropperInstance) return;
+ const z = Math.max(1, Math.min(4, parseFloat(zoomSlider.value) || 1));
+ const current = cropperInstance.getData(true);
+ // Preserve the crop centre while changing zoom.
+ const cx = current.x + current.width / 2;
+ const cy = current.y + current.height / 2;
+ const L = Math.min(cropperNaturalSize.w, cropperNaturalSize.h);
+ const newSide = L / z;
+ cropperInstance.setData({ x: cx - newSide / 2, y: cy - newSide / 2, width: newSide, height: newSide, rotate: 0, scaleX: 1, scaleY: 1 });
+ };
+ }
+}
+
+function resetCropperCrop() {
+ seedCropperFromCrop({ ...DEFAULT_CROP });
+ const zoomSlider = document.getElementById('cropperZoom');
+ if (zoomSlider) zoomSlider.value = '1';
+}
+
+async function saveCropperCrop() {
+ if (!cropperInstance) { closeCropperModal(); return; }
+ const data = cropperInstance.getData(true);
+ const crop = readCropFromCropper(data, cropperNaturalSize);
+ if (cropperMode === 'existing') {
+ // Image is already on the server — PUT the crop only.
+ try {
+ const ctxId = activeDatasetId || '';
+ const propagate = checked('f-picturePropagate');
+ await api('/api/profile/picture/crop', {
+ method: 'PUT',
+ body: { offsetX: crop.offsetX, offsetY: crop.offsetY, zoom: crop.zoom, applyToAll: propagate, current_dataset_id: ctxId || undefined }
+ });
+ toast(t('toast.crop_saved'), 'success');
+ // Reflect in the 80px preview + the live header circle.
+ const previewImg = document.getElementById('profilePreviewImg');
+ if (previewImg) applyProfilePictureCrop(previewImg, crop);
+ const headerImg = document.getElementById('profilePicture');
+ if (headerImg) applyProfilePictureCrop(headerImg, crop);
+ } catch (err) {
+ toast(t('toast.upload_failed'), 'error');
+ }
+ } else {
+ // New upload: queue the crop and update the preview; upload happens on
+ // profile-form save (uploadProfilePicture will PUT the crop afterwards).
+ pendingProfilePictureCrop = crop;
+ const previewImg = document.getElementById('profilePreviewImg');
+ if (previewImg) applyProfilePictureCrop(previewImg, crop);
+ }
+ closeCropperModal();
+}
+
+function closeCropperModal() {
+ if (cropperInstance) { cropperInstance.destroy(); cropperInstance = null; }
+ const modal = document.getElementById('cropperModal');
+ if (modal) modal.style.display = 'none';
+ cropperMode = null;
+ cropperInitialCrop = null;
}
// Company logo upload
diff --git a/public/shared/i18n/de.json b/public/shared/i18n/de.json
index 3d32feb5..71ff6732 100644
--- a/public/shared/i18n/de.json
+++ b/public/shared/i18n/de.json
@@ -110,6 +110,13 @@
"copy_section.toast_error": "Der Abschnitt konnte nicht kopiert werden.",
"btn.save": "Speichern",
"btn.cancel": "Abbrechen",
+ "btn.reset_crop": "Zurücksetzen",
+ "form.adjust_picture": "Anpassen",
+ "form.adjust_hint": "Zum Verschieben ziehen, Schieberegler oder Zwei-Finger-Geste zum Zoomen.",
+ "form.zoom": "Zoom",
+ "modal.adjust_picture": "Profilbild anpassen",
+ "toast.crop_saved": "Profilbild angepasst",
+ "toast.no_picture": "Laden Sie zuerst ein Profilbild hoch.",
"btn.delete": "Löschen",
"btn.close": "Schließen",
"btn.save_changes": "Änderungen speichern",
diff --git a/public/shared/i18n/en.json b/public/shared/i18n/en.json
index 2db7fd30..910a5747 100644
--- a/public/shared/i18n/en.json
+++ b/public/shared/i18n/en.json
@@ -112,6 +112,7 @@
"btn.save": "Save",
"btn.cancel": "Cancel",
"btn.delete": "Delete",
+ "btn.reset_crop": "Reset",
"btn.close": "Close",
"btn.save_changes": "Save Changes",
"btn.done": "Done",
@@ -130,10 +131,14 @@
"modal.add_skill": "Add Skill Category",
"modal.edit_project": "Edit Project",
"modal.add_project": "Add Project",
+ "modal.adjust_picture": "Adjust Profile Picture",
"form.section_heading": "Section heading",
"form.profile_picture": "Profile Picture",
"form.show_profile_picture": "Show Profile Picture",
"form.choose_image": "Choose Image",
+ "form.adjust_picture": "Adjust",
+ "form.adjust_hint": "Drag to reposition, use the slider or pinch to zoom.",
+ "form.zoom": "Zoom",
"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",
@@ -408,6 +413,8 @@
"toast.logo_deleted": "Logo deleted",
"toast.picture_deleted": "Picture deleted",
"toast.picture_in_use": "Picture is still in use and cannot be deleted.",
+ "toast.crop_saved": "Profile picture adjusted",
+ "toast.no_picture": "Upload a profile picture first.",
"toast.cannot_delete_picture": "Failed to delete picture",
"toast.order_saved": "Order saved",
"toast.order_failed": "Failed to save order",
diff --git a/public/shared/i18n/es.json b/public/shared/i18n/es.json
index 8ae59d64..7e2030ea 100644
--- a/public/shared/i18n/es.json
+++ b/public/shared/i18n/es.json
@@ -110,6 +110,13 @@
"copy_section.toast_error": "No se pudo copiar la sección.",
"btn.save": "Guardar",
"btn.cancel": "Cancelar",
+ "btn.reset_crop": "Restablecer",
+ "form.adjust_picture": "Ajustar",
+ "form.adjust_hint": "Arrastra para reposicionar, usa el deslizador o pellizca para hacer zoom.",
+ "form.zoom": "Zoom",
+ "modal.adjust_picture": "Ajustar foto de perfil",
+ "toast.crop_saved": "Foto de perfil ajustada",
+ "toast.no_picture": "Sube primero una foto de perfil.",
"btn.delete": "Eliminar",
"btn.close": "Cerrar",
"btn.save_changes": "Guardar cambios",
diff --git a/public/shared/i18n/fr.json b/public/shared/i18n/fr.json
index 057fcf45..41ea414f 100644
--- a/public/shared/i18n/fr.json
+++ b/public/shared/i18n/fr.json
@@ -110,6 +110,13 @@
"copy_section.toast_error": "Impossible de copier la section.",
"btn.save": "Enregistrer",
"btn.cancel": "Annuler",
+ "btn.reset_crop": "Réinitialiser",
+ "form.adjust_picture": "Ajuster",
+ "form.adjust_hint": "Faites glisser pour repositionner, utilisez le curseur ou pincez pour zoomer.",
+ "form.zoom": "Zoom",
+ "modal.adjust_picture": "Ajuster la photo de profil",
+ "toast.crop_saved": "Photo de profil ajustée",
+ "toast.no_picture": "Téléchargez d'abord une photo de profil.",
"btn.delete": "Supprimer",
"btn.close": "Fermer",
"btn.save_changes": "Enregistrer les modifications",
diff --git a/public/shared/i18n/it.json b/public/shared/i18n/it.json
index cc244ac3..789103f2 100644
--- a/public/shared/i18n/it.json
+++ b/public/shared/i18n/it.json
@@ -110,6 +110,13 @@
"copy_section.toast_error": "Impossibile copiare la sezione.",
"btn.save": "Salva",
"btn.cancel": "Annulla",
+ "btn.reset_crop": "Reimposta",
+ "form.adjust_picture": "Regola",
+ "form.adjust_hint": "Trascina per riposizionare, usa il cursore o pizzica per ingrandire.",
+ "form.zoom": "Zoom",
+ "modal.adjust_picture": "Regola foto profilo",
+ "toast.crop_saved": "Foto profilo aggiornata",
+ "toast.no_picture": "Carica prima una foto profilo.",
"btn.delete": "Elimina",
"btn.close": "Chiudi",
"btn.save_changes": "Salva modifiche",
diff --git a/public/shared/i18n/nl.json b/public/shared/i18n/nl.json
index dfb75f7a..208be662 100644
--- a/public/shared/i18n/nl.json
+++ b/public/shared/i18n/nl.json
@@ -110,6 +110,13 @@
"copy_section.toast_error": "De sectie kon niet worden gekopieerd.",
"btn.save": "Opslaan",
"btn.cancel": "Annuleren",
+ "btn.reset_crop": "Herstellen",
+ "form.adjust_picture": "Aanpassen",
+ "form.adjust_hint": "Sleep om te verplaatsen, gebruik de schuifregelaar of knijp om te zoomen.",
+ "form.zoom": "Zoom",
+ "modal.adjust_picture": "Profielfoto aanpassen",
+ "toast.crop_saved": "Profielfoto aangepast",
+ "toast.no_picture": "Upload eerst een profielfoto.",
"btn.delete": "Verwijderen",
"btn.close": "Sluiten",
"btn.save_changes": "Wijzigingen opslaan",
diff --git a/public/shared/i18n/pt.json b/public/shared/i18n/pt.json
index 4aa463b0..d6116a04 100644
--- a/public/shared/i18n/pt.json
+++ b/public/shared/i18n/pt.json
@@ -110,6 +110,13 @@
"copy_section.toast_error": "Não foi possível copiar a secção.",
"btn.save": "Guardar",
"btn.cancel": "Cancelar",
+ "btn.reset_crop": "Redefinir",
+ "form.adjust_picture": "Ajustar",
+ "form.adjust_hint": "Arraste para reposicionar, use o controlo deslizante ou aperte para ampliar.",
+ "form.zoom": "Zoom",
+ "modal.adjust_picture": "Ajustar foto de perfil",
+ "toast.crop_saved": "Foto de perfil ajustada",
+ "toast.no_picture": "Envie primeiro uma foto de perfil.",
"btn.delete": "Eliminar",
"btn.close": "Fechar",
"btn.save_changes": "Guardar Alterações",
diff --git a/public/shared/i18n/zh.json b/public/shared/i18n/zh.json
index 4fe77a68..33bd9284 100644
--- a/public/shared/i18n/zh.json
+++ b/public/shared/i18n/zh.json
@@ -110,6 +110,13 @@
"copy_section.toast_error": "无法复制该板块。",
"btn.save": "保存",
"btn.cancel": "取消",
+ "btn.reset_crop": "重置",
+ "form.adjust_picture": "调整",
+ "form.adjust_hint": "拖动以重新定位,使用滑块或双指缩放。",
+ "form.zoom": "缩放",
+ "modal.adjust_picture": "调整头像",
+ "toast.crop_saved": "头像已调整",
+ "toast.no_picture": "请先上传头像。",
"btn.delete": "删除",
"btn.close": "关闭",
"btn.save_changes": "保存更改",
diff --git a/public/shared/scripts.js b/public/shared/scripts.js
index a5151cce..ce0149ad 100644
--- a/public/shared/scripts.js
+++ b/public/shared/scripts.js
@@ -333,6 +333,39 @@ function escapeHtml(text) {
return div.innerHTML;
}
+// Apply stored crop metadata to a profile-picture . The crop is expressed as
+// percent offsets (-100..100) and a scale factor (1..4). We use object-position +
+// transform with a shared origin so the visible centre stays anchored through scale.
+// Accepts either a JSON string (as stored in DB) or a parsed object; null/invalid
+// clears all crop-related inline styles so default framing is restored.
+function applyProfilePictureCrop(imgEl, crop) {
+ if (!imgEl) return;
+ let c = null;
+ if (crop) {
+ try { c = typeof crop === 'string' ? JSON.parse(crop) : crop; } catch { c = null; }
+ }
+ if (!c || typeof c !== 'object') {
+ imgEl.style.objectPosition = '';
+ imgEl.style.transform = '';
+ imgEl.style.transformOrigin = '';
+ return;
+ }
+ const ox = Number.isFinite(c.offsetX) ? c.offsetX : 0;
+ const oy = Number.isFinite(c.offsetY) ? c.offsetY : 0;
+ const z = Number.isFinite(c.zoom) && c.zoom > 0 ? c.zoom : 1;
+ if (ox === 0 && oy === 0 && z === 1) {
+ imgEl.style.objectPosition = '';
+ imgEl.style.transform = '';
+ imgEl.style.transformOrigin = '';
+ return;
+ }
+ const posX = 50 + ox;
+ const posY = 50 + oy;
+ imgEl.style.objectPosition = `${posX}% ${posY}%`;
+ imgEl.style.transformOrigin = `${posX}% ${posY}%`;
+ imgEl.style.transform = `scale(${z})`;
+}
+
// Validate that a string is an http(s) URL. Used for optional URL fields.
function isValidUrl(v) {
if (!v) return false;
@@ -672,6 +705,7 @@ async function loadProfile(includePrivate = false) {
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();
+ applyProfilePictureCrop(pic, p.picture_crop);
profileImg.style.display = 'flex';
} else {
pic.onload = null;
diff --git a/src/server.js b/src/server.js
index df93b0ef..7b374f80 100644
--- a/src/server.js
+++ b/src/server.js
@@ -454,7 +454,7 @@ const SOCIAL_PLATFORMS = [
if (!PUBLIC_ONLY) {
// Step 1: Create tables (without sort_order in section_visibility for compatibility)
db.exec(`
- CREATE TABLE IF NOT EXISTS profile (id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT NOT NULL DEFAULT 'Your Name', initials TEXT DEFAULT 'YN', title TEXT DEFAULT 'Your Title', subtitle TEXT DEFAULT '', bio TEXT DEFAULT '', location TEXT DEFAULT '', linkedin TEXT DEFAULT '', email TEXT DEFAULT '', phone TEXT DEFAULT '', languages TEXT DEFAULT '', visible INTEGER DEFAULT 1, profile_picture_enabled INTEGER DEFAULT 1, picture_filename TEXT, picture_propagate INTEGER DEFAULT 1, open_to_work INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);
+ CREATE TABLE IF NOT EXISTS profile (id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT NOT NULL DEFAULT 'Your Name', initials TEXT DEFAULT 'YN', title TEXT DEFAULT 'Your Title', subtitle TEXT DEFAULT '', bio TEXT DEFAULT '', location TEXT DEFAULT '', linkedin TEXT DEFAULT '', email TEXT DEFAULT '', phone TEXT DEFAULT '', languages TEXT DEFAULT '', visible INTEGER DEFAULT 1, profile_picture_enabled INTEGER DEFAULT 1, picture_filename TEXT, picture_propagate INTEGER DEFAULT 1, picture_crop TEXT, open_to_work INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS experiences (id INTEGER PRIMARY KEY AUTOINCREMENT, job_title TEXT NOT NULL, company_name TEXT NOT NULL, start_date TEXT, end_date TEXT, location TEXT, country_code TEXT DEFAULT '', highlights TEXT, sort_order INTEGER DEFAULT 0, visible INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS certifications (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, provider TEXT, issue_date TEXT, expiry_date TEXT, credential_id TEXT, sort_order INTEGER DEFAULT 0, visible INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
@@ -671,6 +671,15 @@ if (!PUBLIC_ONLY) {
}
} catch (err) { console.log('Migration check (picture_propagate):', err.message); }
+ // Step 2f5: Migration - add picture_crop column to profile if missing
+ try {
+ const profilePicCropInfo = db.prepare("PRAGMA table_info(profile)").all();
+ if (!profilePicCropInfo.some(col => col.name === 'picture_crop')) {
+ console.log('Migrating profile table: adding picture_crop');
+ db.exec('ALTER TABLE profile ADD COLUMN picture_crop TEXT');
+ }
+ } catch (err) { console.log('Migration check (picture_crop):', err.message); }
+
// Step 2g: Migration - add is_default column to saved_datasets if missing
try {
@@ -1102,6 +1111,26 @@ function isValidProfilePictureName(filename) {
return filename.startsWith('profile_') || filename === 'picture.jpeg';
}
+// Clamp + normalize a profile-picture crop payload. Returns a normalized object
+// or null when the input is missing/invalid. Values out of range are clamped
+// rather than rejected, matching the client-side slider behaviour; only
+// non-numeric / missing fields are treated as a hard error.
+const DEFAULT_CROP = { offsetX: 0, offsetY: 0, zoom: 1 };
+function normalizeCrop(input) {
+ if (!input || typeof input !== 'object') return null;
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : NaN;
+ let ox = num(input.offsetX), oy = num(input.offsetY), z = num(input.zoom);
+ if (Number.isNaN(ox) || Number.isNaN(oy) || Number.isNaN(z)) return null;
+ if (z < 1) z = 1; else if (z > 4) z = 4;
+ if (ox < -100) ox = -100; else if (ox > 100) ox = 100;
+ if (oy < -100) oy = -100; else if (oy > 100) oy = 100;
+ return {
+ offsetX: Math.round(ox * 100) / 100,
+ offsetY: Math.round(oy * 100) / 100,
+ zoom: Math.round(z * 1000) / 1000
+ };
+}
+
// Mirror the live profile picture filename into every saved dataset JSON snapshot.
// Also sets data.profile.picture_propagate = 1 so the flag stays consistent across siblings.
function propagateProfilePictureToDatasets(filename) {
@@ -1149,6 +1178,49 @@ function propagateProfilePictureToSiblings(filename, datasetId) {
} catch (e) {}
}
+// Mirror the picture_crop JSON string (or null to clear) into every saved dataset.
+// Used when the picture-propagate flag is on, or when replacing a picture globally.
+function propagateProfileCropToDatasets(cropJson) {
+ try {
+ const datasets = db.prepare('SELECT id, data FROM saved_datasets').all();
+ const target = cropJson || null;
+ for (const ds of datasets) {
+ try {
+ const data = JSON.parse(ds.data);
+ if (!data.profile) continue;
+ if ((data.profile.picture_crop || null) !== target) {
+ data.profile.picture_crop = target;
+ db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(data), ds.id);
+ }
+ } catch (e) {}
+ }
+ } catch (e) {}
+}
+
+// Mirror the crop into every language sibling of the given dataset (same language_group).
+// Parallels propagateProfilePictureToSiblings.
+function propagateProfileCropToSiblings(cropJson, datasetId) {
+ if (!datasetId) return;
+ try {
+ const anchor = db.prepare('SELECT id, language_group FROM saved_datasets WHERE id = ?').get(datasetId);
+ if (!anchor) return;
+ const rows = anchor.language_group
+ ? db.prepare('SELECT id, data FROM saved_datasets WHERE language_group = ?').all(anchor.language_group)
+ : db.prepare('SELECT id, data FROM saved_datasets WHERE id = ?').all(anchor.id);
+ const target = cropJson || null;
+ for (const ds of rows) {
+ try {
+ const data = JSON.parse(ds.data);
+ if (!data.profile) continue;
+ if ((data.profile.picture_crop || null) !== target) {
+ data.profile.picture_crop = target;
+ db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(data), ds.id);
+ }
+ } catch (e) {}
+ }
+ } catch (e) {}
+}
+
// Gather current CV data from live DB into a JSON-serializable snapshot
// Look up a per-language section title override. Returns null when none exists.
function getLanguageSectionOverride(sectionKey, language) {
@@ -1616,7 +1688,7 @@ if (PUBLIC_ONLY) {
publicApp.use(express.static(path.join(__dirname, '../public-readonly'), { index: false }));
publicApp.use('/uploads', express.static(uploadsPath));
- publicApp.get('/api/profile', (req, res) => { res.json(db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, open_to_work FROM profile WHERE id = 1').get() || {}); });
+ publicApp.get('/api/profile', (req, res) => { res.json(db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, picture_crop, open_to_work FROM profile WHERE id = 1').get() || {}); });
publicApp.get('/api/sections', (req, res) => { const sections = db.prepare('SELECT * FROM section_visibility').all(); const result = {}; sections.forEach(s => { result[s.section_name] = !!s.visible; }); res.json(result); });
publicApp.get('/api/sections/order', (req, res) => {
const requestedLang = req.query.language || null;
@@ -1677,7 +1749,7 @@ if (PUBLIC_ONLY) {
publicApp.get('/api/layout-types', (req, res) => { res.json(LAYOUT_TYPES); });
publicApp.get('/api/social-platforms', (req, res) => { res.json(SOCIAL_PLATFORMS); });
publicApp.get('/api/cv', (req, res) => {
- const profile = db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, open_to_work FROM profile WHERE id = 1').get();
+ const profile = db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, picture_crop, open_to_work FROM profile WHERE id = 1').get();
const experiences = db.prepare('SELECT job_title, company_name, start_date, end_date, location, country_code, highlights, summary, logo_filename FROM experiences WHERE visible = 1 ORDER BY sort_order ASC, start_date DESC').all();
const certifications = db.prepare('SELECT name, provider, issue_date, expiry_date, credential_id, logo_filename FROM certifications WHERE visible = 1 ORDER BY sort_order ASC, issue_date DESC').all();
const education = db.prepare('SELECT degree_title, institution_name, start_date, end_date, description, logo_filename FROM education WHERE visible = 1 ORDER BY sort_order ASC, end_date DESC').all();
@@ -1721,10 +1793,16 @@ if (PUBLIC_ONLY) {
const filename = req.file.filename;
const currentDatasetId = req.body && req.body.current_dataset_id ? Number(req.body.current_dataset_id) : null;
const runUpdate = db.transaction(() => {
- db.prepare('UPDATE profile SET picture_filename = ? WHERE id = 1').run(filename);
+ // New upload invalidates the crop — reset to defaults (NULL means "no crop").
+ db.prepare('UPDATE profile SET picture_filename = ?, picture_crop = NULL WHERE id = 1').run(filename);
const prof = db.prepare('SELECT picture_propagate FROM profile WHERE id = 1').get();
- if (prof && prof.picture_propagate == 1) propagateProfilePictureToDatasets(filename);
- else propagateProfilePictureToSiblings(filename, currentDatasetId);
+ if (prof && prof.picture_propagate == 1) {
+ propagateProfilePictureToDatasets(filename);
+ propagateProfileCropToDatasets(null);
+ } else {
+ propagateProfilePictureToSiblings(filename, currentDatasetId);
+ propagateProfileCropToSiblings(null, currentDatasetId);
+ }
});
try { runUpdate(); } catch (err) { return res.status(500).json({ error: err.message }); }
res.json({ success: true, filename });
@@ -1732,10 +1810,15 @@ if (PUBLIC_ONLY) {
app.delete('/api/profile/picture', (req, res) => {
const currentDatasetId = req.query.current_dataset_id ? Number(req.query.current_dataset_id) : null;
const runUpdate = db.transaction(() => {
- db.prepare('UPDATE profile SET picture_filename = NULL WHERE id = 1').run();
+ db.prepare('UPDATE profile SET picture_filename = NULL, picture_crop = NULL WHERE id = 1').run();
const prof = db.prepare('SELECT picture_propagate FROM profile WHERE id = 1').get();
- if (prof && prof.picture_propagate == 1) propagateProfilePictureToDatasets(null);
- else propagateProfilePictureToSiblings(null, currentDatasetId);
+ if (prof && prof.picture_propagate == 1) {
+ propagateProfilePictureToDatasets(null);
+ propagateProfileCropToDatasets(null);
+ } else {
+ propagateProfilePictureToSiblings(null, currentDatasetId);
+ propagateProfileCropToSiblings(null, currentDatasetId);
+ }
});
try { runUpdate(); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -1747,14 +1830,42 @@ if (PUBLIC_ONLY) {
if (!fs.existsSync(path.join(uploadsPath, filename))) return res.status(404).json({ error: 'Picture file not found' });
const currentDatasetId = current_dataset_id ? Number(current_dataset_id) : null;
const runUpdate = db.transaction(() => {
- db.prepare('UPDATE profile SET picture_filename = ? WHERE id = 1').run(filename);
+ db.prepare('UPDATE profile SET picture_filename = ?, picture_crop = NULL WHERE id = 1').run(filename);
const prof = db.prepare('SELECT picture_propagate FROM profile WHERE id = 1').get();
- if (prof && prof.picture_propagate == 1) propagateProfilePictureToDatasets(filename);
- else propagateProfilePictureToSiblings(filename, currentDatasetId);
+ if (prof && prof.picture_propagate == 1) {
+ propagateProfilePictureToDatasets(filename);
+ propagateProfileCropToDatasets(null);
+ } else {
+ propagateProfilePictureToSiblings(filename, currentDatasetId);
+ propagateProfileCropToSiblings(null, currentDatasetId);
+ }
});
try { runUpdate(); res.json({ success: true, filename }); } catch (err) { res.status(500).json({ error: err.message }); }
});
+ // Save the crop framing (LinkedIn-style zoom + pan) for the current profile picture.
+ // Expects { offsetX, offsetY, zoom, applyToAll?, current_dataset_id? }. Offsets are
+ // percent deltas from centre (-100..100), zoom is a scale factor (1..4). When
+ // applyToAll is omitted, falls back to the stored picture_propagate flag so the UI
+ // toggle stays authoritative without a redundant DB read on the client.
+ app.put('/api/profile/picture/crop', express.json(), (req, res) => {
+ const { offsetX, offsetY, zoom, applyToAll, current_dataset_id } = req.body || {};
+ const normalized = normalizeCrop({ offsetX, offsetY, zoom });
+ if (!normalized) return res.status(400).json({ error: 'Invalid crop payload' });
+ const live = db.prepare('SELECT picture_filename, picture_propagate FROM profile WHERE id = 1').get();
+ if (!live || !live.picture_filename) return res.status(400).json({ error: 'No profile picture set' });
+ const cropJson = JSON.stringify(normalized);
+ const ctxId = current_dataset_id ? Number(current_dataset_id) : null;
+ const globalApply = (applyToAll !== undefined) ? !!applyToAll : (live.picture_propagate == 1);
+ const tx = db.transaction(() => {
+ db.prepare('UPDATE profile SET picture_crop = ? WHERE id = 1').run(cropJson);
+ if (globalApply) propagateProfileCropToDatasets(cropJson);
+ else propagateProfileCropToSiblings(cropJson, ctxId);
+ });
+ try { tx(); res.json({ success: true, crop: normalized }); }
+ catch (err) { res.status(500).json({ error: err.message }); }
+ });
+
// List all profile pictures available for reuse (scans filesystem + cross-references live + datasets)
app.get('/api/profile-pictures', (req, res) => {
let files = [];
@@ -3037,7 +3148,7 @@ if (PUBLIC_ONLY) {
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.bulletStyle && ALLOWED_BULLET_STYLES.has(t.bulletStyle)) upsert.run('themeBulletStyle', t.bulletStyle); 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 (t.sectionTitleColor && /^#[0-9a-fA-F]{6}$/.test(t.sectionTitleColor)) upsert.run('themeSectionTitleColor', t.sectionTitleColor); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionTitleColor'); if (Number.isInteger(t.sectionRadius) && t.sectionRadius >= SECTION_RADIUS_MIN && t.sectionRadius <= SECTION_RADIUS_MAX) upsert.run('themeSectionRadius', String(t.sectionRadius)); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionRadius'); } 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.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.bulletStyle && ALLOWED_BULLET_STYLES.has(t.bulletStyle)) upsert.run('themeBulletStyle', t.bulletStyle); 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 (t.sectionTitleColor && /^#[0-9a-fA-F]{6}$/.test(t.sectionTitleColor)) upsert.run('themeSectionTitleColor', t.sectionTitleColor); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionTitleColor'); if (Number.isInteger(t.sectionRadius) && t.sectionRadius >= SECTION_RADIUS_MIN && t.sectionRadius <= SECTION_RADIUS_MAX) upsert.run('themeSectionRadius', String(t.sectionRadius)); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionRadius'); } if (data.profile) { const p = data.profile; const cropValue = p.picture_crop ? (typeof p.picture_crop === 'string' ? p.picture_crop : JSON.stringify(p.picture_crop)) : null; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, profile_picture_enabled = ?, picture_filename = ?, picture_propagate = ?, picture_crop = ? 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), cropValue); } 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);
@@ -3920,7 +4031,7 @@ if (PUBLIC_ONLY) {
publicApp.get('/', (req, res) => { servePublicIndex(req, res); });
publicApp.use(express.static(path.join(__dirname, '../public-readonly'), { index: false }));
publicApp.use('/uploads', express.static(uploadsPath));
- publicApp.get('/api/profile', (req, res) => { res.json(db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, open_to_work FROM profile WHERE id = 1').get() || {}); });
+ publicApp.get('/api/profile', (req, res) => { res.json(db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, picture_crop, open_to_work FROM profile WHERE id = 1').get() || {}); });
publicApp.get('/api/sections', (req, res) => { const sections = db.prepare('SELECT * FROM section_visibility').all(); const result = {}; sections.forEach(s => { result[s.section_name] = !!s.visible; }); res.json(result); });
publicApp.get('/api/sections/order', (req, res) => {
const requestedLang = req.query.language || null;
@@ -3980,7 +4091,7 @@ if (PUBLIC_ONLY) {
});
publicApp.get('/api/layout-types', (req, res) => { res.json(LAYOUT_TYPES); });
publicApp.get('/api/social-platforms', (req, res) => { res.json(SOCIAL_PLATFORMS); });
- publicApp.get('/api/cv', (req, res) => { const profile = db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, open_to_work FROM profile WHERE id = 1').get(); const experiences = db.prepare('SELECT job_title, company_name, start_date, end_date, location, country_code, highlights, logo_filename FROM experiences WHERE visible = 1 ORDER BY sort_order ASC, start_date DESC').all(); const certifications = db.prepare('SELECT name, provider, issue_date, expiry_date, credential_id, logo_filename FROM certifications WHERE visible = 1 ORDER BY sort_order ASC, issue_date DESC').all(); const education = db.prepare('SELECT degree_title, institution_name, start_date, end_date, description, logo_filename FROM education WHERE visible = 1 ORDER BY sort_order ASC, end_date DESC').all(); const skillCategories = db.prepare('SELECT id, name, icon FROM skill_categories WHERE visible = 1 ORDER BY sort_order ASC').all(); const skills = db.prepare('SELECT * FROM skills ORDER BY sort_order ASC').all(); const projects = db.prepare('SELECT title, description, technologies, link FROM projects WHERE visible = 1 ORDER BY sort_order ASC').all(); const sectionOrder = db.prepare('SELECT section_name, sort_order FROM section_visibility WHERE visible = 1 ORDER BY sort_order ASC').all(); res.json({ profile, experiences: experiences.map(e => ({ ...e, highlights: e.highlights ? JSON.parse(e.highlights) : [] })), certifications, education, skills: skillCategories.map(cat => ({ ...cat, skills: skills.filter(s => s.category_id === cat.id).map(s => s.name) })), projects: projects.map(p => ({ ...p, technologies: p.technologies ? JSON.parse(p.technologies) : [] })), sectionOrder: sectionOrder.map(s => s.section_name) }); });
+ publicApp.get('/api/cv', (req, res) => { const profile = db.prepare('SELECT name, initials, title, subtitle, bio, location, linkedin, languages, profile_picture_enabled, picture_filename, picture_crop, open_to_work FROM profile WHERE id = 1').get(); const experiences = db.prepare('SELECT job_title, company_name, start_date, end_date, location, country_code, highlights, logo_filename FROM experiences WHERE visible = 1 ORDER BY sort_order ASC, start_date DESC').all(); const certifications = db.prepare('SELECT name, provider, issue_date, expiry_date, credential_id, logo_filename FROM certifications WHERE visible = 1 ORDER BY sort_order ASC, issue_date DESC').all(); const education = db.prepare('SELECT degree_title, institution_name, start_date, end_date, description, logo_filename FROM education WHERE visible = 1 ORDER BY sort_order ASC, end_date DESC').all(); const skillCategories = db.prepare('SELECT id, name, icon FROM skill_categories WHERE visible = 1 ORDER BY sort_order ASC').all(); const skills = db.prepare('SELECT * FROM skills ORDER BY sort_order ASC').all(); const projects = db.prepare('SELECT title, description, technologies, link FROM projects WHERE visible = 1 ORDER BY sort_order ASC').all(); const sectionOrder = db.prepare('SELECT section_name, sort_order FROM section_visibility WHERE visible = 1 ORDER BY sort_order ASC').all(); res.json({ profile, experiences: experiences.map(e => ({ ...e, highlights: e.highlights ? JSON.parse(e.highlights) : [] })), certifications, education, skills: skillCategories.map(cat => ({ ...cat, skills: skills.filter(s => s.category_id === cat.id).map(s => s.name) })), projects: projects.map(p => ({ ...p, technologies: p.technologies ? JSON.parse(p.technologies) : [] })), sectionOrder: sectionOrder.map(s => s.section_name) }); });
// Public versioned CV routes (language-specific must come before generic)
publicApp.get('/v/:slug/:lang', (req, res) => { serveDatasetPage(req, res, req.params.lang); });
publicApp.get('/v/:slug', (req, res) => { serveDatasetPage(req, res); });
diff --git a/tests/backend.test.js b/tests/backend.test.js
index d162d4ce..ec54f5f8 100644
--- a/tests/backend.test.js
+++ b/tests/backend.test.js
@@ -2710,6 +2710,182 @@ describe('Backend API', () => {
});
});
+ describe('Profile Picture Crop (LinkedIn-style adjustment)', () => {
+ // Reuse the tiny-PNG helper + upload flow from the library block. Tests are
+ // independent but share the uploaded state inside this run.
+ const tinyPngBytes = Buffer.from([
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
+ 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
+ 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
+ 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41,
+ 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
+ 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
+ 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
+ 0x42, 0x60, 0x82
+ ]);
+ async function uploadPic() {
+ const fd = new FormData();
+ fd.append('picture', new Blob([tinyPngBytes], { type: 'image/png' }), 'test.png');
+ const res = await fetch(`${BASE_URL}/api/profile/picture`, { method: 'POST', body: fd });
+ assert.strictEqual(res.status, 200);
+ return (await res.json()).filename;
+ }
+ async function setPropagate(enabled) {
+ const profile = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ await fetch(`${BASE_URL}/api/profile`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...profile, picture_propagate: enabled }),
+ });
+ }
+ async function putCrop(body) {
+ return fetch(`${BASE_URL}/api/profile/picture/crop`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ }
+
+ it('rejects non-numeric offsetX with 400', async () => {
+ await uploadPic();
+ const res = await putCrop({ offsetX: 'nope', offsetY: 0, zoom: 1 });
+ assert.strictEqual(res.status, 400);
+ });
+
+ it('rejects missing zoom with 400', async () => {
+ await uploadPic();
+ const res = await putCrop({ offsetX: 10, offsetY: -5 });
+ assert.strictEqual(res.status, 400);
+ });
+
+ it('clamps out-of-range values (zoom=10 -> 4, offset=999 -> 100)', async () => {
+ await uploadPic();
+ const res = await putCrop({ offsetX: 999, offsetY: -999, zoom: 10 });
+ assert.strictEqual(res.status, 200);
+ const profile = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ const crop = JSON.parse(profile.picture_crop);
+ assert.strictEqual(crop.zoom, 4);
+ assert.strictEqual(crop.offsetX, 100);
+ assert.strictEqual(crop.offsetY, -100);
+ });
+
+ it('stores and returns crop via GET /api/profile', async () => {
+ await uploadPic();
+ const res = await putCrop({ offsetX: 12.5, offsetY: -7.25, zoom: 1.75 });
+ assert.strictEqual(res.status, 200);
+ const profile = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ assert.ok(profile.picture_crop, 'picture_crop must be populated');
+ const crop = JSON.parse(profile.picture_crop);
+ assert.strictEqual(crop.offsetX, 12.5);
+ assert.strictEqual(crop.offsetY, -7.25);
+ assert.strictEqual(crop.zoom, 1.75);
+ });
+
+ it('returns 400 when no picture is set', async () => {
+ // Ensure a picture exists so we can delete it cleanly
+ await uploadPic();
+ await fetch(`${BASE_URL}/api/profile/picture`, { method: 'DELETE' });
+ const res = await putCrop({ offsetX: 0, offsetY: 0, zoom: 1 });
+ assert.strictEqual(res.status, 400);
+ });
+
+ it('new upload resets crop to NULL', async () => {
+ await uploadPic();
+ await putCrop({ offsetX: 10, offsetY: 10, zoom: 2 });
+ const before = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ assert.ok(before.picture_crop);
+ await uploadPic();
+ const after = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ assert.strictEqual(after.picture_crop, null, 'new upload must clear the crop');
+ });
+
+ it('applyToAll:true propagates crop to every dataset snapshot', async () => {
+ await uploadPic();
+ const mk = (name) => fetch(`${BASE_URL}/api/datasets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: `${name} ${Date.now()}${Math.random()}` }),
+ }).then(r => r.json());
+ const a = await mk('Crop Global A');
+ const b = await mk('Crop Global B');
+
+ const res = await putCrop({ offsetX: 3, offsetY: 4, zoom: 1.5, applyToAll: true });
+ assert.strictEqual(res.status, 200);
+
+ const aData = await (await fetch(`${BASE_URL}/api/datasets/id/${a.id}`)).json();
+ const bData = await (await fetch(`${BASE_URL}/api/datasets/id/${b.id}`)).json();
+ const aCrop = JSON.parse(aData.profile.picture_crop);
+ const bCrop = JSON.parse(bData.profile.picture_crop);
+ assert.strictEqual(aCrop.offsetX, 3);
+ assert.strictEqual(bCrop.offsetY, 4);
+ assert.strictEqual(aCrop.zoom, 1.5);
+ });
+
+ it('applyToAll:false mirrors crop to language siblings only', async () => {
+ await uploadPic();
+ const group = `grp-${Date.now()}-${Math.random()}`;
+ const en = await fetch(`${BASE_URL}/api/datasets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: `Crop Sib EN ${Date.now()}`, language: 'en' }),
+ }).then(r => r.json());
+ const fr = await fetch(`${BASE_URL}/api/datasets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: `Crop Sib EN ${Date.now()}`, language: 'fr', language_group: en.language_group }),
+ }).then(r => r.json());
+ const unrelated = await fetch(`${BASE_URL}/api/datasets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: `Crop Unrelated ${Date.now()}` }),
+ }).then(r => r.json());
+
+ const res = await putCrop({ offsetX: -11, offsetY: 22, zoom: 2.2, applyToAll: false, current_dataset_id: en.id });
+ assert.strictEqual(res.status, 200);
+
+ const enData = await (await fetch(`${BASE_URL}/api/datasets/id/${en.id}`)).json();
+ const frData = await (await fetch(`${BASE_URL}/api/datasets/id/${fr.id}`)).json();
+ const unData = await (await fetch(`${BASE_URL}/api/datasets/id/${unrelated.id}`)).json();
+
+ assert.ok(enData.profile.picture_crop, 'active dataset carries the crop');
+ assert.ok(frData.profile.picture_crop, 'language sibling carries the crop');
+ assert.strictEqual(JSON.parse(enData.profile.picture_crop).offsetY, 22);
+ assert.strictEqual(JSON.parse(frData.profile.picture_crop).zoom, 2.2);
+ // Unrelated dataset's snapshot was created before the crop call and falls
+ // outside the language group, so it must still have no crop set.
+ assert.ok(
+ !unData.profile.picture_crop || JSON.parse(unData.profile.picture_crop).offsetY !== 22,
+ 'unrelated dataset must not receive the siblings-scoped crop'
+ );
+ });
+
+ it('dataset save+load restores picture_crop onto the live profile', async () => {
+ await uploadPic();
+ await putCrop({ offsetX: 8, offsetY: -4, zoom: 1.25, applyToAll: true });
+
+ const created = await fetch(`${BASE_URL}/api/datasets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: `Crop Restore ${Date.now()}` }),
+ }).then(r => r.json());
+
+ // Change live crop so we can verify the load restores the snapshot's value.
+ await putCrop({ offsetX: 0, offsetY: 0, zoom: 3, applyToAll: false });
+ const mid = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ assert.strictEqual(JSON.parse(mid.picture_crop).zoom, 3);
+
+ const loadRes = await fetch(`${BASE_URL}/api/datasets/${created.id}/load`, { method: 'POST' });
+ assert.strictEqual(loadRes.status, 200);
+ const after = await (await fetch(`${BASE_URL}/api/profile`)).json();
+ assert.ok(after.picture_crop, 'crop must be restored from the snapshot');
+ const crop = JSON.parse(after.picture_crop);
+ assert.strictEqual(crop.offsetX, 8);
+ assert.strictEqual(crop.offsetY, -4);
+ assert.strictEqual(crop.zoom, 1.25);
+ });
+ });
+
describe('Security', () => {
it('public /api/profile does not expose email or phone', async () => {
// Store profile with sensitive data via admin
diff --git a/tests/frontend.test.js b/tests/frontend.test.js
index 73f8f507..e3ccd4cf 100644
--- a/tests/frontend.test.js
+++ b/tests/frontend.test.js
@@ -655,6 +655,82 @@ describe('Frontend files', () => {
});
});
+ describe('Profile picture cropper (LinkedIn-style adjustment)', () => {
+ it('index.html loads Cropper.js CDN', () => {
+ const html = fs.readFileSync(path.join(ROOT, 'public', 'index.html'), 'utf8');
+ assert.ok(html.includes('cropper.min.css'), 'should link cropper.min.css');
+ assert.ok(html.includes('cropper.min.js'), 'should script cropper.min.js');
+ });
+
+ it('index.html has the cropper modal markup', () => {
+ const html = fs.readFileSync(path.join(ROOT, 'public', 'index.html'), 'utf8');
+ assert.ok(html.includes('id="cropperModal"'), 'should have #cropperModal');
+ assert.ok(html.includes('id="cropperImage"'), 'should have #cropperImage');
+ assert.ok(html.includes('id="cropperZoom"'), 'should have #cropperZoom slider');
+ });
+
+ it('public-readonly/index.html does NOT load Cropper.js (display-only)', () => {
+ const html = fs.readFileSync(path.join(ROOT, 'public-readonly', 'index.html'), 'utf8');
+ assert.ok(!html.includes('cropper.min.js'), 'public page must not include Cropper.js');
+ });
+
+ it('admin.js references cropper controller functions', () => {
+ const js = fs.readFileSync(path.join(ROOT, 'public', 'shared', 'admin.js'), 'utf8');
+ assert.ok(js.includes('openCropperForExisting'), 'admin.js should define openCropperForExisting');
+ assert.ok(js.includes('openCropperForNewUpload'), 'admin.js should define openCropperForNewUpload');
+ assert.ok(js.includes('saveCropperCrop'), 'admin.js should define saveCropperCrop');
+ assert.ok(js.includes('readCropFromCropper'), 'admin.js should define readCropFromCropper');
+ });
+
+ it('scripts.js exposes applyProfilePictureCrop helper', () => {
+ const js = fs.readFileSync(path.join(ROOT, 'public', 'shared', 'scripts.js'), 'utf8');
+ assert.ok(/function\s+applyProfilePictureCrop\s*\(/.test(js),
+ 'scripts.js should define applyProfilePictureCrop');
+ });
+
+ it('readCropFromCropper ↔ cropToCropperData round-trip preserves the crop', () => {
+ // Extract the two pure math helpers from admin.js and evaluate them in a
+ // sandboxed Function so the test doesn't need a browser or Cropper.js.
+ const js = fs.readFileSync(path.join(ROOT, 'public', 'shared', 'admin.js'), 'utf8');
+ const readMatch = js.match(/function readCropFromCropper\(cropperData, naturalSize\)\s*\{[\s\S]*?^\}/m);
+ const toMatch = js.match(/function cropToCropperData\(crop, naturalSize\)\s*\{[\s\S]*?^\}/m);
+ assert.ok(readMatch, 'readCropFromCropper should exist');
+ assert.ok(toMatch, 'cropToCropperData should exist');
+ const readFn = new Function('cropperData', 'naturalSize', readMatch[0]
+ .replace(/^function readCropFromCropper\(cropperData, naturalSize\)\s*\{/, '')
+ .replace(/\}$/, '') + '\nreturn readCropFromCropper(cropperData, naturalSize);');
+ // Evil eval: the body uses DEFAULT_CROP — inline it.
+ const readBody = readMatch[0]
+ .replace(/^function readCropFromCropper\(cropperData, naturalSize\)\s*\{/, '')
+ .replace(/\}$/, '')
+ .replace(/\{\s*\.\.\.DEFAULT_CROP\s*\}/g, '{ offsetX: 0, offsetY: 0, zoom: 1 }');
+ const readImpl = new Function('cropperData', 'naturalSize', readBody);
+ const toBody = toMatch[0]
+ .replace(/^function cropToCropperData\(crop, naturalSize\)\s*\{/, '')
+ .replace(/\}$/, '');
+ const toImpl = new Function('crop', 'naturalSize', toBody);
+
+ const cases = [
+ { W: 1000, H: 800, crop: { offsetX: 0, offsetY: 0, zoom: 1 } }, // landscape, centered
+ { W: 600, H: 900, crop: { offsetX: -15, offsetY: 10, zoom: 1.5 } }, // portrait, off-centre
+ { W: 500, H: 500, crop: { offsetX: 20, offsetY: -8, zoom: 2 } }, // square
+ { W: 1200, H: 600, crop: { offsetX: 0, offsetY: 0, zoom: 3 } }, // wide, heavy zoom
+ ];
+ for (const { W, H, crop } of cases) {
+ const size = { w: W, h: H };
+ const data = toImpl(crop, size);
+ const round = readImpl(data, size);
+ const near = (a, b) => Math.abs(a - b) < 0.01;
+ assert.ok(near(round.offsetX, crop.offsetX),
+ `offsetX round-trip for ${W}x${H}: got ${round.offsetX}, expected ${crop.offsetX}`);
+ assert.ok(near(round.offsetY, crop.offsetY),
+ `offsetY round-trip for ${W}x${H}: got ${round.offsetY}, expected ${crop.offsetY}`);
+ assert.ok(near(round.zoom, crop.zoom),
+ `zoom round-trip for ${W}x${H}: got ${round.zoom}, expected ${crop.zoom}`);
+ }
+ });
+ });
+
describe('Code quality', () => {
it('no console.log in frontend JavaScript files (except error handling)', () => {
const files = [
diff --git a/version.json b/version.json
index e2f0f08f..9a1ea4d2 100644
--- a/version.json
+++ b/version.json
@@ -1,4 +1,4 @@
{
- "version": "1.46.0",
+ "version": "1.47.1",
"changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md"
}