feat: release meme protocol v1.1.0
Add one-click meme copying, complete viewer actions, fixed-footer pagination, protocol lore, and the Homo Reseticus download chooser. Install dedicated social and favicon assets, expand local demo data, and synchronize the changelog, documentation, package, and OpenAPI release metadata.
This commit is contained in:
+216
-25
@@ -5,6 +5,7 @@ const VIEW_DWELL_MS = 1000;
|
||||
const VIEW_THRESHOLD = 0.9;
|
||||
const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000;
|
||||
const VIEW_DEDUPE_PREFIX = 'meme-viewed:';
|
||||
const NEXT_PAGE_LOADER_MIN_MS = 300;
|
||||
const SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
|
||||
|
||||
const grid = document.querySelector('#meme-grid');
|
||||
@@ -15,17 +16,28 @@ const uploadForm = document.querySelector('#upload-form');
|
||||
const fileInput = document.querySelector('#meme-input');
|
||||
const fileLabel = document.querySelector('#file-label');
|
||||
const formStatus = document.querySelector('#form-status');
|
||||
const submitUpload = document.querySelector('#submit-upload');
|
||||
const addMemeButton = document.querySelector('#add-meme-button');
|
||||
const lightbox = document.querySelector('#lightbox');
|
||||
const lightboxImage = document.querySelector('#lightbox-image');
|
||||
const lightboxCopy = document.querySelector('#lightbox-copy');
|
||||
const lightboxDownload = document.querySelector('#lightbox-download');
|
||||
const lightboxShare = document.querySelector('#lightbox-share');
|
||||
const lightboxId = document.querySelector('#lightbox-id');
|
||||
const lightboxViewCount = document.querySelector('#lightbox-view-count');
|
||||
const lightboxDownloadCount = document.querySelector('#lightbox-download-count');
|
||||
const petModal = document.querySelector('#pet-modal');
|
||||
const loreModal = document.querySelector('#lore-modal');
|
||||
const loreText = document.querySelector('#lore-text');
|
||||
const loreCopy = document.querySelector('#lore-copy');
|
||||
const loreShare = document.querySelector('#lore-share');
|
||||
const shareModal = document.querySelector('#share-modal');
|
||||
const shareTitle = document.querySelector('#share-title');
|
||||
const shareLinkLabel = document.querySelector('#share-link-label');
|
||||
const shareLink = document.querySelector('#share-link');
|
||||
const shareStatus = document.querySelector('#share-status');
|
||||
const copyShareLinkButton = document.querySelector('#copy-share-link');
|
||||
const scrollIndicator = document.querySelector('#scroll-indicator');
|
||||
const actionStatus = document.querySelector('#action-status');
|
||||
|
||||
let currentPage = 0;
|
||||
let totalPages = 1;
|
||||
@@ -33,6 +45,8 @@ let isLoading = false;
|
||||
let latestValidationRun = 0;
|
||||
let activeShareTrigger = null;
|
||||
let shareStatusTimer = null;
|
||||
let actionStatusTimer = null;
|
||||
let loreContent = '';
|
||||
const viewedThisSession = new Set();
|
||||
const pendingViewTimers = new Map();
|
||||
const copiedFeedbackTimers = new WeakMap();
|
||||
@@ -45,10 +59,22 @@ document.querySelector('#close-upload').addEventListener('click', closeUpload);
|
||||
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
|
||||
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
|
||||
document.querySelector('#close-share').addEventListener('click', () => shareModal.close());
|
||||
document.querySelector('#open-pet').addEventListener('click', () => petModal.showModal());
|
||||
document.querySelector('#close-pet').addEventListener('click', () => petModal.close());
|
||||
document.querySelector('#open-lore').addEventListener('click', openLoreDialog);
|
||||
document.querySelector('#close-lore').addEventListener('click', () => loreModal.close());
|
||||
copyShareLinkButton.addEventListener('click', copyActiveShareLink);
|
||||
lightboxShare.addEventListener('click', async () => {
|
||||
await openShareDialog(lightboxShare.dataset.shareId, lightboxShare);
|
||||
await openMemeShareDialog(lightboxShare.dataset.shareId, lightboxShare);
|
||||
});
|
||||
lightboxCopy.addEventListener('click', () => copyImage(lightboxCopy.dataset.imageUrl, lightboxCopy));
|
||||
loreCopy.addEventListener('click', copyLore);
|
||||
loreShare.addEventListener('click', () => openShareDialog({
|
||||
path: '/lore',
|
||||
title: 'SHARE_LORE',
|
||||
label: 'LORE_LINK',
|
||||
triggerButton: loreShare
|
||||
}));
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const validationRun = ++latestValidationRun;
|
||||
@@ -70,7 +96,7 @@ uploadForm.addEventListener('submit', async (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
submitUpload.disabled = true;
|
||||
addMemeButton.disabled = true;
|
||||
formStatus.textContent = 'UPLOADING...';
|
||||
|
||||
try {
|
||||
@@ -90,14 +116,20 @@ uploadForm.addEventListener('submit', async (event) => {
|
||||
} catch (error) {
|
||||
formStatus.textContent = error.message.toUpperCase();
|
||||
} finally {
|
||||
submitUpload.disabled = false;
|
||||
addMemeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
grid.addEventListener('click', async (event) => {
|
||||
const copyButton = event.target.closest('[data-copy-image-url]');
|
||||
if (copyButton) {
|
||||
await copyImage(copyButton.dataset.copyImageUrl, copyButton);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareButton = event.target.closest('[data-share-id]');
|
||||
if (shareButton) {
|
||||
await openShareDialog(shareButton.dataset.shareId, shareButton);
|
||||
await openMemeShareDialog(shareButton.dataset.shareId, shareButton);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,7 +140,9 @@ grid.addEventListener('click', async (event) => {
|
||||
id: viewButton.dataset.viewId,
|
||||
url: viewButton.dataset.viewUrl,
|
||||
downloadUrl: viewButton.dataset.downloadUrl,
|
||||
alt: card.querySelector('img').alt
|
||||
alt: card.querySelector('img').alt,
|
||||
viewCount: Number.parseInt(viewButton.dataset.viewCount || '0', 10),
|
||||
downloadCount: Number.parseInt(viewButton.dataset.downloadCount || '0', 10)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,7 +162,7 @@ window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
||||
window.addEventListener('resize', updateScrollIndicator);
|
||||
refreshStatus();
|
||||
window.setInterval(refreshStatus, 5000);
|
||||
await openSharedMemeFromUrl();
|
||||
await openSharedContentFromUrl();
|
||||
await loadMemes(1, { reset: true });
|
||||
observer.observe(feedLoader);
|
||||
|
||||
@@ -140,6 +174,7 @@ async function loadNextPage() {
|
||||
async function loadMemes(page, options = {}) {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
const loaderStartedAt = performance.now();
|
||||
setLoader('FETCHING_NEXT_BLOCK...', true);
|
||||
|
||||
try {
|
||||
@@ -150,6 +185,10 @@ async function loadMemes(page, options = {}) {
|
||||
currentPage = payload.page;
|
||||
totalPages = payload.totalPages;
|
||||
renderMemes(payload.memes, { append: !options.reset });
|
||||
if (page > 1) {
|
||||
const remainingLoaderTime = NEXT_PAGE_LOADER_MIN_MS - (performance.now() - loaderStartedAt);
|
||||
if (remainingLoaderTime > 0) await delay(remainingLoaderTime);
|
||||
}
|
||||
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
|
||||
updateScrollIndicator();
|
||||
} catch {
|
||||
@@ -161,6 +200,10 @@ async function loadMemes(page, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function renderMemes(memes, options = {}) {
|
||||
if (!options.append && memes.length === 0) {
|
||||
unobserveViewedImages();
|
||||
@@ -185,13 +228,14 @@ function renderMemes(memes, options = {}) {
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<div class="stats">
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span>${formatCount(meme.viewCount)}</span>
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span>${formatCount(meme.downloadCount)}</span>
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>${formatCount(meme.viewCount)}</span></span>
|
||||
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>${formatCount(meme.downloadCount)}</span></span>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<button class="text-action icon-only-action" type="button" data-copy-image-url="${meme.url}" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
|
||||
<a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
|
||||
<button class="text-action icon-only-action" type="button" data-share-id="${meme.id}" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
|
||||
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
||||
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" data-view-count="${meme.viewCount}" data-download-count="${meme.downloadCount}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -208,7 +252,12 @@ function renderMemes(memes, options = {}) {
|
||||
observeViewedImages(cards);
|
||||
}
|
||||
|
||||
async function openSharedMemeFromUrl() {
|
||||
async function openSharedContentFromUrl() {
|
||||
if (window.location.pathname === '/lore' || window.location.pathname === '/lore/') {
|
||||
await openLoreDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
|
||||
if (!match) return;
|
||||
|
||||
@@ -225,20 +274,39 @@ async function openSharedMemeFromUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openLightbox({ id, url, downloadUrl, alt }) {
|
||||
async function openLightbox({ id, url, downloadUrl, alt, viewCount = 0, downloadCount = 0 }) {
|
||||
lightboxImage.src = url;
|
||||
lightboxImage.alt = alt;
|
||||
lightboxCopy.dataset.imageUrl = url;
|
||||
lightboxDownload.href = downloadUrl;
|
||||
lightboxShare.dataset.shareId = id;
|
||||
lightboxId.textContent = `ID: ${shortId(id)}`;
|
||||
lightboxViewCount.dataset.counterId = id;
|
||||
lightboxViewCount.dataset.counterKind = 'view';
|
||||
lightboxViewCount.querySelector('[data-count]').textContent = formatCount(viewCount);
|
||||
lightboxDownloadCount.dataset.counterId = id;
|
||||
lightboxDownloadCount.dataset.counterKind = 'download';
|
||||
lightboxDownloadCount.querySelector('[data-count]').textContent = formatCount(downloadCount);
|
||||
lightboxId.textContent = `ID: 0x${id.toUpperCase()}`;
|
||||
setActionStatus('', lightboxCopy);
|
||||
if (!lightbox.open) lightbox.showModal();
|
||||
await recordViewOnce(id);
|
||||
}
|
||||
|
||||
async function openShareDialog(id, triggerButton = null) {
|
||||
async function openMemeShareDialog(id, triggerButton = null) {
|
||||
if (!isMemeId(id)) return;
|
||||
return openShareDialog({
|
||||
path: `/meme/${id}`,
|
||||
title: 'SHARE_MEME',
|
||||
label: 'MEME_LINK',
|
||||
triggerButton
|
||||
});
|
||||
}
|
||||
|
||||
async function openShareDialog({ path, title, label, triggerButton = null }) {
|
||||
activeShareTrigger = triggerButton;
|
||||
shareLink.value = new URL(`/meme/${id}`, window.location.origin).href;
|
||||
shareTitle.textContent = title;
|
||||
shareLinkLabel.textContent = label;
|
||||
shareLink.value = new URL(path, window.location.origin).href;
|
||||
window.clearTimeout(shareStatusTimer);
|
||||
shareStatusTimer = null;
|
||||
shareStatus.textContent = '';
|
||||
@@ -252,8 +320,8 @@ async function copyActiveShareLink() {
|
||||
try {
|
||||
await copyText(shareLink.value);
|
||||
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
|
||||
showCopiedFeedback(copyShareLinkButton);
|
||||
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger);
|
||||
showCopiedFeedback(copyShareLinkButton, 'Share link copied');
|
||||
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger, 'Share link copied');
|
||||
window.clearTimeout(shareStatusTimer);
|
||||
shareStatusTimer = window.setTimeout(() => {
|
||||
shareStatus.textContent = '';
|
||||
@@ -268,7 +336,104 @@ async function copyActiveShareLink() {
|
||||
}
|
||||
}
|
||||
|
||||
function showCopiedFeedback(button) {
|
||||
async function openLoreDialog() {
|
||||
setActionStatus('', loreCopy);
|
||||
if (!loreModal.open) loreModal.showModal();
|
||||
if (loreContent) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/assets/meme-protocol-lore.txt');
|
||||
if (!response.ok) throw new Error('Lore unavailable.');
|
||||
loreContent = await response.text();
|
||||
loreText.textContent = loreContent;
|
||||
} catch {
|
||||
loreText.textContent = 'LORE_TRANSMISSION_UNAVAILABLE';
|
||||
setActionStatus('LORE_TRANSMISSION_UNAVAILABLE', loreCopy);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLore() {
|
||||
if (!loreContent) await openLoreDialog();
|
||||
if (!loreContent) return;
|
||||
|
||||
loreCopy.disabled = true;
|
||||
try {
|
||||
await copyText(loreContent);
|
||||
showCopiedFeedback(loreCopy, 'Lore copied');
|
||||
setActionStatus('LORE_COPIED_TO_CLIPBOARD', loreCopy);
|
||||
} catch {
|
||||
setActionStatus('LORE_COPY_UNAVAILABLE — USE_DOWNLOAD', loreCopy);
|
||||
} finally {
|
||||
loreCopy.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyImage(url, button) {
|
||||
if (!url || !button) return;
|
||||
button.disabled = true;
|
||||
setActionStatus('', button);
|
||||
|
||||
try {
|
||||
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
|
||||
throw new Error('Image clipboard unavailable.');
|
||||
}
|
||||
const png = fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('Image fetch failed.');
|
||||
return response.blob();
|
||||
})
|
||||
.then(convertImageBlobToPng);
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
|
||||
showCopiedFeedback(button, 'Meme image copied');
|
||||
setActionStatus('MEME_IMAGE_COPIED_TO_CLIPBOARD', button);
|
||||
} catch {
|
||||
setActionStatus('IMAGE_COPY_UNAVAILABLE — USE_DOWNLOAD', button);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertImageBlobToPng(blob) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Canvas unavailable.');
|
||||
|
||||
let image;
|
||||
let cleanup = () => {};
|
||||
if ('createImageBitmap' in window) {
|
||||
image = await createImageBitmap(blob);
|
||||
cleanup = () => image.close();
|
||||
} else {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
image = await loadImage(objectUrl);
|
||||
cleanup = () => URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
context.drawImage(image, 0, 0);
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((png) => {
|
||||
if (png) resolve(png);
|
||||
else reject(new Error('PNG conversion failed.'));
|
||||
}, 'image/png');
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Image decode failed.'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function showCopiedFeedback(button, copiedLabel = 'Copied') {
|
||||
const icon = button.querySelector('.material-symbols-outlined');
|
||||
const label = button.querySelector('[data-copy-label]');
|
||||
if (!icon) return;
|
||||
@@ -280,8 +445,8 @@ function showCopiedFeedback(button) {
|
||||
|
||||
icon.textContent = 'check';
|
||||
if (label) label.textContent = 'COPIED';
|
||||
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', 'Share link copied');
|
||||
if (button.dataset.defaultTitle) button.setAttribute('title', 'Share link copied');
|
||||
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', copiedLabel);
|
||||
if (button.dataset.defaultTitle) button.setAttribute('title', copiedLabel);
|
||||
button.classList.add('is-copied');
|
||||
|
||||
window.clearTimeout(copiedFeedbackTimers.get(button));
|
||||
@@ -308,9 +473,29 @@ async function copyText(value) {
|
||||
return;
|
||||
}
|
||||
|
||||
shareLink.focus();
|
||||
shareLink.select();
|
||||
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.');
|
||||
const fallback = document.createElement('textarea');
|
||||
fallback.value = value;
|
||||
fallback.setAttribute('readonly', '');
|
||||
fallback.className = 'clipboard-fallback';
|
||||
document.body.append(fallback);
|
||||
fallback.select();
|
||||
const copied = document.execCommand('copy');
|
||||
fallback.remove();
|
||||
if (!copied) throw new Error('Clipboard unavailable.');
|
||||
}
|
||||
|
||||
function setActionStatus(message, triggerButton = null) {
|
||||
window.clearTimeout(actionStatusTimer);
|
||||
for (const status of document.querySelectorAll('[data-action-status], #action-status')) {
|
||||
status.textContent = '';
|
||||
}
|
||||
const target = triggerButton?.closest('dialog')?.querySelector('[data-action-status]') || actionStatus;
|
||||
target.textContent = message;
|
||||
if (!message) return;
|
||||
actionStatusTimer = window.setTimeout(() => {
|
||||
target.textContent = '';
|
||||
actionStatusTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
async function validateClientFile(file) {
|
||||
@@ -464,13 +649,19 @@ function connectLiveCounters() {
|
||||
|
||||
function updateCounters(meme) {
|
||||
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
|
||||
const count = element.querySelector('[data-count]');
|
||||
if (!count) continue;
|
||||
if (element.dataset.counterKind === 'view') {
|
||||
element.lastChild.textContent = formatCount(meme.viewCount);
|
||||
count.textContent = formatCount(meme.viewCount);
|
||||
}
|
||||
if (element.dataset.counterKind === 'download') {
|
||||
element.lastChild.textContent = formatCount(meme.downloadCount);
|
||||
count.textContent = formatCount(meme.downloadCount);
|
||||
}
|
||||
}
|
||||
for (const button of document.querySelectorAll(`[data-view-id="${meme.id}"]`)) {
|
||||
button.dataset.viewCount = String(meme.viewCount);
|
||||
button.dataset.downloadCount = String(meme.downloadCount);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoader(label, spinning) {
|
||||
|
||||
Reference in New Issue
Block a user