diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/app.css | 299 | ||||
| -rw-r--r-- | static/app.js | 300 |
2 files changed, 599 insertions, 0 deletions
diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..1eac76b --- /dev/null +++ b/static/app.css @@ -0,0 +1,299 @@ +/* ── Variables ───────────────────────────────────────────── */ +:root { + --bg: #f4f4f4; + --surface: #ffffff; + --text: #111111; + --muted: #666666; + --accent: #e0245e; + --nav-bg: #ffffff; + --border: #e0e0e0; + --overlay: rgba(0,0,0,0.85); + --gap: 8px; + --col-width: 280px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f0f0f; + --surface: #1a1a1a; + --text: #f0f0f0; + --muted: #888888; + --accent: #ff4d80; + --nav-bg: #111111; + --border: #2a2a2a; + } +} + +/* ── Reset / Base ────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 15px; } + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +a { color: inherit; text-decoration: none; } +a:hover { text-decoration: underline; } +img, video { display: block; width: 100%; height: auto; } + +/* ── Nav ─────────────────────────────────────────────────── */ +nav { + position: sticky; + top: 0; + z-index: 100; + background: var(--nav-bg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 1rem; + padding: 0.6rem 1.2rem; + flex-wrap: wrap; +} + +.brand { + font-weight: 700; + font-size: 1.1rem; + letter-spacing: -0.02em; + color: var(--accent); + flex-shrink: 0; +} +.brand:hover { text-decoration: none; } + +.nav-links { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-left: auto; +} + +.nav-blogname { + font-size: 0.85rem; + color: var(--muted); +} + +.type-filter { display: flex; gap: 0.4rem; } +.type-filter a { + font-size: 0.8rem; + padding: 0.25rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); + transition: all 0.15s; +} +.type-filter a.active, +.type-filter a:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; + text-decoration: none; +} + +.search-form input { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text); + font-size: 0.8rem; + padding: 0.25rem 0.75rem; + outline: none; + width: 160px; + transition: border-color 0.15s, width 0.2s; +} +.search-form input:focus { + border-color: var(--accent); + width: 200px; +} + +.nav-logout { + font-size: 0.8rem; + color: var(--muted); +} + +/* ── Main / Grid ─────────────────────────────────────────── */ +main { padding: var(--gap); } + +.page-header { + font-size: 1rem; + font-weight: 500; + color: var(--muted); + padding: 0.75rem 0.25rem 0.5rem; +} + +/* True CSS masonry using column-count */ +.masonry-grid { + column-count: auto; + column-width: var(--col-width); + column-gap: var(--gap); +} + +.post-card { + break-inside: avoid; + margin-bottom: var(--gap); + background: var(--surface); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + position: relative; + transition: transform 0.15s, box-shadow 0.15s; +} + +.post-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0,0,0,0.15); +} + +@media (prefers-color-scheme: dark) { + .post-card:hover { + box-shadow: 0 6px 20px rgba(0,0,0,0.5); + } +} + +.post-card img, +.post-card video { + width: 100%; + height: auto; + display: block; +} + +.post-card .post-meta { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 0.4rem 0.6rem; + background: linear-gradient(transparent, rgba(0,0,0,0.6)); + display: flex; + align-items: center; + justify-content: space-between; + opacity: 0; + transition: opacity 0.15s; +} + +.post-card:hover .post-meta { opacity: 1; } + +.post-meta .blog-name { + font-size: 0.75rem; + color: #fff; + font-weight: 500; +} + +.post-meta .note-count { + font-size: 0.7rem; + color: rgba(255,255,255,0.75); +} + +/* ── Loader / Sentinel ───────────────────────────────────── */ +.loader { + display: flex; + justify-content: center; + gap: 6px; + padding: 2rem; +} +.loader span { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--accent); + animation: bounce 0.9s infinite alternate; +} +.loader span:nth-child(2) { animation-delay: 0.2s; } +.loader span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes bounce { + from { opacity: 0.3; transform: translateY(0); } + to { opacity: 1; transform: translateY(-6px); } +} + +.hidden { display: none !important; } + +.end-message { + text-align: center; + color: var(--muted); + font-size: 0.8rem; + padding: 2rem; +} + +#sentinel { height: 100px; } + +/* ── Lightbox ────────────────────────────────────────────── */ +.lightbox { + position: fixed; + inset: 0; + z-index: 1000; + background: var(--overlay); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.lightbox-media { + max-width: 90vw; + max-height: 80vh; + display: flex; + align-items: center; + justify-content: center; +} + +.lightbox-media img, +.lightbox-media video { + max-width: 90vw; + max-height: 80vh; + width: auto; + height: auto; + object-fit: contain; + border-radius: 2px; +} + +.lightbox-close { + position: absolute; + top: 1rem; right: 1rem; + background: none; border: none; + color: #fff; font-size: 1.5rem; + cursor: pointer; opacity: 0.7; + transition: opacity 0.15s; +} +.lightbox-close:hover { opacity: 1; } + +.lightbox-prev, +.lightbox-next { + position: absolute; + top: 50%; transform: translateY(-50%); + background: rgba(255,255,255,0.1); + border: none; color: #fff; + font-size: 2rem; width: 2.5rem; height: 5rem; + cursor: pointer; border-radius: 4px; + transition: background 0.15s; + display: flex; align-items: center; justify-content: center; +} +.lightbox-prev { left: 1rem; } +.lightbox-next { right: 1rem; } +.lightbox-prev:hover, +.lightbox-next:hover { background: rgba(255,255,255,0.2); } + +.lightbox-actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +.lightbox-actions button, +.lightbox-actions a { + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.2); + color: #fff; + padding: 0.35rem 0.9rem; + border-radius: 999px; + font-size: 0.8rem; + cursor: pointer; + transition: background 0.15s; + text-decoration: none; +} +.lightbox-actions button:hover, +.lightbox-actions a:hover { background: rgba(255,255,255,0.25); } + +.btn-like.liked { color: var(--accent); border-color: var(--accent); } 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, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + } + + function fmtNotes(n) { + if (!n) return ''; + if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; + return String(n); + } + + // ── Init ─────────────────────────────────────────────────── + fetchPosts(); + +})(); |
