Hero image: drag to reposition + adjustable height

- Click and drag the hero image vertically to adjust framing
- Height slider appears on hover below the banner
- Position (Y%) and height saved to NocoDB as JSON
- Backwards compatible with legacy base64-only format
- Upload button moved to top-right controls overlay

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-05-29 10:52:21 +02:00
parent faa8da6820
commit 5f60ba2829
+97 -13
View File
@@ -201,13 +201,22 @@
.v-card.full { grid-column: 1 / -1; }
.v-card h3 { font-family: 'Playfair Display', serif; font-size: 14px; font-weight: 600; color: var(--text-hint); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
.hero-banner { grid-column: 1 / -1; border-radius: 12px; height: 280px; background: linear-gradient(135deg, #d4a574 0%, #c4956a 30%, #a07850 60%, #7a5c40 100%); position: relative; overflow: hidden; display: flex; align-items: flex-end; cursor: pointer; background-size: cover; background-position: center; }
.hero-banner { grid-column: 1 / -1; border-radius: 12px; height: 280px; background: linear-gradient(135deg, #d4a574 0%, #c4956a 30%, #a07850 60%, #7a5c40 100%); position: relative; overflow: hidden; display: flex; align-items: flex-end; background-size: cover; background-position: center; }
.dark .hero-banner { background: linear-gradient(135deg, #5a4030 0%, #4a3528 30%, #3a2a20 60%, #2a1e18 100%); }
.hero-banner.has-image { background-color: #1a1614; }
.hero-banner::after { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 30%, rgba(44,24,16,0.55)); }
.hero-overlay { position: relative; z-index: 1; padding: 24px 32px; width: 100%; }
.hero-upload-hint { position: absolute; top: 12px; right: 16px; z-index: 2; font-size: 12px; color: rgba(255,255,255,0.5); background: rgba(44,24,16,0.4); padding: 4px 12px; border-radius: 6px; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
.hero-banner:hover .hero-upload-hint { opacity: 1; }
.hero-banner.has-image { background-color: #1a1614; cursor: grab; }
.hero-banner.has-image.dragging { cursor: grabbing; }
.hero-banner::after { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 30%, rgba(44,24,16,0.55)); pointer-events: none; }
.hero-overlay { position: relative; z-index: 1; padding: 24px 32px; width: 100%; pointer-events: none; }
.hero-overlay [contenteditable] { pointer-events: auto; }
.hero-controls { position: absolute; top: 10px; right: 12px; z-index: 3; display: flex; gap: 8px; align-items: center; opacity: 0; transition: opacity 0.2s; }
.hero-banner:hover .hero-controls { opacity: 1; }
.hero-btn { font-size: 12px; color: rgba(255,255,255,0.8); background: rgba(44,24,16,0.5); padding: 4px 12px; border-radius: 6px; border: none; cursor: pointer; backdrop-filter: blur(4px); }
.hero-btn:hover { background: rgba(44,24,16,0.7); color: #fff; }
.hero-height-control { position: absolute; bottom: -28px; left: 50%; transform: translateX(-50%); z-index: 3; display: flex; align-items: center; gap: 8px; opacity: 0; transition: opacity 0.2s; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: 4px 12px; }
.hero-banner:hover + .hero-height-control, .hero-height-control:hover { opacity: 1; }
.hero-height-control label { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
.hero-height-control input[type="range"] { width: 120px; accent-color: var(--accent); }
.hero-height-control + input[type="file"] { margin-bottom: 8px; }
.hero-overlay .three-words { font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; color: #fff; letter-spacing: 2px; }
.hero-overlay .hero-sub { font-size: 14px; color: rgba(255,255,255,0.75); margin-top: 6px; font-style: italic; }
@@ -341,13 +350,19 @@
<div class="vision-content">
<div class="vision-grid">
<div class="hero-banner" id="hero-banner" onclick="document.getElementById('hero-file-input').click()">
<span class="hero-upload-hint">Cliquer pour changer l'image</span>
<div class="hero-banner" id="hero-banner">
<div class="hero-controls">
<button class="hero-btn" onclick="document.getElementById('hero-file-input').click()">Changer l'image</button>
</div>
<div class="hero-overlay">
<div class="three-words" id="v-trois-mots" contenteditable="true" onclick="event.stopPropagation()"></div>
<div class="hero-sub" id="v-hero-sub" contenteditable="true" onclick="event.stopPropagation()"></div>
</div>
</div>
<div class="hero-height-control" id="hero-height-control">
<label>Hauteur</label>
<input type="range" min="150" max="600" value="280" id="hero-height-slider" oninput="setHeroHeight(this.value)" onchange="saveHeroMeta()">
</div>
<input type="file" id="hero-file-input" accept="image/*" style="display:none;" onchange="uploadHeroImage(this)">
<div class="v-card full">
@@ -1061,7 +1076,9 @@ function removeInspiration(index) {
renderInspirations();
}
// ── Hero image ──
// ── Hero image with drag-to-reposition ──
let heroMeta = { posY: 50, height: 280 }; // posY in %, height in px
function applyHeroImage(dataUrl) {
const banner = document.getElementById('hero-banner');
if (dataUrl) {
@@ -1073,10 +1090,74 @@ function applyHeroImage(dataUrl) {
}
}
function applyHeroMeta() {
const banner = document.getElementById('hero-banner');
banner.style.backgroundPosition = `center ${heroMeta.posY}%`;
banner.style.height = heroMeta.height + 'px';
document.getElementById('hero-height-slider').value = heroMeta.height;
}
function setHeroHeight(val) {
heroMeta.height = parseInt(val);
document.getElementById('hero-banner').style.height = val + 'px';
}
function saveHeroMeta() {
const imageData = visionData['hero-image']?.Contenu || '';
if (!imageData) return;
const payload = JSON.stringify({ image: imageData, posY: heroMeta.posY, height: heroMeta.height });
saveVisionSection('hero-image', payload);
}
function loadHeroImageData(raw) {
if (!raw) return;
// Try JSON format (new) or plain base64 (legacy)
try {
const parsed = JSON.parse(raw);
heroMeta.posY = parsed.posY ?? 50;
heroMeta.height = parsed.height ?? 280;
applyHeroImage(parsed.image || '');
} catch {
// Legacy: raw is the base64 image directly
applyHeroImage(raw);
}
applyHeroMeta();
}
// Drag to reposition
(function() {
const banner = document.getElementById('hero-banner');
let dragging = false, startY = 0, startPos = 0;
banner.addEventListener('mousedown', (e) => {
if (!banner.classList.contains('has-image')) return;
if (e.target.closest('[contenteditable]') || e.target.closest('.hero-controls') || e.target.closest('button')) return;
dragging = true;
startY = e.clientY;
startPos = heroMeta.posY;
banner.classList.add('dragging');
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const delta = e.clientY - startY;
const sensitivity = 0.15;
heroMeta.posY = Math.max(0, Math.min(100, startPos + delta * sensitivity));
banner.style.backgroundPosition = `center ${heroMeta.posY}%`;
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
banner.classList.remove('dragging');
saveHeroMeta();
});
})();
function uploadHeroImage(input) {
const file = input.files[0];
if (!file) return;
// Resize to max 1600px wide to keep base64 reasonable
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
@@ -1089,7 +1170,10 @@ function uploadHeroImage(input) {
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
applyHeroImage(dataUrl);
saveVisionSection('hero-image', dataUrl);
heroMeta.posY = 50; // Reset position on new image
applyHeroMeta();
const payload = JSON.stringify({ image: dataUrl, posY: heroMeta.posY, height: heroMeta.height });
saveVisionSection('hero-image', payload);
};
img.src = e.target.result;
};
@@ -1118,8 +1202,8 @@ async function loadVision() {
setupEditable(elId, section);
}
// Hero image
applyHeroImage(visionData['hero-image']?.Contenu || '');
// Hero image (JSON with position/height or legacy base64)
loadHeroImageData(visionData['hero-image']?.Contenu || '');
// Render structured sections
renderTags();