From 1f0087c73bb0b13ac783963fb5ebf049f41c266f Mon Sep 17 00:00:00 2001 From: Etienne Delvarre Date: Fri, 29 May 2026 12:55:53 +0200 Subject: [PATCH] Simplify moodboard: free images + editable captions - Remove category/label system, replace with free text captions - Click image area to upload, caption editable below - Images uploaded as files to server (same as hero, avoids NocoDB limit) - Wider cards (160px min), cleaner layout Co-Authored-By: Claude Opus 4.6 --- public/index.html | 86 ++++++++++++++++++++++++++++++----------------- server.js | 19 +++++++++++ 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/public/index.html b/public/index.html index 7d1a279..50dc335 100644 --- a/public/index.html +++ b/public/index.html @@ -289,14 +289,15 @@ window.dispatchEvent(new Event('tiptap-loaded')); .list-item-expand { font-size: 12px; color: var(--text-hint); cursor: pointer; margin-left: 8px; white-space: nowrap; } .list-item-expand:hover { color: var(--accent); } - .mood-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; } - .mood-card { position: relative; border-radius: 10px; overflow: hidden; aspect-ratio: 1; background: var(--bg-alt); border: 1px solid var(--border); cursor: pointer; display: flex; align-items: flex-end; background-size: cover; background-position: center; } - .mood-card.has-image { border-color: transparent; } - .mood-label { width: 100%; padding: 6px 10px; font-size: 12px; font-weight: 600; color: #fff; background: rgba(44,24,16,0.55); backdrop-filter: blur(4px); text-align: center; } - .mood-card:not(.has-image) .mood-label { color: var(--text-muted); background: transparent; } + .mood-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; } + .mood-card { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg-alt); border: 1px solid var(--border); } + .mood-card-img { width: 100%; aspect-ratio: 1; background-size: cover; background-position: center; cursor: pointer; background-color: var(--bg-alt); display: flex; align-items: center; justify-content: center; } + .mood-card-img.empty { border: 1px dashed var(--border); border-radius: 10px 10px 0 0; } + .mood-card-img.empty::after { content: '+'; font-size: 28px; color: var(--text-hint); } + .mood-card-caption { padding: 8px 10px; font-size: 13px; color: var(--text); min-height: 32px; } .mood-card-remove { position: absolute; top: 4px; right: 4px; display: none; background: rgba(139,48,48,0.8); color: #fff; border: none; border-radius: 50%; width: 20px; height: 20px; font-size: 12px; cursor: pointer; align-items: center; justify-content: center; z-index: 2; } .mood-card:hover .mood-card-remove { display: flex; } - .mood-add { border: 1px dashed var(--border); background: transparent; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer; aspect-ratio: 1; border-radius: 10px; } + .mood-add { border: 1px dashed var(--border); background: transparent; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer; min-height: 120px; border-radius: 10px; } .mood-add:hover { background: var(--bg-alt); } .mood-add-icon { font-size: 24px; color: var(--text-hint); } @@ -1208,7 +1209,7 @@ function removeListItem(section, containerId, cssClass, index) { renderList(section, containerId, cssClass); } -// ── Moodboard ── +// ── Moodboard (images + free text captions) ── function renderMoodboard() { const container = document.getElementById('v-moodboard'); const row = visionData['moodboard']; @@ -1218,18 +1219,34 @@ function renderMoodboard() { container.innerHTML = ''; items.forEach((item, i) => { const card = document.createElement('div'); - card.className = 'mood-card' + (item.image ? ' has-image' : ''); - if (item.image) card.style.backgroundImage = `url(${item.image})`; + card.className = 'mood-card'; - card.innerHTML = ` - -
${esc(item.label)}
- `; - card.onclick = (e) => { - if (e.target.closest('.mood-card-remove')) return; - moodUploadTarget = i; - document.getElementById('mood-file-input').click(); - }; + const imgDiv = document.createElement('div'); + imgDiv.className = 'mood-card-img' + (item.image ? '' : ' empty'); + if (item.image) imgDiv.style.backgroundImage = `url(${item.image})`; + imgDiv.onclick = () => { moodUploadTarget = i; document.getElementById('mood-file-input').click(); }; + + const caption = document.createElement('div'); + caption.className = 'mood-card-caption'; + caption.contentEditable = 'true'; + caption.textContent = item.caption || ''; + caption.setAttribute('placeholder', 'Légende...'); + caption.addEventListener('blur', () => { + const v = caption.textContent.trim(); + if (v !== (items[i].caption || '')) { + items[i].caption = v; + saveVisionSection('moodboard', JSON.stringify(items)); + } + }); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'mood-card-remove'; + removeBtn.innerHTML = '×'; + removeBtn.onclick = (e) => { e.stopPropagation(); removeMoodItem(i); }; + + card.appendChild(removeBtn); + card.appendChild(imgDiv); + card.appendChild(caption); container.appendChild(card); }); @@ -1237,9 +1254,7 @@ function renderMoodboard() { addCard.className = 'mood-add'; addCard.innerHTML = '
+
'; addCard.onclick = () => { - const label = prompt('Catégorie (ex : Ambiance, Architecture, Costumes...) :'); - if (!label) return; - items.push({ label, image: '' }); + items.push({ caption: '', image: '' }); saveVisionSection('moodboard', JSON.stringify(items)); renderMoodboard(); }; @@ -1255,7 +1270,7 @@ document.addEventListener('DOMContentLoaded', () => { const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); - img.onload = () => { + img.onload = async () => { const maxW = 400; let w = img.width, h = img.height; if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; } @@ -1263,14 +1278,25 @@ document.addEventListener('DOMContentLoaded', () => { canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(img, 0, 0, w, h); const dataUrl = canvas.toDataURL('image/jpeg', 0.8); - const row = visionData['moodboard']; - let items = []; - try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; } - if (items[moodUploadTarget]) { - items[moodUploadTarget].image = dataUrl; - saveVisionSection('moodboard', JSON.stringify(items)); - renderMoodboard(); - } + + try { + const res = await fetch('/api/mood-upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dataUrl, index: moodUploadTarget }), + }); + const data = await res.json(); + if (data.path) { + const row = visionData['moodboard']; + let items = []; + try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; } + if (items[moodUploadTarget]) { + items[moodUploadTarget].image = data.path; + saveVisionSection('moodboard', JSON.stringify(items)); + renderMoodboard(); + } + } + } catch (err) { console.error('Mood upload error:', err); } }; img.src = e.target.result; }; diff --git a/server.js b/server.js index fa6dbe3..2d3d397 100644 --- a/server.js +++ b/server.js @@ -261,6 +261,25 @@ app.post('/api/hero-upload', requireAuth, (req, res) => { } }); +// Moodboard image upload +app.post('/api/mood-upload', requireAuth, (req, res) => { + try { + const { dataUrl, index } = req.body; + if (!dataUrl || !dataUrl.startsWith('data:image/')) return res.status(400).json({ error: 'Invalid image' }); + const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); + if (!matches) return res.status(400).json({ error: 'Invalid format' }); + const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]; + const buffer = Buffer.from(matches[2], 'base64'); + const uploadDir = path.join(__dirname, 'public', 'uploads'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + const filename = `mood-${index}.${ext}`; + fs.writeFileSync(path.join(uploadDir, filename), buffer); + res.json({ path: `/uploads/${filename}?t=${Date.now()}` }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + // Auth gate — redirect to login page if not authenticated app.get('/', (req, res) => { if (isAuth(req)) {