757 lines
27 KiB
JavaScript
757 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() {
|
|
const requestedDialog = new URLSearchParams(window.location.search).get('open');
|
|
if (window.location.pathname === '/' && requestedDialog === 'pet') {
|
|
petModal.showModal();
|
|
window.history.replaceState({}, '', '/');
|
|
return;
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|