From d63e3c91a97d77b202e280ab0fa007dfbe1baa46 Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Sat, 21 Mar 2026 22:36:11 +0100 Subject: Add editor with webcam/upload capture, overlay compositing, and gallery feed --- src/public/assets/overlays/chest.png | Bin 0 -> 115538 bytes src/public/assets/overlays/cloud.png | Bin 0 -> 79115 bytes src/public/assets/overlays/eagle.png | Bin 0 -> 63116 bytes src/public/assets/overlays/explosion.png | Bin 0 -> 117861 bytes src/public/assets/overlays/fire.png | Bin 0 -> 97785 bytes src/public/assets/overlays/hat.png | Bin 0 -> 59733 bytes src/public/assets/overlays/heart.png | Bin 0 -> 64053 bytes src/public/assets/overlays/rainbow.png | Bin 0 -> 77826 bytes src/public/assets/overlays/water.png | Bin 0 -> 108751 bytes src/public/css/style.css | 203 +++++++++++++++++++++++++++++++ src/public/js/app.js | 200 +++++++++++++++++++++++++++++- 11 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 src/public/assets/overlays/chest.png create mode 100644 src/public/assets/overlays/cloud.png create mode 100644 src/public/assets/overlays/eagle.png create mode 100644 src/public/assets/overlays/explosion.png create mode 100644 src/public/assets/overlays/fire.png create mode 100644 src/public/assets/overlays/hat.png create mode 100644 src/public/assets/overlays/heart.png create mode 100644 src/public/assets/overlays/rainbow.png create mode 100644 src/public/assets/overlays/water.png (limited to 'src/public') diff --git a/src/public/assets/overlays/chest.png b/src/public/assets/overlays/chest.png new file mode 100644 index 0000000..a4db4c0 Binary files /dev/null and b/src/public/assets/overlays/chest.png differ diff --git a/src/public/assets/overlays/cloud.png b/src/public/assets/overlays/cloud.png new file mode 100644 index 0000000..b06eff4 Binary files /dev/null and b/src/public/assets/overlays/cloud.png differ diff --git a/src/public/assets/overlays/eagle.png b/src/public/assets/overlays/eagle.png new file mode 100644 index 0000000..d837908 Binary files /dev/null and b/src/public/assets/overlays/eagle.png differ diff --git a/src/public/assets/overlays/explosion.png b/src/public/assets/overlays/explosion.png new file mode 100644 index 0000000..008495b Binary files /dev/null and b/src/public/assets/overlays/explosion.png differ diff --git a/src/public/assets/overlays/fire.png b/src/public/assets/overlays/fire.png new file mode 100644 index 0000000..e9438fd Binary files /dev/null and b/src/public/assets/overlays/fire.png differ diff --git a/src/public/assets/overlays/hat.png b/src/public/assets/overlays/hat.png new file mode 100644 index 0000000..36f2760 Binary files /dev/null and b/src/public/assets/overlays/hat.png differ diff --git a/src/public/assets/overlays/heart.png b/src/public/assets/overlays/heart.png new file mode 100644 index 0000000..b54e462 Binary files /dev/null and b/src/public/assets/overlays/heart.png differ diff --git a/src/public/assets/overlays/rainbow.png b/src/public/assets/overlays/rainbow.png new file mode 100644 index 0000000..3e9719e Binary files /dev/null and b/src/public/assets/overlays/rainbow.png differ diff --git a/src/public/assets/overlays/water.png b/src/public/assets/overlays/water.png new file mode 100644 index 0000000..7558079 Binary files /dev/null and b/src/public/assets/overlays/water.png differ diff --git a/src/public/css/style.css b/src/public/css/style.css index 897f54b..e152519 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -207,9 +207,212 @@ footer { height: 1rem; } +/* Gallery page */ +.gallery-page { + max-width: 600px; + margin: 0 auto; +} + +.gallery-page h1 { + margin-bottom: 1.5rem; +} + +.gallery-feed { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.gallery-post { + background: #fff; + border: 1px solid #dbdbdb; + border-radius: 8px; + overflow: hidden; +} + +.post-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; +} + +.post-header time { + color: #999; + font-size: 0.85rem; +} + +.gallery-post img { + width: 100%; + display: block; +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + margin-top: 2rem; +} + +.pagination a { + padding: 0.4rem 0.75rem; + border: 1px solid #dbdbdb; + border-radius: 4px; + text-decoration: none; + color: #0095f6; +} + +.pagination a:hover { + background: #f0f0f0; +} + +.pagination .current-page { + padding: 0.4rem 0.75rem; + background: #0095f6; + color: #fff; + border-radius: 4px; +} + +/* Editor page */ +.editor-page { + max-width: 960px; + margin: 0 auto; +} + +.editor-page h1 { + margin-bottom: 1.5rem; +} + +.editor-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + align-items: start; +} + +.editor-source, +.editor-preview { + background: #fff; + border: 1px solid #dbdbdb; + border-radius: 8px; + padding: 1rem; +} + +.source-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.tab { + flex: 1; + padding: 0.5rem; + background: #fafafa; + border: 1px solid #dbdbdb; + border-radius: 4px; + cursor: pointer; + font-size: 0.95rem; +} + +.tab.active { + background: #0095f6; + color: #fff; + border-color: #0095f6; +} + +#webcam-video { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 4px; + background: #000; +} + +#preview-canvas { + width: 100%; + height: auto; + border-radius: 4px; + border: 1px solid #dbdbdb; +} + +.overlay-controls { + margin: 0.75rem 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.overlay-controls input[type="range"] { + flex: 1; +} + +.editor-preview h2 { + font-size: 1rem; + margin-bottom: 0.5rem; +} + +.overlay-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.5rem; + margin-bottom: 1rem; +} + +.overlay-thumb { + width: 100%; + aspect-ratio: 1; + object-fit: contain; + border: 2px solid #dbdbdb; + border-radius: 4px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s, border-color 0.15s; + background: #fafafa; + padding: 4px; +} + +.overlay-thumb:hover { + opacity: 0.8; +} + +.overlay-thumb.selected { + opacity: 1; + border-color: #0095f6; +} + +.btn { + width: 100%; + padding: 0.6rem; + background: #0095f6; + color: #fff; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + margin-top: 0.5rem; +} + +.btn:hover { + background: #007ad9; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +#file-input { + width: 100%; + margin-top: 0.5rem; +} + @media (max-width: 600px) { header nav { flex-direction: column; gap: 0.5rem; } + .editor-layout { + grid-template-columns: 1fr; + } } diff --git a/src/public/js/app.js b/src/public/js/app.js index 86b3c33..a6bb9c5 100644 --- a/src/public/js/app.js +++ b/src/public/js/app.js @@ -1,3 +1,201 @@ document.addEventListener('DOMContentLoaded', function () { - // App initialization + var canvas = document.getElementById('preview-canvas'); + if (!canvas) return; + + var ctx = canvas.getContext('2d'); + var video = document.getElementById('webcam-video'); + var btnCapture = document.getElementById('btn-capture'); + var btnSave = document.getElementById('btn-save'); + var fileInput = document.getElementById('file-input'); + var scaleSlider = document.getElementById('overlay-scale'); + var tabWebcam = document.getElementById('tab-webcam'); + var tabUpload = document.getElementById('tab-upload'); + var webcamPanel = document.getElementById('webcam-panel'); + var uploadPanel = document.getElementById('upload-panel'); + var csrfToken = document.getElementById('csrf-token').value; + var overlayThumbs = document.querySelectorAll('.overlay-thumb'); + + // State + var baseImageData = null; // base64 data URL of the captured/uploaded image + var selectedOverlay = null; // filename of the selected overlay + var overlayImg = null; // loaded Image object for the selected overlay + var webcamStream = null; + + // -- Tabs -- + + tabWebcam.addEventListener('click', function () { + tabWebcam.classList.add('active'); + tabUpload.classList.remove('active'); + webcamPanel.hidden = false; + uploadPanel.hidden = true; + startWebcam(); + }); + + tabUpload.addEventListener('click', function () { + tabUpload.classList.add('active'); + tabWebcam.classList.remove('active'); + uploadPanel.hidden = false; + webcamPanel.hidden = true; + stopWebcam(); + }); + + // -- Webcam -- + + function startWebcam() { + if (webcamStream) return; + navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 640, facingMode: 'user' } }) + .then(function (stream) { + webcamStream = stream; + video.srcObject = stream; + }) + .catch(function () { + // Camera not available — switch to upload tab + tabUpload.click(); + }); + } + + function stopWebcam() { + if (webcamStream) { + webcamStream.getTracks().forEach(function (t) { t.stop(); }); + webcamStream = null; + video.srcObject = null; + } + } + + btnCapture.addEventListener('click', function () { + // Draw current video frame onto an offscreen canvas to get a data URL + var offscreen = document.createElement('canvas'); + offscreen.width = 640; + offscreen.height = 640; + var offCtx = offscreen.getContext('2d'); + // Center-crop the video frame into the square canvas + var vw = video.videoWidth; + var vh = video.videoHeight; + var crop = Math.min(vw, vh); + var sx = (vw - crop) / 2; + var sy = (vh - crop) / 2; + offCtx.drawImage(video, sx, sy, crop, crop, 0, 0, 640, 640); + baseImageData = offscreen.toDataURL('image/jpeg', 0.9); + renderPreview(); + }); + + // -- Upload -- + + fileInput.addEventListener('change', function () { + var file = fileInput.files[0]; + if (!file) return; + var reader = new FileReader(); + reader.onload = function (e) { + baseImageData = e.target.result; + renderPreview(); + }; + reader.readAsDataURL(file); + }); + + // -- Overlay selection -- + + overlayThumbs.forEach(function (thumb) { + thumb.addEventListener('click', function () { + // Deselect previous + overlayThumbs.forEach(function (t) { t.classList.remove('selected'); }); + thumb.classList.add('selected'); + selectedOverlay = thumb.dataset.filename; + + // Preload the full overlay image for canvas rendering + overlayImg = new Image(); + overlayImg.onload = renderPreview; + overlayImg.src = thumb.src; + }); + }); + + // -- Scale slider -- + + scaleSlider.addEventListener('input', renderPreview); + + // -- Preview -- + + function renderPreview() { + ctx.clearRect(0, 0, 640, 640); + // Fill with light gray so the canvas isn't transparent + ctx.fillStyle = '#eee'; + ctx.fillRect(0, 0, 640, 640); + + if (!baseImageData) { + ctx.fillStyle = '#999'; + ctx.font = '18px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Take a photo or upload an image', 320, 320); + updateSaveButton(); + return; + } + + var img = new Image(); + img.onload = function () { + // Center-crop into the 640x640 canvas + var sw = img.width; + var sh = img.height; + var crop = Math.min(sw, sh); + var sx = (sw - crop) / 2; + var sy = (sh - crop) / 2; + ctx.drawImage(img, sx, sy, crop, crop, 0, 0, 640, 640); + + // Draw overlay on top if one is selected + if (overlayImg && overlayImg.complete) { + var scale = scaleSlider.value / 100; + var ow = overlayImg.width * scale; + var oh = overlayImg.height * scale; + var ox = (640 - ow) / 2; + var oy = (640 - oh) / 2; + ctx.drawImage(overlayImg, ox, oy, ow, oh); + } + + updateSaveButton(); + }; + img.src = baseImageData; + } + + function updateSaveButton() { + // Both a base image and an overlay must be selected + btnSave.disabled = !(baseImageData && selectedOverlay); + } + + // -- Save -- + + btnSave.addEventListener('click', function () { + if (btnSave.disabled) return; + btnSave.disabled = true; + btnSave.textContent = 'Saving...'; + + var scale = scaleSlider.value / 100; + + fetch('/editor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image_data: baseImageData, + overlay: selectedOverlay, + overlay_scale: scale, + csrf_token: csrfToken + }) + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data.success) { + window.location.href = data.redirect; + } else { + alert(data.error || 'Something went wrong.'); + btnSave.disabled = false; + btnSave.textContent = 'Save post'; + } + }) + .catch(function () { + alert('Network error. Please try again.'); + btnSave.disabled = false; + btnSave.textContent = 'Save post'; + }); + }); + + // Start webcam by default + startWebcam(); + renderPreview(); }); -- cgit v1.2.3