Count views on stream
This commit is contained in:
+111
-5
@@ -1,5 +1,9 @@
|
|||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
|
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 SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
|
||||||
|
|
||||||
const grid = document.querySelector('#meme-grid');
|
const grid = document.querySelector('#meme-grid');
|
||||||
@@ -21,6 +25,8 @@ let currentPage = 0;
|
|||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let latestValidationRun = 0;
|
let latestValidationRun = 0;
|
||||||
|
const viewedThisSession = new Set();
|
||||||
|
const pendingViewTimers = new Map();
|
||||||
|
|
||||||
document.querySelector('#open-upload').addEventListener('click', () => {
|
document.querySelector('#open-upload').addEventListener('click', () => {
|
||||||
formStatus.textContent = '';
|
formStatus.textContent = '';
|
||||||
@@ -83,7 +89,7 @@ grid.addEventListener('click', async (event) => {
|
|||||||
lightboxDownload.href = viewButton.dataset.downloadUrl;
|
lightboxDownload.href = viewButton.dataset.downloadUrl;
|
||||||
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
|
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
|
||||||
lightbox.showModal();
|
lightbox.showModal();
|
||||||
await recordView(viewButton.dataset.viewId);
|
await recordViewOnce(viewButton.dataset.viewId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
@@ -92,6 +98,10 @@ const observer = new IntersectionObserver((entries) => {
|
|||||||
}
|
}
|
||||||
}, { rootMargin: '420px 0px' });
|
}, { rootMargin: '420px 0px' });
|
||||||
|
|
||||||
|
const viewObserver = 'IntersectionObserver' in window
|
||||||
|
? new IntersectionObserver(handleViewIntersections, { threshold: [VIEW_THRESHOLD] })
|
||||||
|
: null;
|
||||||
|
|
||||||
connectLiveCounters();
|
connectLiveCounters();
|
||||||
updateScrollIndicator();
|
updateScrollIndicator();
|
||||||
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
||||||
@@ -132,6 +142,7 @@ async function loadMemes(page, options = {}) {
|
|||||||
|
|
||||||
function renderMemes(memes, options = {}) {
|
function renderMemes(memes, options = {}) {
|
||||||
if (!options.append && memes.length === 0) {
|
if (!options.append && memes.length === 0) {
|
||||||
|
unobserveViewedImages();
|
||||||
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
|
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,7 +160,7 @@ function renderMemes(memes, options = {}) {
|
|||||||
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
|
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-frame">
|
<div class="image-frame">
|
||||||
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy">
|
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy" data-view-observe-id="${meme.id}">
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
@@ -169,8 +180,10 @@ function renderMemes(memes, options = {}) {
|
|||||||
if (options.append) {
|
if (options.append) {
|
||||||
grid.append(...cards);
|
grid.append(...cards);
|
||||||
} else {
|
} else {
|
||||||
|
unobserveViewedImages();
|
||||||
grid.replaceChildren(...cards);
|
grid.replaceChildren(...cards);
|
||||||
}
|
}
|
||||||
|
observeViewedImages(cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateClientFile(file) {
|
async function validateClientFile(file) {
|
||||||
@@ -211,13 +224,106 @@ function closeUpload() {
|
|||||||
uploadModal.close();
|
uploadModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordView(id) {
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
|
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (response.ok) updateCounters(payload.meme);
|
if (!response.ok) throw new Error(payload.error || 'View update failed.');
|
||||||
|
updateCounters(payload.meme);
|
||||||
} catch {
|
} catch {
|
||||||
// Live counter updates are best-effort; the lightbox should still open.
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user