From 79316aba2f74c156b3d75bab6ef7f861c08031f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 18:13:19 +0000 Subject: [PATCH 1/4] Add in-place section reorder overlay with drag handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag handle now appears next to every section heading (admin-only, hidden in print and absent on the public site). Pressing it opens a floating overlay showing each section's title over a dimmed, blurred CV. The grabbed pill is latched to the cursor; drop it anywhere in the list and other pills FLIP-animate into place. The overlay stays open for further adjustments; OK saves via the existing PUT /api/sections/order, Cancel/Esc/backdrop click reverts. Hidden sections appear dimmed with a visibility_off badge so their order can still be adjusted for when they get re-enabled. The existing Settings → Sections & Headlines panel is unchanged. https://claude.ai/code/session_019TcEZbfYgmysPB4avbS6Bi --- CHANGELOG.md | 5 + package-lock.json | 4 +- package.json | 2 +- public/index.html | 33 ++++ public/shared/admin.css | 218 ++++++++++++++++++++++++ public/shared/admin.js | 331 +++++++++++++++++++++++++++++++++++++ public/shared/i18n/de.json | 3 + public/shared/i18n/en.json | 4 + public/shared/i18n/es.json | 3 + public/shared/i18n/fr.json | 3 + public/shared/i18n/it.json | 3 + public/shared/i18n/nl.json | 3 + public/shared/i18n/pt.json | 3 + public/shared/i18n/zh.json | 3 + version.json | 2 +- 15 files changed, 616 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7beee73..bca3317f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.33.0] - 2026-04-17 + +### Added +- In-place section reorder overlay. A drag handle now appears next to every section heading; activating it opens a floating overlay with each section's title over a dimmed, blurred CV. Drag pills to reorder with animated transitions, then click OK to save or Cancel to revert. Hidden sections appear dimmed so their position can still be adjusted. The Settings → Sections & Headlines panel is unchanged and remains available. + ## [1.32.0] - 2026-04-17 ### Added diff --git a/package-lock.json b/package-lock.json index 6d494a69..5c024601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.32.0", + "version": "1.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.32.0", + "version": "1.33.0", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index 96f69f1d..fbe71de5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.32.0", + "version": "1.33.0", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/public/index.html b/public/index.html index 4bb6bbed..c4fbc5f7 100644 --- a/public/index.html +++ b/public/index.html @@ -168,6 +168,9 @@

Loading...

+

Professional Summary

@@ -184,6 +187,9 @@

Professional Summary

+

Career Timeline

@@ -200,6 +206,9 @@

Career Timeline

+

Work Experience

@@ -217,6 +226,9 @@

Work Experience

+

Certifications

@@ -234,6 +246,9 @@

Certifications

+

Education

@@ -251,6 +266,9 @@

Education

+

Skills & Expertise

@@ -268,6 +286,9 @@

Skills & Expertise

+

Featured Projects

@@ -726,6 +747,18 @@

ATS-Friendly Document

+ + + diff --git a/public/shared/admin.css b/public/shared/admin.css index 83ca8f7e..adf0cfbd 100644 --- a/public/shared/admin.css +++ b/public/shared/admin.css @@ -2739,3 +2739,221 @@ } .ats-pdf-scale-row input[type="range"] { width: 100px; } } + +/* ========================================================= + Section Reorder Overlay + ========================================================= */ + +.section-reorder-handle { + position: absolute; + top: 20px; + left: -14px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--white); + color: var(--gray-500); + border-radius: 50%; + cursor: grab; + box-shadow: var(--shadow-md); + opacity: 0; + transform: translateX(6px) scale(0.85); + transition: opacity .18s ease, transform .18s ease, color .18s ease, background .18s ease; + z-index: 2; + padding: 0; +} + +.section:hover .section-reorder-handle, +.section-reorder-handle:focus-visible { + opacity: 1; + transform: translateX(0) scale(1); +} + +.section-reorder-handle:hover { + color: var(--primary); + background: var(--light); +} + +.section-reorder-handle:active { + cursor: grabbing; + transform: scale(0.95); +} + +.section-reorder-handle .material-symbols-outlined { + font-size: 18px; + pointer-events: none; +} + +body.reorder-active .section-reorder-handle { opacity: 0; pointer-events: none; } + +.reorder-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + z-index: 2500; + background: rgba(0, 26, 77, 0); + backdrop-filter: blur(0); + -webkit-backdrop-filter: blur(0); + transition: background .28s ease, backdrop-filter .28s ease, -webkit-backdrop-filter .28s ease; + padding: 24px; +} + +.reorder-overlay.active { + display: flex; + background: rgba(0, 26, 77, 0.55); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.reorder-dialog { + display: flex; + flex-direction: column; + gap: 18px; + width: 100%; + max-width: 440px; + max-height: 90vh; + opacity: 0; + transition: opacity .28s ease .05s; +} + +.reorder-overlay.active .reorder-dialog { + opacity: 1; +} + +.reorder-hint { + text-align: center; + color: var(--white); + font-size: 13px; + font-weight: 500; + opacity: 0.85; + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25); +} + +.reorder-list { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding: 4px 2px; +} + +.reorder-pill { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + color: var(--primary); + font-size: 16px; + font-weight: 600; + cursor: grab; + user-select: none; + touch-action: none; + will-change: transform; + animation: reorderPillIn .32s cubic-bezier(.2,.8,.2,1) both; + transition: box-shadow .2s ease, opacity .2s ease; +} + +.reorder-pill:hover { + box-shadow: var(--shadow-lg); +} + +.reorder-pill:active { + cursor: grabbing; +} + +.reorder-pill.dragging { + cursor: grabbing; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25), 0 4px 10px rgba(0, 0, 0, 0.15); + transform: scale(1.04); + animation: none; + transition: none; + background: var(--white); +} + +.reorder-pill--hidden { + opacity: 0.55; +} + +.reorder-pill-grip { + font-size: 20px !important; + color: var(--gray-400); + flex-shrink: 0; +} + +.reorder-pill-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reorder-pill-hidden-icon { + font-size: 18px !important; + color: var(--gray-400); + flex-shrink: 0; +} + +.reorder-placeholder { + border: 2px dashed rgba(255, 255, 255, 0.45); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.08); + transition: height .2s ease; +} + +.reorder-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 4px; +} + +.reorder-footer .btn { + min-width: 96px; +} + +.reorder-footer .btn-ghost { + background: rgba(255, 255, 255, 0.14); + color: var(--white); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.reorder-footer .btn-ghost:hover { + background: rgba(255, 255, 255, 0.24); +} + +@keyframes reorderPillIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} + +.reorder-pill--hidden { + animation-name: reorderPillInHidden; +} + +@keyframes reorderPillInHidden { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 0.55; transform: none; } +} + +@media (max-width: 600px) { + .section-reorder-handle { + left: 6px; + top: 16px; + } + .reorder-dialog { max-width: 100%; } + .reorder-pill { padding: 12px 14px; font-size: 15px; } +} + +@media print { + .reorder-overlay, + .section-reorder-handle { display: none !important; } +} diff --git a/public/shared/admin.js b/public/shared/admin.js index 880922e9..9f9bb0e7 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -756,6 +756,9 @@ function renderCustomSection(section) { return `
+

${escapeHtml(section.name)}

@@ -4859,3 +4862,331 @@ async function downloadAtsPdf() { btn.disabled = false; } } + +// =========================== +// Section Reorder Overlay +// =========================== + +let reorderState = null; + +function reorderSectionDisplayName(section) { + const isCustom = section.name && section.name !== section.default_name; + return isCustom ? section.name : getTranslatedSectionName(section.key, section.name || section.default_name); +} + +function renderReorderPills() { + const list = document.getElementById('reorderList'); + if (!list || !reorderState) return; + list.innerHTML = reorderState.order.map((section, index) => { + const hidden = section.visible === false; + const label = reorderSectionDisplayName(section); + const hiddenBadge = hidden + ? '' + : ''; + return ` +
+ + ${escapeHtml(label)} + ${hiddenBadge} +
+ `; + }).join(''); + attachReorderPillListeners(); +} + +function attachReorderPillListeners() { + document.querySelectorAll('#reorderList .reorder-pill').forEach(pill => { + pill.addEventListener('pointerdown', onReorderPillPointerDown); + }); +} + +function onReorderPillPointerDown(e) { + if (e.button !== undefined && e.button !== 0) return; + const pill = e.currentTarget; + const key = pill.dataset.key; + beginReorderDrag(key, e); +} + +function openReorderOverlay(grabbedKey, pointerEvent) { + if (!Array.isArray(sectionOrder) || sectionOrder.length === 0) return; + const overlay = document.getElementById('reorderOverlay'); + if (!overlay) return; + + // Snapshot so Cancel can revert + const snapshot = sectionOrder.map(s => ({ ...s })); + reorderState = { + order: sectionOrder.map(s => ({ ...s })), + snapshot, + dragging: null, + isOpen: true + }; + + renderReorderPills(); + + overlay.classList.add('active'); + overlay.setAttribute('aria-hidden', 'false'); + document.body.classList.add('reorder-active'); + + // Wire footer buttons once + const okBtn = document.getElementById('reorderOkBtn'); + const cancelBtn = document.getElementById('reorderCancelBtn'); + if (okBtn && !okBtn.dataset.bound) { + okBtn.addEventListener('click', confirmReorder); + okBtn.dataset.bound = '1'; + } + if (cancelBtn && !cancelBtn.dataset.bound) { + cancelBtn.addEventListener('click', cancelReorder); + cancelBtn.dataset.bound = '1'; + } + + // Esc to cancel, backdrop click to cancel + document.addEventListener('keydown', onReorderKeyDown); + overlay.addEventListener('pointerdown', onReorderOverlayPointerDown); + + // If a handle triggered the open, kick off a drag on that pill immediately. + // Suppress the pill's entrance animation so getBoundingClientRect matches final layout. + if (grabbedKey && pointerEvent) { + const pill = document.querySelector(`#reorderList .reorder-pill[data-key="${grabbedKey}"]`); + if (pill) pill.style.animation = 'none'; + beginReorderDrag(grabbedKey, pointerEvent); + } +} + +function onReorderOverlayPointerDown(e) { + // Clicking the backdrop (the overlay itself, not the dialog) cancels + if (e.target && e.target.id === 'reorderOverlay') { + cancelReorder(); + } +} + +function onReorderKeyDown(e) { + if (e.key === 'Escape') { + cancelReorder(); + } +} + +function beginReorderDrag(key, pointerEvent) { + if (!reorderState) return; + const pill = document.querySelector(`#reorderList .reorder-pill[data-key="${key}"]`); + if (!pill) return; + + // End any previous drag cleanly + if (reorderState.dragging) endReorderDrag(); + + const rect = pill.getBoundingClientRect(); + const pointerX = pointerEvent.clientX ?? (rect.left + rect.width / 2); + const pointerY = pointerEvent.clientY ?? (rect.top + rect.height / 2); + + // Placeholder keeps the slot while the pill floats + const placeholder = document.createElement('div'); + placeholder.className = 'reorder-placeholder'; + placeholder.style.height = `${rect.height}px`; + pill.parentNode.insertBefore(placeholder, pill); + + pill.classList.add('dragging'); + pill.style.width = `${rect.width}px`; + pill.style.height = `${rect.height}px`; + pill.style.position = 'fixed'; + pill.style.left = `${rect.left}px`; + pill.style.top = `${rect.top}px`; + pill.style.zIndex = '10'; + pill.style.pointerEvents = 'none'; + + reorderState.dragging = { + key, + pill, + placeholder, + offsetX: pointerX - rect.left, + offsetY: pointerY - rect.top, + pointerId: pointerEvent.pointerId + }; + + updateDraggingPillPosition(pointerX, pointerY); + + try { + if (pointerEvent.pointerId !== undefined) { + document.body.setPointerCapture?.(pointerEvent.pointerId); + } + } catch (_) { /* ignore */ } + + document.addEventListener('pointermove', onReorderPointerMove); + document.addEventListener('pointerup', onReorderPointerUp); + document.addEventListener('pointercancel', onReorderPointerUp); +} + +function updateDraggingPillPosition(clientX, clientY) { + const d = reorderState?.dragging; + if (!d) return; + d.pill.style.left = `${clientX - d.offsetX}px`; + d.pill.style.top = `${clientY - d.offsetY}px`; +} + +function onReorderPointerMove(e) { + const d = reorderState?.dragging; + if (!d) return; + e.preventDefault(); + updateDraggingPillPosition(e.clientX, e.clientY); + + // Find the placeholder's target index based on pointer Y vs. other pills + const list = document.getElementById('reorderList'); + if (!list) return; + const siblings = Array.from(list.children).filter(el => + (el.classList.contains('reorder-pill') && el !== d.pill) || + el.classList.contains('reorder-placeholder') + ); + + let insertBefore = null; + for (const sib of siblings) { + if (sib === d.placeholder) continue; + const rect = sib.getBoundingClientRect(); + if (e.clientY < rect.top + rect.height / 2) { + insertBefore = sib; + break; + } + } + + // FLIP animation: measure before mutation + const pills = Array.from(list.querySelectorAll('.reorder-pill')).filter(p => p !== d.pill); + const firstRects = new Map(pills.map(p => [p, p.getBoundingClientRect()])); + + if (insertBefore) { + if (d.placeholder.nextSibling !== insertBefore) { + list.insertBefore(d.placeholder, insertBefore); + } + } else { + if (list.lastElementChild !== d.placeholder) { + list.appendChild(d.placeholder); + } + } + + // Measure after, animate the delta + pills.forEach(p => { + const first = firstRects.get(p); + const last = p.getBoundingClientRect(); + const dy = first.top - last.top; + if (dy) { + p.style.transition = 'none'; + p.style.transform = `translateY(${dy}px)`; + requestAnimationFrame(() => { + p.style.transition = 'transform .22s cubic-bezier(.2,.8,.2,1)'; + p.style.transform = ''; + }); + } + }); +} + +function onReorderPointerUp(e) { + const d = reorderState?.dragging; + if (!d) return; + + // Commit new position: replace placeholder with pill + const list = document.getElementById('reorderList'); + if (list && d.placeholder.parentNode === list) { + list.insertBefore(d.pill, d.placeholder); + } + d.placeholder.remove(); + + // Clear inline floating styles so the pill sits back in flow + d.pill.classList.remove('dragging'); + d.pill.style.position = ''; + d.pill.style.left = ''; + d.pill.style.top = ''; + d.pill.style.width = ''; + d.pill.style.height = ''; + d.pill.style.zIndex = ''; + d.pill.style.pointerEvents = ''; + + try { + if (d.pointerId !== undefined) { + document.body.releasePointerCapture?.(d.pointerId); + } + } catch (_) { /* ignore */ } + + endReorderDrag(); + + // Rebuild reorderState.order from DOM order + if (list && reorderState) { + const keys = Array.from(list.querySelectorAll('.reorder-pill')).map(p => p.dataset.key); + const lookup = new Map(reorderState.order.map(s => [s.key, s])); + reorderState.order = keys.map(k => lookup.get(k)).filter(Boolean); + // Refresh data-index + animation-delay staleness by updating indices + Array.from(list.querySelectorAll('.reorder-pill')).forEach((p, i) => { + p.dataset.index = String(i); + }); + } +} + +function endReorderDrag() { + document.removeEventListener('pointermove', onReorderPointerMove); + document.removeEventListener('pointerup', onReorderPointerUp); + document.removeEventListener('pointercancel', onReorderPointerUp); + if (reorderState) reorderState.dragging = null; +} + +function closeReorderOverlay() { + const overlay = document.getElementById('reorderOverlay'); + if (!overlay) return; + overlay.classList.remove('active'); + overlay.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('reorder-active'); + document.removeEventListener('keydown', onReorderKeyDown); + overlay.removeEventListener('pointerdown', onReorderOverlayPointerDown); + endReorderDrag(); + reorderState = null; +} + +function cancelReorder() { + closeReorderOverlay(); +} + +async function confirmReorder() { + if (!reorderState) { + closeReorderOverlay(); + return; + } + const newOrder = reorderState.order; + + // No change — just close + const snap = reorderState.snapshot; + const unchanged = newOrder.length === snap.length && + newOrder.every((s, i) => s.key === snap[i].key); + if (unchanged) { + closeReorderOverlay(); + return; + } + + const sections = newOrder.map((s, index) => ({ + key: s.key, + visible: s.visible, + print_visible: s.print_visible !== false, + sort_order: index, + display_name: (s.name && s.name !== s.default_name) ? s.name : null + })); + + try { + await api('/api/sections/order', { method: 'PUT', body: { sections } }); + // Update globals so subsequent renders see the new order + sectionOrder = newOrder.map(s => ({ ...s })); + settingsSectionOrder = newOrder.map(s => ({ ...s })); + reorderSectionElements(); + toast(t('toast.settings_saved'), 'success'); + } catch (err) { + toast(t('toast.settings_failed'), 'error'); + } finally { + closeReorderOverlay(); + } +} + +// Delegated handle listener: press-and-drag on any .section-reorder-handle +document.addEventListener('pointerdown', (e) => { + const handle = e.target.closest && e.target.closest('.section-reorder-handle'); + if (!handle) return; + if (e.button !== undefined && e.button !== 0) return; + const key = handle.dataset.sectionKey; + if (!key) return; + e.preventDefault(); + openReorderOverlay(key, e); +}); diff --git a/public/shared/i18n/de.json b/public/shared/i18n/de.json index 0ee71c93..b620998c 100644 --- a/public/shared/i18n/de.json +++ b/public/shared/i18n/de.json @@ -49,14 +49,17 @@ "action.add_project": "Projekt hinzufügen", "action.move_up": "Nach oben", "action.move_down": "Nach unten", + "action.reorder_sections": "Abschnitte neu anordnen", "btn.save": "Speichern", "btn.cancel": "Abbrechen", "btn.delete": "Löschen", "btn.close": "Schließen", "btn.save_changes": "Änderungen speichern", "btn.done": "Fertig", + "btn.ok": "OK", "btn.add_item": "Eintrag hinzufügen", "btn.delete_section": "Abschnitt löschen", + "reorder.hint": "Abschnitte ziehen, um sie neu anzuordnen. Klicken Sie auf OK zum Speichern.", "modal.edit": "Bearbeiten", "modal.edit_experience": "Erfahrung bearbeiten", "modal.add_experience": "Erfahrung hinzufügen", diff --git a/public/shared/i18n/en.json b/public/shared/i18n/en.json index 0e3c11ac..149d3c6e 100644 --- a/public/shared/i18n/en.json +++ b/public/shared/i18n/en.json @@ -54,6 +54,7 @@ "action.add_project": "Add Project", "action.move_up": "Move Up", "action.move_down": "Move Down", + "action.reorder_sections": "Reorder sections", "btn.save": "Save", "btn.cancel": "Cancel", @@ -61,9 +62,12 @@ "btn.close": "Close", "btn.save_changes": "Save Changes", "btn.done": "Done", + "btn.ok": "OK", "btn.add_item": "Add Item", "btn.delete_section": "Delete Section", + "reorder.hint": "Drag sections to reorder. Click OK to save.", + "modal.edit": "Edit", "modal.edit_experience": "Edit Experience", "modal.add_experience": "Add Experience", diff --git a/public/shared/i18n/es.json b/public/shared/i18n/es.json index e6f025ee..d7eb951a 100644 --- a/public/shared/i18n/es.json +++ b/public/shared/i18n/es.json @@ -49,14 +49,17 @@ "action.add_project": "Añadir proyecto", "action.move_up": "Mover arriba", "action.move_down": "Mover abajo", + "action.reorder_sections": "Reordenar secciones", "btn.save": "Guardar", "btn.cancel": "Cancelar", "btn.delete": "Eliminar", "btn.close": "Cerrar", "btn.save_changes": "Guardar cambios", "btn.done": "Hecho", + "btn.ok": "OK", "btn.add_item": "Añadir elemento", "btn.delete_section": "Eliminar sección", + "reorder.hint": "Arrastre las secciones para reordenarlas. Haga clic en OK para guardar.", "modal.edit": "Editar", "modal.edit_experience": "Editar experiencia", "modal.add_experience": "Añadir experiencia", diff --git a/public/shared/i18n/fr.json b/public/shared/i18n/fr.json index ada99de0..f656422d 100644 --- a/public/shared/i18n/fr.json +++ b/public/shared/i18n/fr.json @@ -49,14 +49,17 @@ "action.add_project": "Ajouter un projet", "action.move_up": "Déplacer vers le haut", "action.move_down": "Déplacer vers le bas", + "action.reorder_sections": "Réorganiser les sections", "btn.save": "Enregistrer", "btn.cancel": "Annuler", "btn.delete": "Supprimer", "btn.close": "Fermer", "btn.save_changes": "Enregistrer les modifications", "btn.done": "Terminé", + "btn.ok": "OK", "btn.add_item": "Ajouter un élément", "btn.delete_section": "Supprimer la section", + "reorder.hint": "Glissez les sections pour les réorganiser. Cliquez sur OK pour enregistrer.", "modal.edit": "Modifier", "modal.edit_experience": "Modifier l'expérience", "modal.add_experience": "Ajouter une expérience", diff --git a/public/shared/i18n/it.json b/public/shared/i18n/it.json index 33928ff4..ee74c61d 100644 --- a/public/shared/i18n/it.json +++ b/public/shared/i18n/it.json @@ -49,14 +49,17 @@ "action.add_project": "Aggiungi progetto", "action.move_up": "Sposta su", "action.move_down": "Sposta giù", + "action.reorder_sections": "Riordina sezioni", "btn.save": "Salva", "btn.cancel": "Annulla", "btn.delete": "Elimina", "btn.close": "Chiudi", "btn.save_changes": "Salva modifiche", "btn.done": "Fatto", + "btn.ok": "OK", "btn.add_item": "Aggiungi elemento", "btn.delete_section": "Elimina sezione", + "reorder.hint": "Trascina le sezioni per riordinarle. Fai clic su OK per salvare.", "modal.edit": "Modifica", "modal.edit_experience": "Modifica esperienza", "modal.add_experience": "Aggiungi esperienza", diff --git a/public/shared/i18n/nl.json b/public/shared/i18n/nl.json index 584db0ef..78b7151a 100644 --- a/public/shared/i18n/nl.json +++ b/public/shared/i18n/nl.json @@ -49,14 +49,17 @@ "action.add_project": "Project toevoegen", "action.move_up": "Omhoog", "action.move_down": "Omlaag", + "action.reorder_sections": "Secties herschikken", "btn.save": "Opslaan", "btn.cancel": "Annuleren", "btn.delete": "Verwijderen", "btn.close": "Sluiten", "btn.save_changes": "Wijzigingen opslaan", "btn.done": "Gereed", + "btn.ok": "OK", "btn.add_item": "Item toevoegen", "btn.delete_section": "Sectie verwijderen", + "reorder.hint": "Sleep secties om ze te herschikken. Klik op OK om op te slaan.", "modal.edit": "Bewerken", "modal.edit_experience": "Ervaring bewerken", "modal.add_experience": "Ervaring toevoegen", diff --git a/public/shared/i18n/pt.json b/public/shared/i18n/pt.json index 57325914..c2b421b6 100644 --- a/public/shared/i18n/pt.json +++ b/public/shared/i18n/pt.json @@ -49,14 +49,17 @@ "action.add_project": "Adicionar Projeto", "action.move_up": "Mover para cima", "action.move_down": "Mover para baixo", + "action.reorder_sections": "Reordenar secções", "btn.save": "Guardar", "btn.cancel": "Cancelar", "btn.delete": "Eliminar", "btn.close": "Fechar", "btn.save_changes": "Guardar Alterações", "btn.done": "Concluído", + "btn.ok": "OK", "btn.add_item": "Adicionar Item", "btn.delete_section": "Eliminar Secção", + "reorder.hint": "Arraste as secções para reordenar. Clique em OK para guardar.", "modal.edit": "Editar", "modal.edit_experience": "Editar Experiência", "modal.add_experience": "Adicionar Experiência", diff --git a/public/shared/i18n/zh.json b/public/shared/i18n/zh.json index 765f20b1..2fb5926e 100644 --- a/public/shared/i18n/zh.json +++ b/public/shared/i18n/zh.json @@ -49,14 +49,17 @@ "action.add_project": "添加项目", "action.move_up": "上移", "action.move_down": "下移", + "action.reorder_sections": "重新排序板块", "btn.save": "保存", "btn.cancel": "取消", "btn.delete": "删除", "btn.close": "关闭", "btn.save_changes": "保存更改", "btn.done": "完成", + "btn.ok": "确定", "btn.add_item": "添加条目", "btn.delete_section": "删除板块", + "reorder.hint": "拖动板块以重新排序。点击确定保存。", "modal.edit": "编辑", "modal.edit_experience": "编辑工作经历", "modal.add_experience": "添加工作经历", diff --git a/version.json b/version.json index b491dfa2..d4f55d59 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.32.0", + "version": "1.33.0", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From 28ea217fa4799acf77e6ff471d0a22f0eb1b061e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 19:19:27 +0000 Subject: [PATCH 2/4] Reorder overlay: hit-test by pill midpoint, not cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop-position indicator was computed from the pointer's Y coordinate, but the floating pill is offset from the cursor (the pointer latches on wherever the user grabbed the handle). That made the placeholder appear in a different slot than where the pill was visually, which was disorienting — especially when grabbing a pill near its top or bottom edge. Now we use the floating pill's own midpoint (its getBoundingClientRect top + height / 2), so the "ghost slot" always sits where the pill looks like it belongs. https://claude.ai/code/session_019TcEZbfYgmysPB4avbS6Bi --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- public/shared/admin.js | 7 ++++++- version.json | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bca3317f..1527320c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.33.1] - 2026-04-17 + +### Fixed +- Section reorder overlay: the drop-position indicator now tracks the floating pill's midpoint instead of the cursor, so grabbing a pill near its top or bottom edge no longer puts the placeholder one slot off from where the pill is actually shown. + ## [1.33.0] - 2026-04-17 ### Added diff --git a/package-lock.json b/package-lock.json index 5c024601..64c9375e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.33.0", + "version": "1.33.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.33.0", + "version": "1.33.1", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index fbe71de5..19517d89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.33.0", + "version": "1.33.1", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/public/shared/admin.js b/public/shared/admin.js index 9f9bb0e7..dcd82e83 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -5038,11 +5038,16 @@ function onReorderPointerMove(e) { el.classList.contains('reorder-placeholder') ); + // Target index follows the floating pill's midpoint, not the cursor's Y + // (the pointer typically grabs the pill off-center). + const pillRect = d.pill.getBoundingClientRect(); + const pillMidY = pillRect.top + pillRect.height / 2; + let insertBefore = null; for (const sib of siblings) { if (sib === d.placeholder) continue; const rect = sib.getBoundingClientRect(); - if (e.clientY < rect.top + rect.height / 2) { + if (pillMidY < rect.top + rect.height / 2) { insertBefore = sib; break; } diff --git a/version.json b/version.json index d4f55d59..7daef4e8 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.33.0", + "version": "1.33.1", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From cd71445dba8efed7331c5a05e4b88b0a100ade93 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 19:23:55 +0000 Subject: [PATCH 3/4] Reorder overlay: persist new order into active dataset confirmReorder PUT /api/sections/order but forgot to call autoSaveActiveDataset(). When a dataset is loaded (which it is by default via loadDefaultDatasetOnStartup), the live section_visibility table got the new order, but the dataset's saved snapshot kept the old one. On reload, the dataset's snapshot was restored and the reorder appeared to vanish. Mirrors what saveSettingsSectionOrder does at admin.js:2992. https://claude.ai/code/session_019TcEZbfYgmysPB4avbS6Bi --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- public/shared/admin.js | 3 +++ version.json | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1527320c..7d6ec1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.33.2] - 2026-04-17 + +### Fixed +- Section reorder overlay: the new order is now also synced into the active dataset snapshot, matching how the Settings → Sections Save button behaves. Without this, the live `section_visibility` table was updated but the active dataset kept the old order, and a page reload restored it — making the reorder appear not to persist. + ## [1.33.1] - 2026-04-17 ### Fixed diff --git a/package-lock.json b/package-lock.json index 64c9375e..1e3b326c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.33.1", + "version": "1.33.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.33.1", + "version": "1.33.2", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index 19517d89..1e07c14e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.33.1", + "version": "1.33.2", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/public/shared/admin.js b/public/shared/admin.js index dcd82e83..ef7d7c82 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -5177,6 +5177,9 @@ async function confirmReorder() { sectionOrder = newOrder.map(s => ({ ...s })); settingsSectionOrder = newOrder.map(s => ({ ...s })); reorderSectionElements(); + // Persist into the active dataset snapshot; otherwise a page reload + // restores the dataset's saved order and the change appears lost. + autoSaveActiveDataset(); toast(t('toast.settings_saved'), 'success'); } catch (err) { toast(t('toast.settings_failed'), 'error'); diff --git a/version.json b/version.json index 7daef4e8..97f282d7 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.33.1", + "version": "1.33.2", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" } From a006874060ae191c0fbcf9dc2bdcc7141881fedb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 19:25:39 +0000 Subject: [PATCH 4/4] Reorder overlay: snap pill to cursor on handle-initiated drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the drag starts from a section-heading handle (outside the overlay), the cursor is typically on the left edge of the section while the pill is in the overlay's centered list. Preserving the pointer→pill offset meant the pill floated far from the cursor and the user had to traverse the viewport before the pill caught up — annoying on a small trackpad. Now, if the pointerdown falls outside the pill's bounding rect (or the caller passes snapPillToCursor: true), we center the pill on the cursor. Drags started on a pill already inside the overlay keep the original offset, so grabbing near the edge still feels natural. https://claude.ai/code/session_019TcEZbfYgmysPB4avbS6Bi --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- public/shared/admin.js | 20 ++++++++++++++++---- version.json | 2 +- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6ec1ae..854c28b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.33.3] - 2026-04-17 + +### Changed +- Section reorder overlay: when a drag is started from a section-heading handle, the floating pill now snaps under the cursor (centered on the pointer) instead of preserving the pointer→pill offset. The handle can be far from the overlay's center, which previously required dragging the cursor a long way before the pill caught up — painful on small trackpads. + ## [1.33.2] - 2026-04-17 ### Fixed diff --git a/package-lock.json b/package-lock.json index 1e3b326c..566e1bf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.33.2", + "version": "1.33.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.33.2", + "version": "1.33.3", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index 1e07c14e..4e457ef7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.33.2", + "version": "1.33.3", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/public/shared/admin.js b/public/shared/admin.js index ef7d7c82..30e716a3 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -4948,10 +4948,12 @@ function openReorderOverlay(grabbedKey, pointerEvent) { // If a handle triggered the open, kick off a drag on that pill immediately. // Suppress the pill's entrance animation so getBoundingClientRect matches final layout. + // Snap the pill under the cursor so the user doesn't have to chase it from + // the section heading handle (potentially far from the overlay center). if (grabbedKey && pointerEvent) { const pill = document.querySelector(`#reorderList .reorder-pill[data-key="${grabbedKey}"]`); if (pill) pill.style.animation = 'none'; - beginReorderDrag(grabbedKey, pointerEvent); + beginReorderDrag(grabbedKey, pointerEvent, { snapPillToCursor: true }); } } @@ -4968,7 +4970,7 @@ function onReorderKeyDown(e) { } } -function beginReorderDrag(key, pointerEvent) { +function beginReorderDrag(key, pointerEvent, opts = {}) { if (!reorderState) return; const pill = document.querySelector(`#reorderList .reorder-pill[data-key="${key}"]`); if (!pill) return; @@ -4980,6 +4982,16 @@ function beginReorderDrag(key, pointerEvent) { const pointerX = pointerEvent.clientX ?? (rect.left + rect.width / 2); const pointerY = pointerEvent.clientY ?? (rect.top + rect.height / 2); + // If the pointer is outside the pill (e.g. drag initiated from the + // section-heading handle on the left of the page), center the pill on + // the cursor instead of preserving the pointer→pill offset. Otherwise + // the pill floats far from the cursor and is hard to reach on a small + // trackpad. Caller can also force this via opts.snapPillToCursor. + const pointerOutsidePill = + pointerX < rect.left || pointerX > rect.right || + pointerY < rect.top || pointerY > rect.bottom; + const snap = opts.snapPillToCursor || pointerOutsidePill; + // Placeholder keeps the slot while the pill floats const placeholder = document.createElement('div'); placeholder.className = 'reorder-placeholder'; @@ -4999,8 +5011,8 @@ function beginReorderDrag(key, pointerEvent) { key, pill, placeholder, - offsetX: pointerX - rect.left, - offsetY: pointerY - rect.top, + offsetX: snap ? rect.width / 2 : pointerX - rect.left, + offsetY: snap ? rect.height / 2 : pointerY - rect.top, pointerId: pointerEvent.pointerId }; diff --git a/version.json b/version.json index 97f282d7..2b107cd7 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.33.2", + "version": "1.33.3", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" }