krz/michelangelo

A Tumblr web client.

clone: git clone https://gitbay.org/krz/michelangelo.git

main: static/app.js · raw

  1(() => {
  2  'use strict';
  3
  4  const { view, blog, query, myBlog } = window.MICHELANGELO;
  5
  6  // ── State ──────────────────────────────────────────────────
  7  let offset = 0;
  8  let loading = false;
  9  let exhausted = false;
 10  let postType = 'photo';
 11  let posts = []; // all loaded posts for lightbox navigation
 12  let lightboxIndex = -1;
 13
 14  // ── Elements ───────────────────────────────────────────────
 15  const grid       = document.getElementById('grid');
 16  const sentinel   = document.getElementById('sentinel');
 17  const loader     = document.getElementById('loader');
 18  const endMsg     = document.getElementById('end-message');
 19  const lightbox   = document.getElementById('lightbox');
 20  const lbMedia    = lightbox.querySelector('.lightbox-media');
 21  const lbClose    = lightbox.querySelector('.lightbox-close');
 22  const lbPrev     = lightbox.querySelector('.lightbox-prev');
 23  const lbNext     = lightbox.querySelector('.lightbox-next');
 24  const lbLike     = lightbox.querySelector('.btn-like');
 25  const lbReblog   = lightbox.querySelector('.btn-reblog');
 26  const lbSource   = lightbox.querySelector('.btn-source');
 27
 28  // ── Type filter wiring ─────────────────────────────────────
 29  document.querySelectorAll('.type-filter a').forEach(a => {
 30    a.addEventListener('click', e => {
 31      e.preventDefault();
 32      if (a.dataset.type === postType) return;
 33      postType = a.dataset.type;
 34      document.querySelectorAll('.type-filter a').forEach(x => x.classList.remove('active'));
 35      a.classList.add('active');
 36      reset();
 37    });
 38  });
 39
 40  // ── API fetch ──────────────────────────────────────────────
 41  async function fetchPosts() {
 42    if (loading || exhausted) return;
 43    loading = true;
 44    loader.classList.remove('hidden');
 45
 46    let url;
 47    if (view === 'dashboard') {
 48      url = `/api/dashboard?offset=${offset}&type=${postType}`;
 49    } else if (view === 'blog') {
 50      url = `/api/blog/${encodeURIComponent(blog)}?offset=${offset}&type=${postType}`;
 51    } else if (view === 'search') {
 52      url = `/api/search?q=${encodeURIComponent(query)}`;
 53      exhausted = true; // tagged endpoint returns one batch
 54    }
 55
 56    try {
 57      const resp = await fetch(url);
 58      if (!resp.ok) throw new Error(await resp.text());
 59      const data = await resp.json();
 60      const newPosts = Array.isArray(data) ? data : (data || []);
 61
 62      if (!newPosts.length) {
 63        exhausted = true;
 64        endMsg.classList.remove('hidden');
 65      } else {
 66        renderPosts(newPosts);
 67        offset += newPosts.length;
 68        if (newPosts.length < 20) {
 69          exhausted = true;
 70          endMsg.classList.remove('hidden');
 71        }
 72      }
 73    } catch (err) {
 74      console.error('Fetch error:', err);
 75    } finally {
 76      loading = false;
 77      loader.classList.add('hidden');
 78    }
 79  }
 80
 81  // ── Render ─────────────────────────────────────────────────
 82  function renderPosts(newPosts) {
 83    const startIndex = posts.length;
 84    posts = posts.concat(newPosts);
 85
 86    newPosts.forEach((post, i) => {
 87      const idx = startIndex + i;
 88      const card = buildCard(post, idx);
 89      if (card) grid.appendChild(card);
 90    });
 91  }
 92
 93  function buildCard(post, idx) {
 94    let mediaSrc = null;
 95    let isVideo = false;
 96    let thumb = null;
 97
 98    if (post.type === 'photo' && post.photos && post.photos.length) {
 99      mediaSrc = post.photos[0].original_size.url;
100    } else if (post.type === 'video' && post.video_url) {
101      mediaSrc = post.video_url;
102      thumb = post.thumbnail_url;
103      isVideo = true;
104    }
105
106    if (!mediaSrc) return null;
107
108    const card = document.createElement('div');
109    card.className = 'post-card';
110    card.dataset.index = idx;
111
112    if (isVideo) {
113      const vid = document.createElement('video');
114      vid.src = mediaSrc;
115      if (thumb) vid.poster = thumb;
116      vid.muted = true;
117      vid.loop = true;
118      vid.playsInline = true;
119      vid.addEventListener('mouseenter', () => vid.play());
120      vid.addEventListener('mouseleave', () => { vid.pause(); vid.currentTime = 0; });
121      card.appendChild(vid);
122    } else {
123      const img = document.createElement('img');
124      img.src = mediaSrc;
125      img.loading = 'lazy';
126      img.alt = '';
127      card.appendChild(img);
128    }
129
130    // Hover meta bar
131    const meta = document.createElement('div');
132    meta.className = 'post-meta';
133    meta.innerHTML = `
134      <span class="blog-name">${esc(post.blog_name)}</span>
135      <span class="note-count">${fmtNotes(post.note_count)}</span>
136    `;
137    card.appendChild(meta);
138
139    card.addEventListener('click', () => openLightbox(idx));
140    return card;
141  }
142
143  // ── Lightbox ───────────────────────────────────────────────
144  function openLightbox(idx) {
145    lightboxIndex = idx;
146    renderLightbox();
147    lightbox.classList.remove('hidden');
148    document.body.style.overflow = 'hidden';
149  }
150
151  function closeLightbox() {
152    lightbox.classList.add('hidden');
153    document.body.style.overflow = '';
154    lbMedia.innerHTML = '';
155  }
156
157  function renderLightbox() {
158    const post = posts[lightboxIndex];
159    if (!post) return;
160
161    lbMedia.innerHTML = '';
162    lbPrev.style.visibility = lightboxIndex > 0 ? 'visible' : 'hidden';
163    lbNext.style.visibility = lightboxIndex < posts.length - 1 ? 'visible' : 'hidden';
164
165    if (post.type === 'photo' && post.photos && post.photos.length) {
166      const img = document.createElement('img');
167      img.src = post.photos[0].original_size.url;
168      img.alt = '';
169      lbMedia.appendChild(img);
170    } else if (post.type === 'video' && post.video_url) {
171      const vid = document.createElement('video');
172      vid.src = post.video_url;
173      if (post.thumbnail_url) vid.poster = post.thumbnail_url;
174      vid.controls = true;
175      vid.autoplay = true;
176      lbMedia.appendChild(vid);
177    }
178
179    // Actions
180    lbLike.dataset.id   = post.id_string || post.id;
181    lbLike.dataset.key  = post.reblog_key;
182    lbLike.dataset.liked = post.liked ? 'true' : 'false';
183    lbLike.classList.toggle('liked', !!post.liked);
184    lbLike.textContent = post.liked ? '♥ Liked' : '♡ Like';
185
186    lbReblog.dataset.id   = post.id_string || post.id;
187    lbReblog.dataset.key  = post.reblog_key;
188    lbReblog.dataset.blog = post.blog_name;
189
190    lbSource.href = post.post_url || '#';
191
192    // Prefetch next batch if near end
193    if (lightboxIndex >= posts.length - 5) {
194      fetchPosts();
195    }
196  }
197
198  lbClose.addEventListener('click', closeLightbox);
199  lightbox.addEventListener('click', e => { if (e.target === lightbox) closeLightbox(); });
200
201  lbPrev.addEventListener('click', () => {
202    if (lightboxIndex > 0) { lightboxIndex--; renderLightbox(); }
203  });
204  lbNext.addEventListener('click', () => {
205    if (lightboxIndex < posts.length - 1) { lightboxIndex++; renderLightbox(); }
206  });
207
208  document.addEventListener('keydown', e => {
209    if (lightbox.classList.contains('hidden')) return;
210    if (e.key === 'Escape')       closeLightbox();
211    if (e.key === 'ArrowLeft')    lbPrev.click();
212    if (e.key === 'ArrowRight')   lbNext.click();
213  });
214
215  // ── Like ───────────────────────────────────────────────────
216  lbLike.addEventListener('click', async () => {
217    const id   = lbLike.dataset.id;
218    const key  = lbLike.dataset.key;
219    const liked = lbLike.dataset.liked === 'true';
220    const url  = `/api/like?id=${encodeURIComponent(id)}&key=${encodeURIComponent(key)}${liked ? '&unlike=1' : ''}`;
221    try {
222      const resp = await fetch(url, { method: 'GET' });
223      if (!resp.ok) throw new Error(await resp.text());
224      const post = posts[lightboxIndex];
225      post.liked = !liked;
226      lbLike.dataset.liked = post.liked ? 'true' : 'false';
227      lbLike.classList.toggle('liked', post.liked);
228      lbLike.textContent = post.liked ? '♥ Liked' : '♡ Like';
229    } catch (err) {
230      console.error('Like error:', err);
231    }
232  });
233
234  // ── Reblog ─────────────────────────────────────────────────
235  lbReblog.addEventListener('click', async () => {
236    if (!myBlog) return;
237    const body = new URLSearchParams({
238      id:         lbReblog.dataset.id,
239      reblog_key: lbReblog.dataset.key,
240      blog_name:  lbReblog.dataset.blog,
241      native_blog: myBlog,
242    });
243    try {
244      const resp = await fetch('/api/reblog', { method: 'POST', body });
245      if (!resp.ok) throw new Error(await resp.text());
246      lbReblog.textContent = '✓ Reblogged';
247      setTimeout(() => { lbReblog.textContent = '⇄ Reblog'; }, 2000);
248    } catch (err) {
249      console.error('Reblog error:', err);
250    }
251  });
252
253  // ── Infinite scroll via IntersectionObserver ───────────────
254  const observer = new IntersectionObserver(entries => {
255    if (entries[0].isIntersecting) fetchPosts();
256  }, { rootMargin: '400px' });
257
258  observer.observe(sentinel);
259
260  // ── Reset (type filter change) ─────────────────────────────
261  function reset() {
262    offset = 0;
263    loading = false;
264    exhausted = false;
265    posts = [];
266    lightboxIndex = -1;
267    grid.innerHTML = '';
268    endMsg.classList.add('hidden');
269    fetchPosts();
270  }
271
272  // ── Blog link interception ─────────────────────────────────
273  // Clicking a blog name in the meta overlay navigates to /blog/<name>
274  grid.addEventListener('click', e => {
275    const blogLink = e.target.closest('.blog-name');
276    if (blogLink && blogLink.dataset.name) {
277      e.stopPropagation();
278      window.location.href = `/blog/${encodeURIComponent(blogLink.dataset.name)}`;
279    }
280  });
281
282  // ── Helpers ────────────────────────────────────────────────
283  function esc(str) {
284    return String(str || '')
285      .replace(/&/g, '&amp;')
286      .replace(/</g, '&lt;')
287      .replace(/>/g, '&gt;')
288      .replace(/"/g, '&quot;');
289  }
290
291  function fmtNotes(n) {
292    if (!n) return '';
293    if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
294    return String(n);
295  }
296
297  // ── Init ───────────────────────────────────────────────────
298  fetchPosts();
299
300})();