Skip to content

Commit 89b6bdf

Browse files
authored
Merge pull request #137 from vincentmakes/claude/drag-reorder-undo-redo-pS5tQ
Add undo/redo functionality to admin UI
2 parents bbf6ede + f551008 commit 89b6bdf

18 files changed

Lines changed: 560 additions & 31 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to CV Manager will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/).
66

7+
## [1.43.0] - 2026-04-21
8+
9+
### Added
10+
- **Undo / Redo for the admin UI.** Two new icon-only toolbar buttons (Undo, Redo) sit at the start of the admin toolbar, and the standard keyboard shortcuts (`Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y`) revert or replay the most recent content change. Coverage: profile edits, full CRUD on experiences / certifications / education / skills / projects, custom-section + item CRUD, section visibility and print-visibility toggles, and section reordering. The "Show all items" bulk action collapses into a single undo step. Implementation captures a snapshot of the live CV state via `GET /api/cv` after each successful mutation and restores via `POST /api/import` (the same atomic-replace path used by user import), so no new backend endpoints were added. History caps at 50 entries and is in-memory only — it's wiped when you load, create, or delete a saved dataset, or run the user-facing Import (those operations replace the live state wholesale, so older history would no longer refer to anything coherent). Page reload also clears history. Modals, text inputs, and `contenteditable` elements opt out of the shortcut so native form undo continues to work mid-edit. Theme, settings, section rename, file uploads (logos, profile pictures), and dataset operations are intentionally **not** undoable in this release. Six new toast / button labels (`toolbar.undo`, `toolbar.redo`, `toast.undone`, `toast.redone`, `toast.nothing_to_undo`, `toast.nothing_to_redo`) translated across all 8 supported locales.
11+
12+
### Fixed
13+
- Toast notifications no longer appear in the printed PDF / Print preview. The toast element gained the `no-print` class and `@media print` now hides `.toast` outright, so an auto-save (or any of the new undo/redo) toast that happens to be on-screen when the user hits Print is excluded from the output.
14+
715
## [1.42.0] - 2026-04-20
816

917
### Added

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cv-manager",
3-
"version": "1.42.0",
3+
"version": "1.43.0",
44
"description": "Professional CV Management System",
55
"main": "src/server.js",
66
"scripts": {

public/index.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
<div class="toolbar no-print">
1919
<div class="toolbar-title"></div>
2020
<div class="toolbar-actions" id="toolbarActions">
21+
<button id="undoBtn" class="btn btn-secondary btn-icon-only" onclick="undoAction()" disabled
22+
data-i18n-title="toolbar.undo" title="Undo">
23+
<span class="material-symbols-outlined">undo</span>
24+
</button>
25+
<button id="redoBtn" class="btn btn-secondary btn-icon-only" onclick="redoAction()" disabled
26+
data-i18n-title="toolbar.redo" title="Redo">
27+
<span class="material-symbols-outlined">redo</span>
28+
</button>
2129
<button class="btn btn-secondary" onclick="openCvManager()">
2230
<span class="material-symbols-outlined">folder_managed</span>
2331
<span data-i18n="toolbar.cv_manager">CV Manager</span>
@@ -916,7 +924,7 @@ <h3 class="modal-title" id="customItemModalTitle" data-i18n="custom_item.add_tit
916924
</div>
917925

918926
<!-- Toast -->
919-
<div class="toast" id="toast"></div>
927+
<div class="toast no-print" id="toast"></div>
920928

921929
<!-- ATS PDF Export Modal -->
922930
<div class="modal-overlay ats-pdf-modal" id="atsPdfModalOverlay">

public/shared/admin.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2820,6 +2820,7 @@
28202820

28212821
@media print {
28222822
.toolbar, .add-btn, .item-actions, .section-actions, .no-print { display: none !important; }
2823+
.toast { display: none !important; }
28232824
.container { margin-top: 0 !important; max-width: 100% !important; margin: 0 !important; padding: 0 10px !important; }
28242825
.section { page-break-inside: avoid; break-inside: avoid; }
28252826
#section-experience { page-break-inside: auto; break-inside: auto; }

public/shared/admin.js

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,28 +1979,30 @@ async function confirmDelete(endpoint, id) {
19791979

19801980
// Show All Items
19811981
async function showAllItems() {
1982-
const cv = await api('/api/cv');
1983-
1984-
for (const section of Object.keys(sectionVisibility)) {
1985-
await api(`/api/sections/${section}`, { method: 'PUT', body: { visible: true } });
1986-
}
1987-
1988-
for (const exp of cv.experiences) {
1989-
await api(`/api/experiences/${exp.id}`, { method: 'PUT', body: { ...exp, visible: true } });
1990-
}
1991-
for (const cert of cv.certifications) {
1992-
await api(`/api/certifications/${cert.id}`, { method: 'PUT', body: { ...cert, visible: true } });
1993-
}
1994-
for (const edu of cv.education) {
1995-
await api(`/api/education/${edu.id}`, { method: 'PUT', body: { ...edu, visible: true } });
1996-
}
1997-
for (const skill of cv.skills) {
1998-
await api(`/api/skills/${skill.id}`, { method: 'PUT', body: { ...skill, visible: true } });
1999-
}
2000-
for (const proj of cv.projects) {
2001-
await api(`/api/projects/${proj.id}`, { method: 'PUT', body: { ...proj, visible: true } });
2002-
}
2003-
1982+
await UndoManager.batch(async () => {
1983+
const cv = await api('/api/cv');
1984+
1985+
for (const section of Object.keys(sectionVisibility)) {
1986+
await api(`/api/sections/${section}`, { method: 'PUT', body: { visible: true } });
1987+
}
1988+
1989+
for (const exp of cv.experiences) {
1990+
await api(`/api/experiences/${exp.id}`, { method: 'PUT', body: { ...exp, visible: true } });
1991+
}
1992+
for (const cert of cv.certifications) {
1993+
await api(`/api/certifications/${cert.id}`, { method: 'PUT', body: { ...cert, visible: true } });
1994+
}
1995+
for (const edu of cv.education) {
1996+
await api(`/api/education/${edu.id}`, { method: 'PUT', body: { ...edu, visible: true } });
1997+
}
1998+
for (const skill of cv.skills) {
1999+
await api(`/api/skills/${skill.id}`, { method: 'PUT', body: { ...skill, visible: true } });
2000+
}
2001+
for (const proj of cv.projects) {
2002+
await api(`/api/projects/${proj.id}`, { method: 'PUT', body: { ...proj, visible: true } });
2003+
}
2004+
});
2005+
20042006
await initAdmin();
20052007
toast(t('toast.all_visible'));
20062008
autoSaveActiveDataset();
@@ -2062,6 +2064,9 @@ async function importData(event) {
20622064
toast(result.error, 'error');
20632065
return;
20642066
}
2067+
// Imported data wholesale-replaces the live state — earlier history
2068+
// doesn't refer to anything coherent anymore.
2069+
if (typeof UndoManager !== 'undefined') UndoManager.clear();
20652070
// Clear active dataset — imported data doesn't belong to any dataset
20662071
hideActiveDatasetBanner();
20672072
// Switch UI locale to match imported language
@@ -3366,6 +3371,7 @@ async function submitSaveAs() {
33663371
if (versionGroup) body.version_group = versionGroup;
33673372
const result = await api('/api/datasets', { method: 'POST', body });
33683373
if (result.success) {
3374+
if (typeof UndoManager !== 'undefined') UndoManager.clear();
33693375
await applyLoadedDatasetResult({
33703376
id: result.id,
33713377
name,
@@ -3645,6 +3651,9 @@ async function loadDataset(id, name) {
36453651
try {
36463652
const result = await api(`/api/datasets/${id}/load`, { method: 'POST' });
36473653
if (result.success) {
3654+
// Switching datasets replaces the entire content slice — any prior
3655+
// undo history would restore data that no longer belongs.
3656+
if (typeof UndoManager !== 'undefined') UndoManager.clear();
36483657
// Set active dataset state BEFORE initAdmin (so initAdmin skips auto-load)
36493658
await applyLoadedDatasetResult(result);
36503659
persistActiveDataset();
@@ -3676,8 +3685,10 @@ async function deleteDataset(id, name) {
36763685
toast(result.error, 'error');
36773686
return;
36783687
}
3679-
// If we deleted the active dataset, clear the banner
3688+
// If we deleted the active dataset, the live tables may have been
3689+
// swapped to another default — earlier undo history is no longer valid.
36803690
if (activeDatasetId === id) {
3691+
if (typeof UndoManager !== 'undefined') UndoManager.clear();
36813692
hideActiveDatasetBanner();
36823693
}
36833694
await loadDatasetsList();
@@ -4623,7 +4634,28 @@ document.addEventListener('keydown', (e) => {
46234634
closeCustomItemModal();
46244635
const langDropdown = document.getElementById('languagePickerDropdown');
46254636
if (langDropdown) langDropdown.classList.remove('active');
4637+
return;
46264638
}
4639+
4640+
// Undo / Redo: skip if a modal is open or focus is on an editable element
4641+
// so native form undo and modal-internal flows aren't disrupted.
4642+
const isMod = e.ctrlKey || e.metaKey;
4643+
if (!isMod) return;
4644+
const key = e.key.toLowerCase();
4645+
const isUndo = key === 'z' && !e.shiftKey;
4646+
const isRedo = (key === 'z' && e.shiftKey) || key === 'y';
4647+
if (!isUndo && !isRedo) return;
4648+
4649+
const tgt = e.target;
4650+
const tag = tgt && tgt.tagName ? tgt.tagName.toLowerCase() : '';
4651+
if (tag === 'input' || tag === 'textarea' || (tgt && tgt.isContentEditable)) return;
4652+
if (currentModal && currentModal.type !== null) return;
4653+
// Also skip if any modal-overlay is currently active
4654+
const openModal = document.querySelector('.modal-overlay.active');
4655+
if (openModal) return;
4656+
4657+
e.preventDefault();
4658+
if (isUndo) undoAction(); else redoAction();
46274659
});
46284660

46294661
// ===========================

public/shared/i18n/de.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"toolbar.settings": "Einstellungen",
33
"toolbar.theme": "Design",
4+
"toolbar.undo": "Rückgängig",
5+
"toolbar.redo": "Wiederholen",
46
"toolbar.open": "Öffnen...",
57
"toolbar.save_as": "Speichern unter...",
68
"toolbar.language": "Sprache",
@@ -350,6 +352,10 @@
350352
"custom_item.add_item": "Eintrag hinzufügen",
351353
"toast.saved": "Erfolgreich gespeichert",
352354
"toast.deleted": "Gelöscht",
355+
"toast.undone": "Rückgängig gemacht",
356+
"toast.redone": "Wiederhergestellt",
357+
"toast.nothing_to_undo": "Nichts rückgängig zu machen",
358+
"toast.nothing_to_redo": "Nichts zu wiederholen",
353359
"toast.visibility_updated": "Sichtbarkeit aktualisiert",
354360
"toast.section_visibility_updated": "Abschnittssichtbarkeit aktualisiert",
355361
"toast.section_print_visibility_updated": "Drucksichtbarkeit des Abschnitts aktualisiert",

public/shared/i18n/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"toolbar.settings": "Settings",
33
"toolbar.theme": "Theme",
4+
"toolbar.undo": "Undo",
5+
"toolbar.redo": "Redo",
46
"toolbar.open": "Open...",
57
"toolbar.save_as": "Save As...",
68
"toolbar.cv_manager": "CV Manager",
@@ -360,6 +362,10 @@
360362
"custom_item.add_item": "Add Item",
361363
"toast.saved": "Saved successfully",
362364
"toast.deleted": "Deleted",
365+
"toast.undone": "Undone",
366+
"toast.redone": "Redone",
367+
"toast.nothing_to_undo": "Nothing to undo",
368+
"toast.nothing_to_redo": "Nothing to redo",
363369
"toast.visibility_updated": "Visibility updated",
364370
"toast.section_visibility_updated": "Section visibility updated",
365371
"toast.section_print_visibility_updated": "Section print visibility updated",

public/shared/i18n/es.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"toolbar.settings": "Configuración",
33
"toolbar.theme": "Tema",
4+
"toolbar.undo": "Deshacer",
5+
"toolbar.redo": "Rehacer",
46
"toolbar.open": "Abrir...",
57
"toolbar.save_as": "Guardar como...",
68
"toolbar.language": "Idioma",
@@ -350,6 +352,10 @@
350352
"custom_item.add_item": "Añadir elemento",
351353
"toast.saved": "Guardado correctamente",
352354
"toast.deleted": "Eliminado",
355+
"toast.undone": "Deshecho",
356+
"toast.redone": "Rehecho",
357+
"toast.nothing_to_undo": "Nada que deshacer",
358+
"toast.nothing_to_redo": "Nada que rehacer",
353359
"toast.visibility_updated": "Visibilidad actualizada",
354360
"toast.section_visibility_updated": "Visibilidad de sección actualizada",
355361
"toast.section_print_visibility_updated": "Visibilidad de impresión de sección actualizada",

public/shared/i18n/fr.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"toolbar.settings": "Paramètres",
33
"toolbar.theme": "Thème",
4+
"toolbar.undo": "Annuler",
5+
"toolbar.redo": "Rétablir",
46
"toolbar.open": "Ouvrir...",
57
"toolbar.save_as": "Enregistrer sous...",
68
"toolbar.language": "Langue",
@@ -350,6 +352,10 @@
350352
"custom_item.add_item": "Ajouter un élément",
351353
"toast.saved": "Enregistré avec succès",
352354
"toast.deleted": "Supprimé",
355+
"toast.undone": "Annulé",
356+
"toast.redone": "Rétabli",
357+
"toast.nothing_to_undo": "Rien à annuler",
358+
"toast.nothing_to_redo": "Rien à rétablir",
353359
"toast.visibility_updated": "Visibilité mise à jour",
354360
"toast.section_visibility_updated": "Visibilité de la section mise à jour",
355361
"toast.section_print_visibility_updated": "Visibilité à l'impression de la section mise à jour",

0 commit comments

Comments
 (0)