From d6d9411e27fcc3ae54ef4a67ac0b511d15d766f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 16:47:36 +0000 Subject: [PATCH] Add Picture Grid layout type for custom sections New custom section layout that displays uploaded pictures in a responsive 3-column grid. Pictures center automatically when fewer than 3 are present. Features: - File upload with JPEG/PNG/WebP support (max 5MB) - Optional captions per picture - Automatic file cleanup on item/section deletion - Thumbnail previews in admin item list - Responsive: 2 columns on mobile, 3 on desktop - Full i18n support across all 8 languages Bump version to 1.14.0. https://claude.ai/code/session_01AD2TdNL3RDp5ynSrJr2tpN --- CHANGELOG.md | 6 ++ package-lock.json | 4 +- package.json | 2 +- public/shared/admin.js | 135 +++++++++++++++++++++++++++++++++---- public/shared/i18n/de.json | 12 ++++ public/shared/i18n/en.json | 12 ++++ public/shared/i18n/es.json | 12 ++++ public/shared/i18n/fr.json | 12 ++++ public/shared/i18n/it.json | 12 ++++ public/shared/i18n/nl.json | 12 ++++ public/shared/i18n/pt.json | 12 ++++ public/shared/i18n/zh.json | 12 ++++ public/shared/scripts.js | 18 +++++ public/shared/styles.css | 80 +++++++++++++++++++++- src/server.js | 34 +++++++++- version.json | 2 +- 16 files changed, 358 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd79546..c7b6bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 5d14bf5..b74ba1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.13.0", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.13.0", + "version": "1.14.0", "dependencies": { "better-sqlite3": "^9.4.3", "cors": "^2.8.5", diff --git a/package.json b/package.json index 49dceb8..1051a80 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/shared/admin.js b/public/shared/admin.js index d1fe7ae..cc61fc5 100644 --- a/public/shared/admin.js +++ b/public/shared/admin.js @@ -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); } @@ -626,6 +629,21 @@ function renderFreeTextLayout(items) { }).join('')}`; } +// Picture grid layout - display uploaded images in a 3-column grid +function renderPictureGridLayout(items) { + if (items.length === 0) return `

${t('custom_item.no_pictures')}

`; + + return `
${items.map(item => { + const visible = item.visible !== false; + return ` +
+ ${item.image ? `${escapeHtml(item.title || '')}` : `
${t('custom_item.no_image')}
`} + ${item.title ? `
${escapeHtml(item.title)}
` : ''} +
+ `; + }).join('')}
`; +} + // Load Sections with visibility toggle (admin version) async function loadSectionsAdmin() { const sections = await api('/api/sections'); @@ -3036,14 +3054,15 @@ async function manageCustomSectionItems(sectionId) {
${items.length === 0 ? '

No items yet.

' : items.map(item => `
${dragHandleIcon()}
+ ${section.layout_type === 'picture-grid' && item.image ? `` : ''}
-
${escapeHtml(item.title || 'Untitled')}
+
${escapeHtml(item.title || (section.layout_type === 'picture-grid' ? t('custom_item.picture') : 'Untitled'))}
${item.subtitle ? `
${escapeHtml(item.subtitle)}
` : ''}
@@ -3149,6 +3168,28 @@ function openCustomItemModal(sectionId, itemId = null) {
${t('custom_item.text_content_hint')}
`; + } else if (section.layout_type === 'picture-grid') { + // Picture grid form - picture upload with optional caption + formHtml = ` +
+ +
+ ${item.image ? `` : `
${t('custom_item.no_image')}
`} +
+ +
+ + ${item.image ? `` : ''} +
+
${t('custom_item.picture_hint')}
+
+
+ + +
+ `; } else { // Generic form for other layouts const hideTitle = item.metadata?.hideTitle || false; @@ -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 = ``; + // 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 = `
${t('custom_item.no_image')}
`; + 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() { @@ -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); diff --git a/public/shared/i18n/de.json b/public/shared/i18n/de.json index 46be780..f16bc8e 100644 --- a/public/shared/i18n/de.json +++ b/public/shared/i18n/de.json @@ -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", @@ -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", diff --git a/public/shared/i18n/en.json b/public/shared/i18n/en.json index 800fdf2..92b4e2f 100644 --- a/public/shared/i18n/en.json +++ b/public/shared/i18n/en.json @@ -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", @@ -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", diff --git a/public/shared/i18n/es.json b/public/shared/i18n/es.json index f39a092..3fb69bd 100644 --- a/public/shared/i18n/es.json +++ b/public/shared/i18n/es.json @@ -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", @@ -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", diff --git a/public/shared/i18n/fr.json b/public/shared/i18n/fr.json index a8733cf..5ec9ecd 100644 --- a/public/shared/i18n/fr.json +++ b/public/shared/i18n/fr.json @@ -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é", @@ -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", diff --git a/public/shared/i18n/it.json b/public/shared/i18n/it.json index ecdfe05..4d40e37 100644 --- a/public/shared/i18n/it.json +++ b/public/shared/i18n/it.json @@ -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", @@ -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", diff --git a/public/shared/i18n/nl.json b/public/shared/i18n/nl.json index 563ba22..2c62ec3 100644 --- a/public/shared/i18n/nl.json +++ b/public/shared/i18n/nl.json @@ -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", @@ -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", diff --git a/public/shared/i18n/pt.json b/public/shared/i18n/pt.json index 5b94da8..b76489c 100644 --- a/public/shared/i18n/pt.json +++ b/public/shared/i18n/pt.json @@ -243,6 +243,16 @@ "custom_item.description_optional": "Descrição (opcional)", "custom_item.link_url_optional": "URL da Ligação (opcional)", "custom_item.no_items": "Ainda não existem itens.", + "custom_item.no_pictures": "Nenhuma imagem adicionada.", + "custom_item.no_image": "Sem imagem", + "custom_item.picture": "Imagem", + "custom_item.choose_picture": "Escolher imagem", + "custom_item.remove_picture": "Remover", + "custom_item.picture_hint": "JPEG, PNG ou WebP. Máx. 5 MB.", + "custom_item.caption_optional": "Legenda (opcional)", + "custom_item.caption_placeholder": "ex. Descrição da foto", + "custom_item.add_picture": "Adicionar imagem", + "custom_item.add_item": "Adicionar item", "toast.saved": "Guardado com sucesso", "toast.deleted": "Eliminado", @@ -289,6 +299,8 @@ "toast.section_save_failed": "Falha ao guardar a secção", "toast.section_deleted": "Secção eliminada", "toast.section_delete_failed": "Falha ao eliminar a secção", + "toast.select_picture": "Por favor, selecione uma imagem", + "toast.file_too_large": "Ficheiro demasiado grande. Tamanho máximo: 5 MB.", "toast.enter_title": "Por favor, introduza um título", "toast.enter_text": "Por favor, introduza algum texto", "toast.enter_bullet": "Por favor, introduza pelo menos um ponto", diff --git a/public/shared/i18n/zh.json b/public/shared/i18n/zh.json index c922de8..0aa8104 100644 --- a/public/shared/i18n/zh.json +++ b/public/shared/i18n/zh.json @@ -243,6 +243,16 @@ "custom_item.description_optional": "描述(可选)", "custom_item.link_url_optional": "链接地址(可选)", "custom_item.no_items": "暂无条目。", + "custom_item.no_pictures": "尚未添加图片。", + "custom_item.no_image": "无图片", + "custom_item.picture": "图片", + "custom_item.choose_picture": "选择图片", + "custom_item.remove_picture": "移除", + "custom_item.picture_hint": "JPEG、PNG 或 WebP,最大 5MB。", + "custom_item.caption_optional": "说明文字(可选)", + "custom_item.caption_placeholder": "例如:照片描述", + "custom_item.add_picture": "添加图片", + "custom_item.add_item": "添加条目", "toast.saved": "保存成功", "toast.deleted": "已删除", @@ -289,6 +299,8 @@ "toast.section_save_failed": "保存板块失败", "toast.section_deleted": "板块已删除", "toast.section_delete_failed": "删除板块失败", + "toast.select_picture": "请选择一张图片", + "toast.file_too_large": "文件过大,最大不超过 5MB。", "toast.enter_title": "请输入标题", "toast.enter_text": "请输入文本内容", "toast.enter_bullet": "请至少输入一条要点", diff --git a/public/shared/scripts.js b/public/shared/scripts.js index cac3eee..90c427f 100644 --- a/public/shared/scripts.js +++ b/public/shared/scripts.js @@ -1296,6 +1296,9 @@ function renderCustomSectionPublic(section, layoutTypes, socialPlatforms) { case 'free-text': contentHtml = renderFreeTextPublic(items); break; + case 'picture-grid': + contentHtml = renderPictureGridPublic(items); + break; default: contentHtml = renderGridPublic(items, 3); } @@ -1450,3 +1453,18 @@ function renderFreeTextPublic(items) { `; }).join('')}
`; } + +// Picture grid layout for public view +function renderPictureGridPublic(items) { + if (items.length === 0) return ''; + + const visibleItems = items.filter(item => item.visible !== false && item.image); + if (visibleItems.length === 0) return ''; + + return `
${visibleItems.map(item => ` +
+ ${escapeHtml(item.title || '')} + ${item.title ? `
${escapeHtml(item.title)}
` : ''} +
+ `).join('')}
`; +} diff --git a/public/shared/styles.css b/public/shared/styles.css index 1fe02fa..5c8d386 100644 --- a/public/shared/styles.css +++ b/public/shared/styles.css @@ -892,6 +892,80 @@ body { margin: 0; } +/* Picture Grid Layout */ +.custom-picture-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + justify-items: center; +} + +.custom-picture-item { + width: 100%; + text-align: center; +} + +.custom-picture-img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 8px; + display: block; +} + +.custom-picture-caption { + font-size: 13px; + color: var(--gray-600); + margin-top: 6px; + text-align: center; +} + +.custom-picture-placeholder { + width: 100%; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + background: var(--very-light); + border: 2px dashed var(--gray-300); + border-radius: 8px; + color: var(--gray-400); + font-size: 13px; +} + +/* Picture grid modal preview */ +.picture-grid-preview { + width: 200px; + height: 200px; + border: 2px dashed var(--gray-300); + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: var(--very-light); +} + +.picture-grid-preview-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.picture-grid-placeholder { + color: var(--gray-400); + font-size: 13px; +} + +/* Thumbnail in item list */ +.custom-item-thumb { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + /* Public Print Button */ .public-print-btn { position: fixed; @@ -1215,7 +1289,10 @@ body.has-preview-banner .container { .custom-bullet-list li { font-size: 11px; margin-bottom: 2px; } .custom-item-link, .custom-card-link { font-size: 10px; } .custom-grid-item, .custom-list-item, .custom-card, .custom-bullet-group { padding: 10px; } - + .custom-picture-grid { gap: 10px; } + .custom-picture-img { border-radius: 4px; } + .custom-picture-caption { font-size: 10px; } + .hidden-print { display: none !important; } } @@ -1230,6 +1307,7 @@ body.has-preview-banner .container { .section { padding: 18px; } #section-timeline { display: none; } .cert-grid, .skills-grid, .projects-grid { grid-template-columns: 1fr; } + .custom-picture-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; } } /* Mobile print - show timeline again (only if not hidden by user choice) */ diff --git a/src/server.js b/src/server.js index 0535953..e156676 100644 --- a/src/server.js +++ b/src/server.js @@ -275,7 +275,8 @@ const SVG_ICONS = { dribbble: '', behance: '', bullets: '', - freetext: '' + freetext: '', + pictureGrid: '' }; // Layout types as array for frontend iteration @@ -286,7 +287,8 @@ const LAYOUT_TYPES = [ { id: 'list', name: 'Vertical List', icon: SVG_ICONS.list }, { id: 'cards', name: 'Card Grid', icon: SVG_ICONS.cards }, { id: 'bullet-list', name: 'Bullet Points', icon: SVG_ICONS.bullets }, - { id: 'free-text', name: 'Free Text', icon: SVG_ICONS.freetext } + { id: 'free-text', name: 'Free Text', icon: SVG_ICONS.freetext }, + { id: 'picture-grid', name: 'Picture Grid', icon: SVG_ICONS.pictureGrid } ]; // Social platform definitions as array for frontend iteration @@ -1412,6 +1414,14 @@ if (PUBLIC_ONLY) { if (section) { db.prepare('DELETE FROM section_visibility WHERE section_name = ?').run(section.section_key); } + // Clean up picture files for all items in this section + const items = db.prepare('SELECT image FROM custom_section_items WHERE section_id = ? AND image IS NOT NULL').all(req.params.id); + items.forEach(item => { + if (item.image) { + const imgPath = path.join(uploadsPath, item.image); + try { if (fs.existsSync(imgPath)) fs.unlinkSync(imgPath); } catch (e) {} + } + }); db.prepare('DELETE FROM custom_section_items WHERE section_id = ?').run(req.params.id); db.prepare('DELETE FROM custom_sections WHERE id = ?').run(req.params.id); res.json({ success: true }); @@ -1443,10 +1453,30 @@ if (PUBLIC_ONLY) { }); app.delete('/api/custom-sections/:id/items/:itemId', (req, res) => { + const item = db.prepare('SELECT image FROM custom_section_items WHERE id = ? AND section_id = ?').get(req.params.itemId, req.params.id); + if (item && item.image) { + const imgPath = path.join(uploadsPath, item.image); + try { if (fs.existsSync(imgPath)) fs.unlinkSync(imgPath); } catch (e) {} + } db.prepare('DELETE FROM custom_section_items WHERE id = ? AND section_id = ?').run(req.params.itemId, req.params.id); res.json({ success: true }); }); + // Custom section item picture upload + const csItemPicStorage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadsPath), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; cb(null, `cs_${req.params.id}_${req.params.itemId}_${Date.now()}${ext}`); } }); + const csItemPicUpload = multer({ storage: csItemPicStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'image/webp']; cb(null, allowed.includes(file.mimetype)); } }); + app.post('/api/custom-sections/:id/items/:itemId/picture', csItemPicUpload.single('picture'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + // Delete old picture if exists + const item = db.prepare('SELECT image FROM custom_section_items WHERE id = ? AND section_id = ?').get(req.params.itemId, req.params.id); + if (item && item.image) { + const oldPath = path.join(uploadsPath, item.image); + try { if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } catch (e) {} + } + db.prepare('UPDATE custom_section_items SET image = ? WHERE id = ? AND section_id = ?').run(req.file.filename, req.params.itemId, req.params.id); + res.json({ success: true, filename: req.file.filename }); + }); + // Layout types and social platforms metadata app.get('/api/layout-types', (req, res) => { res.json(LAYOUT_TYPES); }); app.get('/api/social-platforms', (req, res) => { res.json(SOCIAL_PLATFORMS); }); diff --git a/version.json b/version.json index 1ee2d30..21dbf07 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.13.0", + "version": "1.14.0", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" }