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.
750 lines
27 KiB
JavaScript
750 lines
27 KiB
JavaScript
const PAGE_SIZE = 12;
|
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
const MAX_IMAGE_PIXELS = 20_000_000;
|
|
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');
|
|
const feedLoader = document.querySelector('#feed-loader');
|
|
const feedLoaderLabel = document.querySelector('#feed-loader-label');
|
|
const uploadModal = document.querySelector('#upload-modal');
|
|
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 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;
|
|
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();
|
|
|
|
document.querySelector('#open-upload').addEventListener('click', () => {
|
|
formStatus.textContent = '';
|
|
uploadModal.showModal();
|
|
});
|
|
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 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;
|
|
const file = fileInput.files?.[0];
|
|
fileLabel.textContent = file ? file.name : 'SELECT PNG / JPEG';
|
|
formStatus.textContent = file ? 'INSPECTING_IMAGE...' : '';
|
|
const validationError = await validateClientFile(file);
|
|
if (validationRun === latestValidationRun) {
|
|
formStatus.textContent = validationError || '';
|
|
}
|
|
});
|
|
|
|
uploadForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const file = fileInput.files?.[0];
|
|
const validationError = await validateClientFile(file);
|
|
if (validationError) {
|
|
formStatus.textContent = validationError;
|
|
return;
|
|
}
|
|
|
|
addMemeButton.disabled = true;
|
|
formStatus.textContent = 'UPLOADING...';
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('meme', file);
|
|
const response = await fetch('/api/memes', { method: 'POST', body: formData });
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error(payload.error || 'Upload failed.');
|
|
if (payload.meme?.status === 'approved') {
|
|
closeUpload();
|
|
await loadMemes(1, { reset: true });
|
|
} else {
|
|
uploadForm.reset();
|
|
fileLabel.textContent = 'SELECT PNG / JPEG';
|
|
formStatus.textContent = payload.message || 'Upload queued for admin review.';
|
|
}
|
|
} catch (error) {
|
|
formStatus.textContent = error.message.toUpperCase();
|
|
} finally {
|
|
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 openMemeShareDialog(shareButton.dataset.shareId, shareButton);
|
|
return;
|
|
}
|
|
|
|
const viewButton = event.target.closest('[data-view-id]');
|
|
if (!viewButton) return;
|
|
const card = viewButton.closest('.meme-card');
|
|
await openLightbox({
|
|
id: viewButton.dataset.viewId,
|
|
url: viewButton.dataset.viewUrl,
|
|
downloadUrl: viewButton.dataset.downloadUrl,
|
|
alt: card.querySelector('img').alt,
|
|
viewCount: Number.parseInt(viewButton.dataset.viewCount || '0', 10),
|
|
downloadCount: Number.parseInt(viewButton.dataset.downloadCount || '0', 10)
|
|
});
|
|
});
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
loadNextPage();
|
|
}
|
|
}, { rootMargin: '420px 0px' });
|
|
|
|
const viewObserver = 'IntersectionObserver' in window
|
|
? new IntersectionObserver(handleViewIntersections, { threshold: [VIEW_THRESHOLD] })
|
|
: null;
|
|
|
|
connectLiveCounters();
|
|
updateScrollIndicator();
|
|
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
|
window.addEventListener('resize', updateScrollIndicator);
|
|
refreshStatus();
|
|
window.setInterval(refreshStatus, 5000);
|
|
await openSharedContentFromUrl();
|
|
await loadMemes(1, { reset: true });
|
|
observer.observe(feedLoader);
|
|
|
|
async function loadNextPage() {
|
|
if (isLoading || currentPage >= totalPages) return;
|
|
await loadMemes(currentPage + 1);
|
|
}
|
|
|
|
async function loadMemes(page, options = {}) {
|
|
if (isLoading) return;
|
|
isLoading = true;
|
|
const loaderStartedAt = performance.now();
|
|
setLoader('FETCHING_NEXT_BLOCK...', true);
|
|
|
|
try {
|
|
const response = await fetch(`/api/memes?page=${page}&pageSize=${PAGE_SIZE}`);
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error(payload.error || 'Feed error.');
|
|
|
|
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 {
|
|
if (options.reset) grid.innerHTML = `<div class="empty-state">FEED_ERROR</div>`;
|
|
setLoader('FEED_ERROR', false);
|
|
} finally {
|
|
grid.ariaBusy = 'false';
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
function delay(milliseconds) {
|
|
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
function renderMemes(memes, options = {}) {
|
|
if (!options.append && memes.length === 0) {
|
|
unobserveViewedImages();
|
|
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
|
|
return;
|
|
}
|
|
|
|
const cards = memes.map((meme, index) => {
|
|
const article = document.createElement('article');
|
|
article.className = 'meme-card';
|
|
article.dataset.memeId = meme.id;
|
|
article.innerHTML = `
|
|
<div class="card-head">
|
|
<div class="card-id">
|
|
<span class="node active node-score-${scoreBucket(meme.moderationScore)}" tabindex="0" data-tooltip="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" title="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" aria-label="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} out of 100"></span>
|
|
<span class="card-meta">ID: ${shortId(meme.id)}</span>
|
|
</div>
|
|
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
|
|
</div>
|
|
<div class="image-frame">
|
|
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy" data-view-observe-id="${meme.id}">
|
|
</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><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}" 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>
|
|
`;
|
|
if (options.append && index === 0) article.tabIndex = -1;
|
|
return article;
|
|
});
|
|
|
|
if (options.append) {
|
|
grid.append(...cards);
|
|
} else {
|
|
unobserveViewedImages();
|
|
grid.replaceChildren(...cards);
|
|
}
|
|
observeViewedImages(cards);
|
|
}
|
|
|
|
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;
|
|
|
|
try {
|
|
const response = await fetch(`/api/memes/${match[1]}`);
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error(payload.error || 'Meme not found.');
|
|
await openLightbox({
|
|
...payload.meme,
|
|
alt: `Shared meme ${shortId(payload.meme.id)}`
|
|
});
|
|
} catch {
|
|
window.location.replace('/');
|
|
}
|
|
}
|
|
|
|
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;
|
|
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 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;
|
|
shareTitle.textContent = title;
|
|
shareLinkLabel.textContent = label;
|
|
shareLink.value = new URL(path, window.location.origin).href;
|
|
window.clearTimeout(shareStatusTimer);
|
|
shareStatusTimer = null;
|
|
shareStatus.textContent = '';
|
|
resetCopiedFeedback(copyShareLinkButton);
|
|
if (!shareModal.open) shareModal.showModal();
|
|
}
|
|
|
|
async function copyActiveShareLink() {
|
|
if (!shareLink.value) return;
|
|
copyShareLinkButton.disabled = true;
|
|
try {
|
|
await copyText(shareLink.value);
|
|
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
|
|
showCopiedFeedback(copyShareLinkButton, 'Share link copied');
|
|
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger, 'Share link copied');
|
|
window.clearTimeout(shareStatusTimer);
|
|
shareStatusTimer = window.setTimeout(() => {
|
|
shareStatus.textContent = '';
|
|
shareStatusTimer = null;
|
|
}, 2600);
|
|
} catch {
|
|
shareLink.focus();
|
|
shareLink.select();
|
|
shareStatus.textContent = 'COPY_UNAVAILABLE — SELECT_AND_COPY_THE_LINK';
|
|
} finally {
|
|
copyShareLinkButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
button.dataset.defaultIcon ||= icon.textContent.trim();
|
|
if (label) button.dataset.defaultLabel ||= label.textContent.trim();
|
|
button.dataset.defaultAriaLabel ||= button.getAttribute('aria-label') || '';
|
|
button.dataset.defaultTitle ||= button.getAttribute('title') || '';
|
|
|
|
icon.textContent = 'check';
|
|
if (label) label.textContent = '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));
|
|
copiedFeedbackTimers.set(button, window.setTimeout(() => {
|
|
resetCopiedFeedback(button);
|
|
}, 2600));
|
|
}
|
|
|
|
function resetCopiedFeedback(button) {
|
|
const icon = button.querySelector('.material-symbols-outlined');
|
|
const label = button.querySelector('[data-copy-label]');
|
|
window.clearTimeout(copiedFeedbackTimers.get(button));
|
|
copiedFeedbackTimers.delete(button);
|
|
if (icon && button.dataset.defaultIcon) icon.textContent = button.dataset.defaultIcon;
|
|
if (label && button.dataset.defaultLabel) label.textContent = button.dataset.defaultLabel;
|
|
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', button.dataset.defaultAriaLabel);
|
|
if (button.dataset.defaultTitle) button.setAttribute('title', button.dataset.defaultTitle);
|
|
button.classList.remove('is-copied');
|
|
}
|
|
|
|
async function copyText(value) {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(value);
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
if (!file) return 'SELECT A FILE.';
|
|
if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.';
|
|
if (file.size > MAX_FILE_BYTES) return 'IMAGE EXCEEDS 5MB.';
|
|
|
|
try {
|
|
const dimensions = await readImageDimensions(file);
|
|
if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EDGE EXCEEDS 6000PX.';
|
|
if (dimensions.width * dimensions.height > MAX_IMAGE_PIXELS) return 'IMAGE EXCEEDS 20MP.';
|
|
} catch {
|
|
return 'IMAGE COULD NOT BE INSPECTED.';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function readImageDimensions(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
const url = URL.createObjectURL(file);
|
|
image.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
|
};
|
|
image.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error('invalid image'));
|
|
};
|
|
image.src = url;
|
|
});
|
|
}
|
|
|
|
function closeUpload() {
|
|
uploadForm.reset();
|
|
fileLabel.textContent = 'SELECT PNG / JPEG';
|
|
formStatus.textContent = '';
|
|
uploadModal.close();
|
|
}
|
|
|
|
function handleViewIntersections(entries) {
|
|
for (const entry of entries) {
|
|
const id = entry.target.dataset.viewObserveId;
|
|
if (!id || wasRecentlyViewed(id)) {
|
|
stopViewTimer(id);
|
|
if (wasRecentlyViewed(id)) viewObserver.unobserve(entry.target);
|
|
continue;
|
|
}
|
|
|
|
if (entry.isIntersecting && entry.intersectionRatio >= VIEW_THRESHOLD) {
|
|
startViewTimer(id, entry.target);
|
|
} else {
|
|
stopViewTimer(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function observeViewedImages(cards) {
|
|
if (!viewObserver) return;
|
|
for (const card of cards) {
|
|
const image = card.querySelector('[data-view-observe-id]');
|
|
if (image && !wasRecentlyViewed(image.dataset.viewObserveId)) viewObserver.observe(image);
|
|
}
|
|
}
|
|
|
|
function unobserveViewedImages() {
|
|
for (const timer of pendingViewTimers.values()) window.clearTimeout(timer);
|
|
pendingViewTimers.clear();
|
|
if (!viewObserver) return;
|
|
for (const image of grid.querySelectorAll('[data-view-observe-id]')) {
|
|
viewObserver.unobserve(image);
|
|
}
|
|
}
|
|
|
|
function startViewTimer(id, target) {
|
|
if (pendingViewTimers.has(id)) return;
|
|
const timer = window.setTimeout(async () => {
|
|
pendingViewTimers.delete(id);
|
|
if (wasRecentlyViewed(id)) {
|
|
viewObserver.unobserve(target);
|
|
return;
|
|
}
|
|
await recordViewOnce(id);
|
|
viewObserver.unobserve(target);
|
|
}, VIEW_DWELL_MS);
|
|
pendingViewTimers.set(id, timer);
|
|
}
|
|
|
|
function stopViewTimer(id) {
|
|
const timer = pendingViewTimers.get(id);
|
|
if (!timer) return;
|
|
window.clearTimeout(timer);
|
|
pendingViewTimers.delete(id);
|
|
}
|
|
|
|
async function recordViewOnce(id) {
|
|
if (!isMemeId(id)) return;
|
|
if (wasRecentlyViewed(id)) return;
|
|
rememberViewed(id);
|
|
try {
|
|
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error(payload.error || 'View update failed.');
|
|
updateCounters(payload.meme);
|
|
} catch {
|
|
forgetViewed(id);
|
|
// Live counter updates are best-effort; the stream and lightbox should still work.
|
|
}
|
|
}
|
|
|
|
function isMemeId(id) {
|
|
return typeof id === 'string' && /^[a-f0-9]{64}$/.test(id);
|
|
}
|
|
|
|
function wasRecentlyViewed(id) {
|
|
if (viewedThisSession.has(id)) return true;
|
|
try {
|
|
const viewedAt = Number.parseInt(localStorage.getItem(`${VIEW_DEDUPE_PREFIX}${id}`) || '0', 10);
|
|
if (Number.isFinite(viewedAt) && Date.now() - viewedAt < VIEW_DEDUPE_MS) return true;
|
|
} catch {
|
|
// Storage can be unavailable in hardened browser modes; session memory still dedupes.
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function rememberViewed(id) {
|
|
viewedThisSession.add(id);
|
|
try {
|
|
localStorage.setItem(`${VIEW_DEDUPE_PREFIX}${id}`, String(Date.now()));
|
|
} catch {
|
|
// localStorage is optional; no upload or view flow depends on it.
|
|
}
|
|
}
|
|
|
|
function forgetViewed(id) {
|
|
viewedThisSession.delete(id);
|
|
try {
|
|
localStorage.removeItem(`${VIEW_DEDUPE_PREFIX}${id}`);
|
|
} catch {
|
|
// Optional storage cleanup.
|
|
}
|
|
}
|
|
|
|
function connectLiveCounters() {
|
|
if (!('EventSource' in window)) return;
|
|
const source = new EventSource('/api/events');
|
|
source.addEventListener('metric', (event) => {
|
|
updateCounters(JSON.parse(event.data));
|
|
});
|
|
}
|
|
|
|
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') {
|
|
count.textContent = formatCount(meme.viewCount);
|
|
}
|
|
if (element.dataset.counterKind === 'download') {
|
|
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) {
|
|
feedLoaderLabel.textContent = label;
|
|
feedLoader.classList.toggle('is-spinning', spinning);
|
|
}
|
|
|
|
function shortId(id) {
|
|
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
|
|
}
|
|
|
|
function relativeAge(value) {
|
|
const elapsed = Math.max(1, Date.now() - new Date(value).getTime());
|
|
const minutes = Math.floor(elapsed / 60000);
|
|
if (minutes < 60) return `${minutes}M AGO`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}H AGO`;
|
|
return `${Math.floor(hours / 24)}D AGO`;
|
|
}
|
|
|
|
function formatCount(value) {
|
|
const count = Number.isFinite(value) ? value : 0;
|
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
|
|
return String(count);
|
|
}
|
|
|
|
function scoreBucket(value) {
|
|
const score = Number.isFinite(value) ? value : 50;
|
|
if (score >= 90) return 5;
|
|
if (score >= 75) return 4;
|
|
if (score >= 60) return 3;
|
|
if (score >= 40) return 2;
|
|
if (score >= 20) return 1;
|
|
return 0;
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
const started = performance.now();
|
|
try {
|
|
const response = await fetch('/api/status', { cache: 'no-store' });
|
|
const status = await response.json();
|
|
if (!response.ok) throw new Error('status failed');
|
|
setStat('status', `${status.ok ? 'ONLINE' : 'DEGRADED'} ${formatUptime(status.uptimeSeconds)}`);
|
|
setStat('latency', `${Math.max(1, Math.round(performance.now() - started))}MS`);
|
|
setStat('nodes', formatCount(status.liveClients));
|
|
setStat('memes', formatCount(status.memeCount));
|
|
} catch {
|
|
setStat('status', 'OFFLINE');
|
|
setStat('latency', '--MS');
|
|
setStat('nodes', '--');
|
|
setStat('memes', '--');
|
|
}
|
|
}
|
|
|
|
function setStat(name, value) {
|
|
for (const element of document.querySelectorAll(`[data-stat="${name}"]`)) {
|
|
element.textContent = value;
|
|
}
|
|
}
|
|
|
|
function formatUptime(value) {
|
|
const seconds = Math.max(0, Number.isFinite(value) ? value : 0);
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 1) return `UP ${seconds}S`;
|
|
if (minutes < 60) return `UP ${minutes}M`;
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
if (hours < 24) return `UP ${hours}H${remainingMinutes > 0 ? ` ${remainingMinutes}M` : ''}`;
|
|
|
|
const days = Math.floor(hours / 24);
|
|
const remainingHours = hours % 24;
|
|
return `UP ${days}D${remainingHours > 0 ? ` ${remainingHours}H` : ''}`;
|
|
}
|
|
|
|
function updateScrollIndicator() {
|
|
const segments = [...scrollIndicator.querySelectorAll('span')];
|
|
const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
|
|
const progress = Math.min(1, Math.max(0, window.scrollY / maxScroll));
|
|
const activeIndex = Math.min(segments.length - 1, Math.round(progress * (segments.length - 1)));
|
|
segments.forEach((segment, index) => {
|
|
segment.classList.toggle('active', index === activeIndex);
|
|
});
|
|
}
|