openvibers/openvibe.meme Commits

Meme: member template upload uses the shared upload kit (#146)

OP OpenVibers committed 3 days ago · b1edf0b88f · 5 files, +56 −14

The make page loads openvibe-shared/ov-upload.js and ov-upload.css. The
hand-rolled hidden file input and its data-URL fetch are replaced by the
shared picker and progress meter (preparing, uploading with bytes and time
left, done or failed with Retry). The same Media-backed review endpoint
still receives the image.

Changes

ddiff --git a/public/js/meme.js b/public/js/meme.js
iindex f28a476..d9dac57 100644
---- a/public/js/meme.js
++++ b/public/js/meme.js
@@@ -106,18 +106,53 @@
} catch (error) { ideaStatus.textContent = error.message || 'Could not get caption ideas.'; }
finally { ideaButton.disabled = false; }
});
-- var upload = document.getElementById('mm-template-upload');
-- if (upload) upload.addEventListener('change', async function () {
-- var file = upload.files && upload.files[0]; if (!file) return;
-- if (file.size > 6 * 1024 * 1024) { editorStatus.textContent = 'Choose an image under 6 MB.'; return; }
-- editorStatus.textContent = 'Uploading for review…';
-- try {
-- var data = await new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve(reader.result); }; reader.onerror = reject; reader.readAsDataURL(file); });
-- var response = await fetch('/api/v1/templates/uploads', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name.replace(/\.[^.]+$/, '').slice(0, 80), image: data }) });
-- var result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Upload could not be submitted.');
-- editorStatus.textContent = 'Submitted for review. You can keep making this meme while it is checked.'; upload.value = '';
-- } catch (error) { editorStatus.textContent = error.message || 'Upload failed.'; }
-- });
++ // A member template: the shared picker and progress meter, then the same Media-backed review endpoint.
++ var upload = document.querySelector('[data-template-upload]');
++ if (upload && window.OVUpload) {
++ var uploadStatus = document.querySelector('[data-template-upload-status]');
++ var uploadMeter = document.createElement('div');
++ uploadMeter.hidden = true;
++ upload.after(uploadMeter);
++ var lastUpload = null;
++ function sayUpload(text, bad) { if (uploadStatus) { uploadStatus.textContent = text; uploadStatus.className = 'mm-fine' + (bad ? ' is-bad' : ''); } }
++ var meter = window.OVUpload.progress(uploadMeter, { onRetry: function () { if (lastUpload) sendTemplate(lastUpload.file, lastUpload.row); } });
++ function sendTemplate(file, row) {
++ if (file.size > 6 * 1024 * 1024) { sayUpload('Choose an image under 6 MB.', true); return; }
++ lastUpload = { file: file, row: row };
++ uploadMeter.hidden = false;
++ meter.update({ state: 'processing', done: 0, total: file.size, message: 'Preparing your image' });
++ sayUpload('Preparing your image…');
++ var reader = new FileReader();
++ reader.onerror = function () { meter.update({ state: 'failed', message: 'That image could not be read.' }); sayUpload('That image could not be read.', true); };
++ reader.onload = function () {
++ var data = String(reader.result || '');
++ var payload = JSON.stringify({ name: file.name.replace(/\.[^.]+$/, '').slice(0, 80), image: data });
++ meter.update({ state: 'sending', done: 0, total: payload.length });
++ sayUpload('Uploading for review…');
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', '/api/v1/templates/uploads');
++ xhr.setRequestHeader('Content-Type', 'application/json');
++ xhr.withCredentials = true;
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable) meter.update({ state: 'sending', done: e.loaded, total: e.total }); };
++ xhr.onload = function () {
++ var body = null; try { body = JSON.parse(xhr.responseText || 'null'); } catch (_) { body = null; }
++ if (xhr.status >= 200 && xhr.status < 300) {
++ meter.update({ state: 'done', done: payload.length, total: payload.length, message: 'In the review queue' });
++ sayUpload('Submitted for review. You can keep making this meme while it is checked.');
++ if (row) { row.dispatchEvent(new Event('remove')); row.remove(); }
++ lastUpload = null;
++ return;
++ }
++ var detail = (body && (body.detail || body.error)) || 'Upload could not be submitted.';
++ meter.update({ state: 'failed', message: detail }); sayUpload(detail, true);
++ };
++ xhr.onerror = function () { meter.update({ state: 'failed', message: 'The connection dropped. Try again.' }); sayUpload('The connection dropped. Try again.', true); };
++ xhr.send(payload);
++ };
++ reader.readAsDataURL(file);
++ }
++ window.OVUpload.picker(upload, { label: 'Choose a template image', accept: 'image/png,image/jpeg,image/webp', onFile: sendTemplate });
++ }
var download = aside.querySelector('[data-render-png]');
var status = aside.querySelector('[data-render-status]');
if (download) download.addEventListener('click', async function () {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ca05958..35506bd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -201,6 +201,7 @@ function createPageRoutes(ctx) {
title: 'Make a meme',
description: 'Make a meme with movable text, crop and style controls, emoji stickers, optional AI caption ideas, and PNG/GIF/MP4 export.',
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
++ upload: true,
}, status);
}
r.get('/make', async (req, res) => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex fba9a95..5142877 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -89,7 +89,9 @@ ${appIcon.headTags({ site: 'meme' })}
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/meme.css')}">
++${o.upload ? `<link rel="stylesheet" href="${ovServe.url('ov-upload.css')}">` : ''}
<script src="${ovServe.url('motion.js')}" defer></script>
++${o.upload ? `<script src="${ovServe.url('ov-upload.js')}" defer></script>` : ''}
<script src="${asset('js/meme.js')}" defer></script>
${render(scripts)}
<meta name="ov-boost" content="meme@${require('./html').esc(RELEASE)}">
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 1a28114..fa8696e 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -278,7 +278,9 @@ ${LINES.map((i) => `<label class="mm-field"${i > boxCount ? ' hidden' : ''}><spa
</fieldset>
<p class="mm-tpl-h" aria-hidden="true">Template</p>
<div class="mm-tpl-bar"><label class="mm-field mm-tpl-find"><span class="mm-sr">Find a template</span><input id="mm-template-filter" type="search" placeholder="Find a template" aria-controls="mm-template-grid" autocomplete="off"></label>
--<label class="mm-upload"><input id="mm-template-upload" type="file" accept="image/png,image/jpeg,image/webp"><span>Upload your own</span></label></div>
++<details class="mm-upload"><summary class="ovk-btn ovk-btn--quiet">Upload your own</summary>
++<div id="mm-template-upload" data-template-upload></div>
++<p class="mm-fine" data-template-upload-status role="status" aria-live="polite"></p></details></div>
<p class="mm-fine mm-tpl-note">PNG, JPEG or WebP up to 6 MB. New uploads go to moderation before others can use them.</p>
<fieldset class="mm-templates" id="mm-template-grid"><legend class="mm-sr">Template</legend>
${templates.map((t) => `<label class="mm-template${t.id === v.template_id ? ' is-chosen' : ''}" data-template="${esc(t.id)}" data-boxes="${Number(t.box_count) || 1}" data-layout="${esc(JSON.stringify(t.layout || {}))}"><input type="radio" name="template_id" value="${esc(t.id)}"${t.id === v.template_id ? ' checked' : ''} required><img src="${esc(t.image_url)}" alt="" width="220" height="140" loading="lazy"><span><b>${esc(t.name)}</b><small>${Number(t.box_count)} ${Number(t.box_count) === 1 ? 'box' : 'boxes'} · ${esc(t.license)}</small></span></label>`).join('')}
ddiff --git a/test/maker-enhancements.test.js b/test/maker-enhancements.test.js
iindex 11b2935..767c94e 100644
---- a/test/maker-enhancements.test.js
++++ b/test/maker-enhancements.test.js
@@@ -51,7 +51,9 @@ const { boot, check, done } = require('./helpers/boot');
await check('maker page renders the touch canvas, uploads, editor controls, and signed-out AI helper', async () => {
const page = await t.get('/make');
assert.strictEqual(page.status, 200);
-- for (const text of ['data-stage', 'mm-template-upload', 'font_style', 'data-add-sticker', 'data-caption-ideas']) assert.ok(page.text.includes(text), text);
++ for (const text of ['data-stage', 'data-template-upload', 'font_style', 'data-add-sticker', 'data-caption-ideas']) assert.ok(page.text.includes(text), text);
++ assert.ok(page.text.includes('/shared/ov-upload.js') && page.text.includes('/shared/ov-upload.css'), 'the maker loads the shared upload kit');
++ assert.ok(!page.text.includes('type="file"'), 'no hand-rolled file input remains');
});
await check('custom editor geometry and stickers render, and saved captions retain editable state', async () => {
const editor = { boxes: [{ x: 5, y: 7, w: 90, h: 24 }, { x: 5, y: 68, w: 90, h: 24 }, { x: 5, y: 38, w: 90, h: 18 }, { x: 5, y: 44, w: 90, h: 18 }], stickers: [{ emoji: '🔥', x: 52, y: 54 }], font_style: 'impact', font_size: 30, color: '#ffffff', outline: '#000000', outline_width: 3 };