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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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.14.0] - 2026-03-04

### Added
- Picture Grid layout type for custom sections — upload images displayed in a responsive 3-column grid, centered when fewer than 3 pictures are present
- Full CRUD for picture grid items with file upload, optional captions, and automatic file cleanup on deletion

## [1.13.0] - 2026-03-03

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cv-manager",
"version": "1.13.0",
"version": "1.14.0",
"description": "Professional CV Management System",
"main": "src/server.js",
"scripts": {
Expand Down
135 changes: 123 additions & 12 deletions public/shared/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ function renderCustomSection(section) {
case 'free-text':
contentHtml = renderFreeTextLayout(items);
break;
case 'picture-grid':
contentHtml = renderPictureGridLayout(items);
break;
default:
contentHtml = renderGridLayout(items, 3);
}
Expand Down Expand Up @@ -626,6 +629,21 @@ function renderFreeTextLayout(items) {
}).join('')}</div>`;
}

// Picture grid layout - display uploaded images in a 3-column grid
function renderPictureGridLayout(items) {
if (items.length === 0) return `<p class="empty-section">${t('custom_item.no_pictures')}</p>`;

return `<div class="custom-picture-grid">${items.map(item => {
const visible = item.visible !== false;
return `
<div class="custom-picture-item ${visible ? '' : 'hidden-print'}">
${item.image ? `<img src="/uploads/${escapeHtml(item.image)}?${Date.now()}" alt="${escapeHtml(item.title || '')}" class="custom-picture-img">` : `<div class="custom-picture-placeholder">${t('custom_item.no_image')}</div>`}
${item.title ? `<div class="custom-picture-caption">${escapeHtml(item.title)}</div>` : ''}
</div>
`;
}).join('')}</div>`;
}

// Load Sections with visibility toggle (admin version)
async function loadSectionsAdmin() {
const sections = await api('/api/sections');
Expand Down Expand Up @@ -3036,14 +3054,15 @@ async function manageCustomSectionItems(sectionId) {
</div>
<button class="add-btn" onclick="openCustomItemModal(${sectionId})" style="margin-top: 0; margin-bottom: 12px;">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Add Item
${section.layout_type === 'picture-grid' ? t('custom_item.add_picture') : t('custom_item.add_item')}
</button>
<div class="custom-items-list" data-section-id="${sectionId}">
${items.length === 0 ? '<p style="color: var(--gray-500); text-align: center; padding: 20px;">No items yet.</p>' : items.map(item => `
<div class="custom-item-row" data-id="${item.id}" draggable="true">
<div class="drag-handle" title="Drag to reorder">${dragHandleIcon()}</div>
${section.layout_type === 'picture-grid' && item.image ? `<img src="/uploads/${escapeHtml(item.image)}?${Date.now()}" alt="" class="custom-item-thumb">` : ''}
<div class="custom-item-info">
<div class="custom-item-title">${escapeHtml(item.title || 'Untitled')}</div>
<div class="custom-item-title">${escapeHtml(item.title || (section.layout_type === 'picture-grid' ? t('custom_item.picture') : 'Untitled'))}</div>
${item.subtitle ? `<div class="custom-item-subtitle">${escapeHtml(item.subtitle)}</div>` : ''}
</div>
<div class="custom-item-actions">
Expand Down Expand Up @@ -3149,6 +3168,28 @@ function openCustomItemModal(sectionId, itemId = null) {
<div class="form-hint">${t('custom_item.text_content_hint')}</div>
</div>
`;
} else if (section.layout_type === 'picture-grid') {
// Picture grid form - picture upload with optional caption
formHtml = `
<div class="form-group">
<label class="form-label">${t('custom_item.picture')}</label>
<div class="picture-grid-preview" id="ci-picture-preview">
${item.image ? `<img src="/uploads/${escapeHtml(item.image)}?${Date.now()}" alt="" class="picture-grid-preview-img">` : `<div class="picture-grid-placeholder">${t('custom_item.no_image')}</div>`}
</div>
<input type="file" id="ci-picture-file" accept="image/jpeg,image/png,image/webp" style="display:none" onchange="previewPictureGridImage(this)">
<div style="display: flex; gap: 8px; margin-top: 8px;">
<button type="button" class="btn btn-ghost btn-sm" onclick="document.getElementById('ci-picture-file').click()">
${t('custom_item.choose_picture')}
</button>
${item.image ? `<button type="button" class="btn btn-ghost btn-sm" onclick="removePictureGridImage()" id="ci-remove-picture-btn">${t('custom_item.remove_picture')}</button>` : ''}
</div>
<div class="form-hint">${t('custom_item.picture_hint')}</div>
</div>
<div class="form-group">
<label class="form-label">${t('custom_item.caption_optional')}</label>
<input type="text" class="form-input" id="ci-title" value="${escapeHtml(item.title || '')}" placeholder="${t('custom_item.caption_placeholder')}">
</div>
`;
} else {
// Generic form for other layouts
const hideTitle = item.metadata?.hideTitle || false;
Expand Down Expand Up @@ -3213,9 +3254,54 @@ function updateSocialPlatformFields() {
titleInput.placeholder = placeholders[platform] || 'Display Name';
}

// Picture grid helpers
let pendingPictureGridFile = null;
let pictureGridRemoved = false;

function previewPictureGridImage(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
if (file.size > 5 * 1024 * 1024) { toast(t('toast.file_too_large'), 'error'); return; }
pendingPictureGridFile = file;
pictureGridRemoved = false;
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.getElementById('ci-picture-preview');
if (preview) preview.innerHTML = `<img src="${e.target.result}" alt="" class="picture-grid-preview-img">`;
// Show remove button
let removeBtn = document.getElementById('ci-remove-picture-btn');
if (!removeBtn) {
const btnContainer = input.previousElementSibling?.nextElementSibling || input.nextElementSibling;
if (btnContainer) {
removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-ghost btn-sm';
removeBtn.id = 'ci-remove-picture-btn';
removeBtn.onclick = removePictureGridImage;
removeBtn.textContent = t('custom_item.remove_picture');
btnContainer.appendChild(removeBtn);
}
}
};
reader.readAsDataURL(file);
}

function removePictureGridImage() {
pendingPictureGridFile = null;
pictureGridRemoved = true;
const preview = document.getElementById('ci-picture-preview');
if (preview) preview.innerHTML = `<div class="picture-grid-placeholder">${t('custom_item.no_image')}</div>`;
const fileInput = document.getElementById('ci-picture-file');
if (fileInput) fileInput.value = '';
const removeBtn = document.getElementById('ci-remove-picture-btn');
if (removeBtn) removeBtn.remove();
}

function closeCustomItemModal() {
document.getElementById('customItemModalOverlay').classList.remove('active');
currentCustomItem.itemId = null;
pendingPictureGridFile = null;
pictureGridRemoved = false;
}

async function saveCustomItem() {
Expand All @@ -3237,33 +3323,58 @@ async function saveCustomItem() {
metadata = { hideTitle };
}

// Validation - title not required for bullet-list, free-text, or when hideTitle is checked
if (section.layout_type !== 'bullet-list' && section.layout_type !== 'free-text' && !metadata.hideTitle && !title) {
// Validation - title not required for bullet-list, free-text, picture-grid, or when hideTitle is checked
if (section.layout_type !== 'bullet-list' && section.layout_type !== 'free-text' && section.layout_type !== 'picture-grid' && !metadata.hideTitle && !title) {
toast(t('toast.enter_title'), 'error');
return;
}

// Bullet list and free text require description
if ((section.layout_type === 'bullet-list' || section.layout_type === 'free-text') && !description) {
toast(section.layout_type === 'free-text' ? t('toast.enter_text') : t('toast.enter_bullet'), 'error');
return;
}


// Picture grid requires either an existing image, a pending file, or it's an edit with an image
if (section.layout_type === 'picture-grid' && !currentCustomItem.itemId && !pendingPictureGridFile) {
toast(t('toast.select_picture'), 'error');
return;
}

try {
if (currentCustomItem.itemId) {
await api(`/api/custom-sections/${currentCustomItem.sectionId}/items/${currentCustomItem.itemId}`, {
method: 'PUT',
body: { title, subtitle, description, link, metadata }
let itemId = currentCustomItem.itemId;
if (itemId) {
await api(`/api/custom-sections/${currentCustomItem.sectionId}/items/${itemId}`, {
method: 'PUT',
body: { title, subtitle, description, link, metadata }
});
toast(t('toast.item_updated'));
} else {
await api(`/api/custom-sections/${currentCustomItem.sectionId}/items`, {
const result = await api(`/api/custom-sections/${currentCustomItem.sectionId}/items`, {
method: 'POST',
body: { title, subtitle, description, link, metadata }
});
itemId = result.id;
toast(t('toast.item_added'));
}


// Handle picture upload for picture-grid
if (section.layout_type === 'picture-grid' && itemId) {
if (pictureGridRemoved) {
// Clear the image field (file already deleted by server on next upload or we handle it)
await api(`/api/custom-sections/${currentCustomItem.sectionId}/items/${itemId}`, {
method: 'PUT',
body: { title, subtitle, description, link, image: '', metadata }
});
}
if (pendingPictureGridFile) {
const formData = new FormData();
formData.append('picture', pendingPictureGridFile);
const uploadRes = await fetch(`/api/custom-sections/${currentCustomItem.sectionId}/items/${itemId}/picture`, { method: 'POST', body: formData });
if (!uploadRes.ok) { toast(t('toast.upload_failed'), 'error'); }
}
}

closeCustomItemModal();
await loadCustomSectionsData();
manageCustomSectionItems(currentCustomItem.sectionId);
Expand Down
12 changes: 12 additions & 0 deletions public/shared/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@
"custom_item.description_optional": "Beschreibung (optional)",
"custom_item.link_url_optional": "Link-URL (optional)",
"custom_item.no_items": "Noch keine Einträge.",
"custom_item.no_pictures": "Noch keine Bilder hinzugefügt.",
"custom_item.no_image": "Kein Bild",
"custom_item.picture": "Bild",
"custom_item.choose_picture": "Bild auswählen",
"custom_item.remove_picture": "Entfernen",
"custom_item.picture_hint": "JPEG, PNG oder WebP. Max. 5 MB.",
"custom_item.caption_optional": "Bildunterschrift (optional)",
"custom_item.caption_placeholder": "z. B. Bildbeschreibung",
"custom_item.add_picture": "Bild hinzufügen",
"custom_item.add_item": "Eintrag hinzufügen",

"toast.saved": "Erfolgreich gespeichert",
"toast.deleted": "Gelöscht",
Expand Down Expand Up @@ -289,6 +299,8 @@
"toast.section_save_failed": "Abschnitt konnte nicht gespeichert werden",
"toast.section_deleted": "Abschnitt gelöscht",
"toast.section_delete_failed": "Abschnitt konnte nicht gelöscht werden",
"toast.select_picture": "Bitte wählen Sie ein Bild aus",
"toast.file_too_large": "Datei zu groß. Maximale Größe ist 5 MB.",
"toast.enter_title": "Bitte geben Sie einen Titel ein",
"toast.enter_text": "Bitte geben Sie einen Text ein",
"toast.enter_bullet": "Bitte geben Sie mindestens einen Aufzählungspunkt ein",
Expand Down
12 changes: 12 additions & 0 deletions public/shared/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@
"custom_item.description_optional": "Description (optional)",
"custom_item.link_url_optional": "Link URL (optional)",
"custom_item.no_items": "No items yet.",
"custom_item.no_pictures": "No pictures added yet.",
"custom_item.no_image": "No image",
"custom_item.picture": "Picture",
"custom_item.choose_picture": "Choose Picture",
"custom_item.remove_picture": "Remove",
"custom_item.picture_hint": "JPEG, PNG or WebP. Max 5MB.",
"custom_item.caption_optional": "Caption (optional)",
"custom_item.caption_placeholder": "e.g., Photo description",
"custom_item.add_picture": "Add Picture",
"custom_item.add_item": "Add Item",

"toast.saved": "Saved successfully",
"toast.deleted": "Deleted",
Expand Down Expand Up @@ -289,6 +299,8 @@
"toast.section_save_failed": "Failed to save section",
"toast.section_deleted": "Section deleted",
"toast.section_delete_failed": "Failed to delete section",
"toast.select_picture": "Please select a picture",
"toast.file_too_large": "File too large. Maximum size is 5MB.",
"toast.enter_title": "Please enter a title",
"toast.enter_text": "Please enter some text",
"toast.enter_bullet": "Please enter at least one bullet point",
Expand Down
12 changes: 12 additions & 0 deletions public/shared/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@
"custom_item.description_optional": "Descripción (opcional)",
"custom_item.link_url_optional": "URL del enlace (opcional)",
"custom_item.no_items": "Aún no hay elementos.",
"custom_item.no_pictures": "Aún no se han añadido imágenes.",
"custom_item.no_image": "Sin imagen",
"custom_item.picture": "Imagen",
"custom_item.choose_picture": "Elegir imagen",
"custom_item.remove_picture": "Eliminar",
"custom_item.picture_hint": "JPEG, PNG o WebP. Máx. 5 MB.",
"custom_item.caption_optional": "Pie de foto (opcional)",
"custom_item.caption_placeholder": "ej. Descripción de la foto",
"custom_item.add_picture": "Añadir imagen",
"custom_item.add_item": "Añadir elemento",

"toast.saved": "Guardado correctamente",
"toast.deleted": "Eliminado",
Expand Down Expand Up @@ -289,6 +299,8 @@
"toast.section_save_failed": "Error al guardar la sección",
"toast.section_deleted": "Sección eliminada",
"toast.section_delete_failed": "Error al eliminar la sección",
"toast.select_picture": "Por favor, selecciona una imagen",
"toast.file_too_large": "Archivo demasiado grande. Tamaño máximo: 5 MB.",
"toast.enter_title": "Por favor, introduce un título",
"toast.enter_text": "Por favor, introduce algo de texto",
"toast.enter_bullet": "Por favor, introduce al menos una viñeta",
Expand Down
12 changes: 12 additions & 0 deletions public/shared/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@
"custom_item.description_optional": "Description (facultatif)",
"custom_item.link_url_optional": "URL du lien (facultatif)",
"custom_item.no_items": "Aucun élément pour le moment.",
"custom_item.no_pictures": "Aucune image ajoutée.",
"custom_item.no_image": "Pas d'image",
"custom_item.picture": "Image",
"custom_item.choose_picture": "Choisir une image",
"custom_item.remove_picture": "Supprimer",
"custom_item.picture_hint": "JPEG, PNG ou WebP. 5 Mo max.",
"custom_item.caption_optional": "Légende (facultatif)",
"custom_item.caption_placeholder": "ex. Description de la photo",
"custom_item.add_picture": "Ajouter une image",
"custom_item.add_item": "Ajouter un élément",

"toast.saved": "Enregistré avec succès",
"toast.deleted": "Supprimé",
Expand Down Expand Up @@ -289,6 +299,8 @@
"toast.section_save_failed": "Échec de l'enregistrement de la section",
"toast.section_deleted": "Section supprimée",
"toast.section_delete_failed": "Échec de la suppression de la section",
"toast.select_picture": "Veuillez sélectionner une image",
"toast.file_too_large": "Fichier trop volumineux. Taille maximale : 5 Mo.",
"toast.enter_title": "Veuillez entrer un titre",
"toast.enter_text": "Veuillez entrer du texte",
"toast.enter_bullet": "Veuillez entrer au moins une puce",
Expand Down
12 changes: 12 additions & 0 deletions public/shared/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@
"custom_item.description_optional": "Descrizione (opzionale)",
"custom_item.link_url_optional": "URL link (opzionale)",
"custom_item.no_items": "Nessun elemento presente.",
"custom_item.no_pictures": "Nessuna immagine aggiunta.",
"custom_item.no_image": "Nessuna immagine",
"custom_item.picture": "Immagine",
"custom_item.choose_picture": "Scegli immagine",
"custom_item.remove_picture": "Rimuovi",
"custom_item.picture_hint": "JPEG, PNG o WebP. Max 5 MB.",
"custom_item.caption_optional": "Didascalia (opzionale)",
"custom_item.caption_placeholder": "es. Descrizione della foto",
"custom_item.add_picture": "Aggiungi immagine",
"custom_item.add_item": "Aggiungi elemento",

"toast.saved": "Salvato con successo",
"toast.deleted": "Eliminato",
Expand Down Expand Up @@ -289,6 +299,8 @@
"toast.section_save_failed": "Salvataggio sezione fallito",
"toast.section_deleted": "Sezione eliminata",
"toast.section_delete_failed": "Eliminazione sezione fallita",
"toast.select_picture": "Seleziona un'immagine",
"toast.file_too_large": "File troppo grande. Dimensione massima: 5 MB.",
"toast.enter_title": "Inserisci un titolo",
"toast.enter_text": "Inserisci del testo",
"toast.enter_bullet": "Inserisci almeno un punto elenco",
Expand Down
12 changes: 12 additions & 0 deletions public/shared/i18n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@
"custom_item.description_optional": "Beschrijving (optioneel)",
"custom_item.link_url_optional": "Link-URL (optioneel)",
"custom_item.no_items": "Nog geen items.",
"custom_item.no_pictures": "Nog geen afbeeldingen toegevoegd.",
"custom_item.no_image": "Geen afbeelding",
"custom_item.picture": "Afbeelding",
"custom_item.choose_picture": "Afbeelding kiezen",
"custom_item.remove_picture": "Verwijderen",
"custom_item.picture_hint": "JPEG, PNG of WebP. Max. 5 MB.",
"custom_item.caption_optional": "Bijschrift (optioneel)",
"custom_item.caption_placeholder": "bijv. Fotobeschrijving",
"custom_item.add_picture": "Afbeelding toevoegen",
"custom_item.add_item": "Item toevoegen",

"toast.saved": "Succesvol opgeslagen",
"toast.deleted": "Verwijderd",
Expand Down Expand Up @@ -289,6 +299,8 @@
"toast.section_save_failed": "Opslaan van sectie mislukt",
"toast.section_deleted": "Sectie verwijderd",
"toast.section_delete_failed": "Verwijderen van sectie mislukt",
"toast.select_picture": "Selecteer een afbeelding",
"toast.file_too_large": "Bestand te groot. Maximale grootte is 5 MB.",
"toast.enter_title": "Voer een titel in",
"toast.enter_text": "Voer tekst in",
"toast.enter_bullet": "Voer ten minste één opsommingsteken in",
Expand Down
Loading