krz/michelangelo
A Tumblr web client.
clone: git clone https://gitbay.org/krz/michelangelo.git
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, '&')
286 .replace(/</g, '<')
287 .replace(/>/g, '>')
288 .replace(/"/g, '"');
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})();