summaryrefslogtreecommitdiff
path: root/static/app.js
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-01 19:32:52 -0600
committerChristian Cleberg <[email protected]>2026-03-01 19:32:52 -0600
commita98c2a4542e19ce4fb632bc32fc47c18273161cc (patch)
tree99d016c1c9f12d98d57cfec34262e7c228a1d920 /static/app.js
parentcd8c38db601355003510e92a0936cde6b71cf849 (diff)
downloadmichelangelo-a98c2a4542e19ce4fb632bc32fc47c18273161cc.tar.gz
michelangelo-a98c2a4542e19ce4fb632bc32fc47c18273161cc.tar.bz2
michelangelo-a98c2a4542e19ce4fb632bc32fc47c18273161cc.zip
convert from PHP to Go
Diffstat (limited to 'static/app.js')
-rw-r--r--static/app.js300
1 files changed, 300 insertions, 0 deletions
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..86be9b1
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,300 @@
+(() => {
+ 'use strict';
+
+ const { view, blog, query, myBlog } = window.MICHELANGELO;
+
+ // ── State ──────────────────────────────────────────────────
+ let offset = 0;
+ let loading = false;
+ let exhausted = false;
+ let postType = 'photo';
+ let posts = []; // all loaded posts for lightbox navigation
+ let lightboxIndex = -1;
+
+ // ── Elements ───────────────────────────────────────────────
+ const grid = document.getElementById('grid');
+ const sentinel = document.getElementById('sentinel');
+ const loader = document.getElementById('loader');
+ const endMsg = document.getElementById('end-message');
+ const lightbox = document.getElementById('lightbox');
+ const lbMedia = lightbox.querySelector('.lightbox-media');
+ const lbClose = lightbox.querySelector('.lightbox-close');
+ const lbPrev = lightbox.querySelector('.lightbox-prev');
+ const lbNext = lightbox.querySelector('.lightbox-next');
+ const lbLike = lightbox.querySelector('.btn-like');
+ const lbReblog = lightbox.querySelector('.btn-reblog');
+ const lbSource = lightbox.querySelector('.btn-source');
+
+ // ── Type filter wiring ─────────────────────────────────────
+ document.querySelectorAll('.type-filter a').forEach(a => {
+ a.addEventListener('click', e => {
+ e.preventDefault();
+ if (a.dataset.type === postType) return;
+ postType = a.dataset.type;
+ document.querySelectorAll('.type-filter a').forEach(x => x.classList.remove('active'));
+ a.classList.add('active');
+ reset();
+ });
+ });
+
+ // ── API fetch ──────────────────────────────────────────────
+ async function fetchPosts() {
+ if (loading || exhausted) return;
+ loading = true;
+ loader.classList.remove('hidden');
+
+ let url;
+ if (view === 'dashboard') {
+ url = `/api/dashboard?offset=${offset}&type=${postType}`;
+ } else if (view === 'blog') {
+ url = `/api/blog/${encodeURIComponent(blog)}?offset=${offset}&type=${postType}`;
+ } else if (view === 'search') {
+ url = `/api/search?q=${encodeURIComponent(query)}`;
+ exhausted = true; // tagged endpoint returns one batch
+ }
+
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error(await resp.text());
+ const data = await resp.json();
+ const newPosts = Array.isArray(data) ? data : (data || []);
+
+ if (!newPosts.length) {
+ exhausted = true;
+ endMsg.classList.remove('hidden');
+ } else {
+ renderPosts(newPosts);
+ offset += newPosts.length;
+ if (newPosts.length < 20) {
+ exhausted = true;
+ endMsg.classList.remove('hidden');
+ }
+ }
+ } catch (err) {
+ console.error('Fetch error:', err);
+ } finally {
+ loading = false;
+ loader.classList.add('hidden');
+ }
+ }
+
+ // ── Render ─────────────────────────────────────────────────
+ function renderPosts(newPosts) {
+ const startIndex = posts.length;
+ posts = posts.concat(newPosts);
+
+ newPosts.forEach((post, i) => {
+ const idx = startIndex + i;
+ const card = buildCard(post, idx);
+ if (card) grid.appendChild(card);
+ });
+ }
+
+ function buildCard(post, idx) {
+ let mediaSrc = null;
+ let isVideo = false;
+ let thumb = null;
+
+ if (post.type === 'photo' && post.photos && post.photos.length) {
+ mediaSrc = post.photos[0].original_size.url;
+ } else if (post.type === 'video' && post.video_url) {
+ mediaSrc = post.video_url;
+ thumb = post.thumbnail_url;
+ isVideo = true;
+ }
+
+ if (!mediaSrc) return null;
+
+ const card = document.createElement('div');
+ card.className = 'post-card';
+ card.dataset.index = idx;
+
+ if (isVideo) {
+ const vid = document.createElement('video');
+ vid.src = mediaSrc;
+ if (thumb) vid.poster = thumb;
+ vid.muted = true;
+ vid.loop = true;
+ vid.playsInline = true;
+ vid.addEventListener('mouseenter', () => vid.play());
+ vid.addEventListener('mouseleave', () => { vid.pause(); vid.currentTime = 0; });
+ card.appendChild(vid);
+ } else {
+ const img = document.createElement('img');
+ img.src = mediaSrc;
+ img.loading = 'lazy';
+ img.alt = '';
+ card.appendChild(img);
+ }
+
+ // Hover meta bar
+ const meta = document.createElement('div');
+ meta.className = 'post-meta';
+ meta.innerHTML = `
+ <span class="blog-name">${esc(post.blog_name)}</span>
+ <span class="note-count">${fmtNotes(post.note_count)}</span>
+ `;
+ card.appendChild(meta);
+
+ card.addEventListener('click', () => openLightbox(idx));
+ return card;
+ }
+
+ // ── Lightbox ───────────────────────────────────────────────
+ function openLightbox(idx) {
+ lightboxIndex = idx;
+ renderLightbox();
+ lightbox.classList.remove('hidden');
+ document.body.style.overflow = 'hidden';
+ }
+
+ function closeLightbox() {
+ lightbox.classList.add('hidden');
+ document.body.style.overflow = '';
+ lbMedia.innerHTML = '';
+ }
+
+ function renderLightbox() {
+ const post = posts[lightboxIndex];
+ if (!post) return;
+
+ lbMedia.innerHTML = '';
+ lbPrev.style.visibility = lightboxIndex > 0 ? 'visible' : 'hidden';
+ lbNext.style.visibility = lightboxIndex < posts.length - 1 ? 'visible' : 'hidden';
+
+ if (post.type === 'photo' && post.photos && post.photos.length) {
+ const img = document.createElement('img');
+ img.src = post.photos[0].original_size.url;
+ img.alt = '';
+ lbMedia.appendChild(img);
+ } else if (post.type === 'video' && post.video_url) {
+ const vid = document.createElement('video');
+ vid.src = post.video_url;
+ if (post.thumbnail_url) vid.poster = post.thumbnail_url;
+ vid.controls = true;
+ vid.autoplay = true;
+ lbMedia.appendChild(vid);
+ }
+
+ // Actions
+ lbLike.dataset.id = post.id_string || post.id;
+ lbLike.dataset.key = post.reblog_key;
+ lbLike.dataset.liked = post.liked ? 'true' : 'false';
+ lbLike.classList.toggle('liked', !!post.liked);
+ lbLike.textContent = post.liked ? '♥ Liked' : '♡ Like';
+
+ lbReblog.dataset.id = post.id_string || post.id;
+ lbReblog.dataset.key = post.reblog_key;
+ lbReblog.dataset.blog = post.blog_name;
+
+ lbSource.href = post.post_url || '#';
+
+ // Prefetch next batch if near end
+ if (lightboxIndex >= posts.length - 5) {
+ fetchPosts();
+ }
+ }
+
+ lbClose.addEventListener('click', closeLightbox);
+ lightbox.addEventListener('click', e => { if (e.target === lightbox) closeLightbox(); });
+
+ lbPrev.addEventListener('click', () => {
+ if (lightboxIndex > 0) { lightboxIndex--; renderLightbox(); }
+ });
+ lbNext.addEventListener('click', () => {
+ if (lightboxIndex < posts.length - 1) { lightboxIndex++; renderLightbox(); }
+ });
+
+ document.addEventListener('keydown', e => {
+ if (lightbox.classList.contains('hidden')) return;
+ if (e.key === 'Escape') closeLightbox();
+ if (e.key === 'ArrowLeft') lbPrev.click();
+ if (e.key === 'ArrowRight') lbNext.click();
+ });
+
+ // ── Like ───────────────────────────────────────────────────
+ lbLike.addEventListener('click', async () => {
+ const id = lbLike.dataset.id;
+ const key = lbLike.dataset.key;
+ const liked = lbLike.dataset.liked === 'true';
+ const url = `/api/like?id=${encodeURIComponent(id)}&key=${encodeURIComponent(key)}${liked ? '&unlike=1' : ''}`;
+ try {
+ const resp = await fetch(url, { method: 'GET' });
+ if (!resp.ok) throw new Error(await resp.text());
+ const post = posts[lightboxIndex];
+ post.liked = !liked;
+ lbLike.dataset.liked = post.liked ? 'true' : 'false';
+ lbLike.classList.toggle('liked', post.liked);
+ lbLike.textContent = post.liked ? '♥ Liked' : '♡ Like';
+ } catch (err) {
+ console.error('Like error:', err);
+ }
+ });
+
+ // ── Reblog ─────────────────────────────────────────────────
+ lbReblog.addEventListener('click', async () => {
+ if (!myBlog) return;
+ const body = new URLSearchParams({
+ id: lbReblog.dataset.id,
+ reblog_key: lbReblog.dataset.key,
+ blog_name: lbReblog.dataset.blog,
+ native_blog: myBlog,
+ });
+ try {
+ const resp = await fetch('/api/reblog', { method: 'POST', body });
+ if (!resp.ok) throw new Error(await resp.text());
+ lbReblog.textContent = '✓ Reblogged';
+ setTimeout(() => { lbReblog.textContent = '⇄ Reblog'; }, 2000);
+ } catch (err) {
+ console.error('Reblog error:', err);
+ }
+ });
+
+ // ── Infinite scroll via IntersectionObserver ───────────────
+ const observer = new IntersectionObserver(entries => {
+ if (entries[0].isIntersecting) fetchPosts();
+ }, { rootMargin: '400px' });
+
+ observer.observe(sentinel);
+
+ // ── Reset (type filter change) ─────────────────────────────
+ function reset() {
+ offset = 0;
+ loading = false;
+ exhausted = false;
+ posts = [];
+ lightboxIndex = -1;
+ grid.innerHTML = '';
+ endMsg.classList.add('hidden');
+ fetchPosts();
+ }
+
+ // ── Blog link interception ─────────────────────────────────
+ // Clicking a blog name in the meta overlay navigates to /blog/<name>
+ grid.addEventListener('click', e => {
+ const blogLink = e.target.closest('.blog-name');
+ if (blogLink && blogLink.dataset.name) {
+ e.stopPropagation();
+ window.location.href = `/blog/${encodeURIComponent(blogLink.dataset.name)}`;
+ }
+ });
+
+ // ── Helpers ────────────────────────────────────────────────
+ function esc(str) {
+ return String(str || '')
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+ }
+
+ function fmtNotes(n) {
+ if (!n) return '';
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
+ return String(n);
+ }
+
+ // ── Init ───────────────────────────────────────────────────
+ fetchPosts();
+
+})();