openvibers/openvibe.meme Commits
Meme: member template upload uses the shared upload kit (#146)
Changes
@@@ -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 () {@@@ -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) => {@@@ -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)}">@@@ -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('')}@@@ -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 };
@@ -106,18 +106,53 @@
@@ -106,18 +106,53 @@
} catch (error) { ideaStatus.textContent = error.message || 'Could not get caption ideas.'; }
} catch (error) { ideaStatus.textContent = error.message || 'Could not get caption ideas.'; }
finally { ideaButton.disabled = false; }
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 download = aside.querySelector('[data-render-png]');
var status = aside.querySelector('[data-render-status]');
var status = aside.querySelector('[data-render-status]');
if (download) download.addEventListener('click', async function () {
if (download) download.addEventListener('click', async function () {
@@ -201,6 +201,7 @@ function createPageRoutes(ctx) {
@@ -201,6 +201,7 @@ function createPageRoutes(ctx) {
title: 'Make a meme',
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.',
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 })),
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
+ upload: true,
}, status);
}, status);
}
}
r.get('/make', async (req, res) => {
r.get('/make', async (req, res) => {
@@ -89,7 +89,9 @@ ${appIcon.headTags({ site: 'meme' })}
@@ -89,7 +89,9 @@ ${appIcon.headTags({ site: 'meme' })}
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/meme.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>
<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>
<script src="${asset('js/meme.js')}" defer></script>
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="meme@${require('./html').esc(RELEASE)}">
<meta name="ov-boost" content="meme@${require('./html').esc(RELEASE)}">
@@ -278,7 +278,9 @@ ${LINES.map((i) => `<label class="mm-field"${i > boxCount ? ' hidden' : ''}><spa
@@ -278,7 +278,9 @@ ${LINES.map((i) => `<label class="mm-field"${i > boxCount ? ' hidden' : ''}><spa
</fieldset>
</fieldset>
<p class="mm-tpl-h" aria-hidden="true">Template</p>
<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>
<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>
<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>
<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('')}
${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('')}
@@ -51,7 +51,9 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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 () => {
await check('maker page renders the touch canvas, uploads, editor controls, and signed-out AI helper', async () => {
const page = await t.get('/make');
const page = await t.get('/make');
assert.strictEqual(page.status, 200);
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 () => {
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 };
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 };