summaryrefslogtreecommitdiff
path: root/static
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
parentcd8c38db601355003510e92a0936cde6b71cf849 (diff)
downloadmichelangelo-a98c2a4542e19ce4fb632bc32fc47c18273161cc.tar.gz
michelangelo-a98c2a4542e19ce4fb632bc32fc47c18273161cc.tar.bz2
michelangelo-a98c2a4542e19ce4fb632bc32fc47c18273161cc.zip
convert from PHP to Go
Diffstat (limited to 'static')
-rw-r--r--static/app.css299
-rw-r--r--static/app.js300
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, '&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();
+
+})();