Files
bitsforfree/public/assets/app.js
T

433 lines
15 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 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 submitUpload = document.querySelector('#submit-upload');
const lightbox = document.querySelector('#lightbox');
const lightboxImage = document.querySelector('#lightbox-image');
const lightboxDownload = document.querySelector('#lightbox-download');
const lightboxId = document.querySelector('#lightbox-id');
const scrollIndicator = document.querySelector('#scroll-indicator');
let currentPage = 0;
let totalPages = 1;
let isLoading = false;
let latestValidationRun = 0;
const viewedThisSession = new Set();
const pendingViewTimers = new Map();
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());
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;
}
submitUpload.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 || 'QUEUED_FOR_REVIEW'} MEME_CONSENSUS_SCORE: ${payload.meme?.moderationScore ?? '--'}`;
}
} catch (error) {
formStatus.textContent = error.message.toUpperCase();
} finally {
submitUpload.disabled = false;
}
});
grid.addEventListener('click', async (event) => {
const viewButton = event.target.closest('[data-view-id]');
if (!viewButton) return;
const card = viewButton.closest('.meme-card');
lightboxImage.src = viewButton.dataset.viewUrl;
lightboxImage.alt = card.querySelector('img').alt;
lightboxDownload.href = viewButton.dataset.downloadUrl;
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
lightbox.showModal();
await recordViewOnce(viewButton.dataset.viewId);
});
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 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;
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 });
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 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>${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>
</div>
<div class="action-group">
<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 text-action-accent 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>
</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 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}"]`)) {
if (element.dataset.counterKind === 'view') {
element.lastChild.textContent = formatCount(meme.viewCount);
}
if (element.dataset.counterKind === 'download') {
element.lastChild.textContent = formatCount(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);
});
}