// Responsive like / repost / follow — progressive enhancement over the plain forms.
//
// THE PROBLEM. Every one of these is a real <form method="POST"> that redirects back to `return`. That is
// what makes them work with scripting off, and it stays the fallback — but with JS on it means a FULL PAGE
// RELOAD to toggle a heart: the timeline is re-fetched and re-rendered, scroll position is lost, and on a
// slow P2P read the whole page stalls behind one click. The affordance felt broken even though it worked.
//
// The server already speaks JSON on these routes (`content-type: application/json` -> {ok, liked,
// likeCount} / {ok, reposted, repostCount} / {ok, following}) instead of redirecting. Nothing server-side
// was needed; only this.
//
// OPTIMISTIC, THEN RECONCILED. The button flips immediately — a P2P write can take a moment and a control
// that does nothing for 400ms reads as broken. If the request fails we put the ORIGINAL state back and say
// so, rather than leaving a lie on screen. The server's returned count is authoritative and overwrites the
// optimistic guess, so a concurrent like from another device self-corrects on the next click.
//
// Delegated on document, so posts swapped in by the live refresh keep working with nothing to re-bind.
import { toast } from './toast.js'
// Naming the action in the message matters: "failed" on its own does not tell you WHICH of three
// controls in a row it was, and the button has already reverted by the time you read it.
const LABEL = { '/api/like': 'like', '/api/repost': 'repost', '/api/follow': 'follow' }
const ROUTES = {
'/api/like': { btn: '.likebtn', on: 'liked', count: 'likeCount', countEl: '.likes', active: '♥', idle: '♡' },
'/api/repost': { btn: '.rpbtn', on: 'reposted', count: 'repostCount', countEl: '.rps', active: '⇄', idle: '⇄' },
'/api/follow': { btn: '.followbtn', on: 'following', count: null, countEl: null, active: 'following ✓', idle: 'follow' }
}
function setState (btn, spec, isOn, count) {
btn.classList.toggle('on', !!isOn)
if (spec.active !== spec.idle || spec.count === null) btn.textContent = isOn ? spec.active : spec.idle
btn.title = spec.count === null ? (isOn ? 'unfollow' : 'follow') : (isOn ? 'undo' : 'do')
btn.setAttribute('aria-pressed', isOn ? 'true' : 'false')
if (!spec.countEl) return
// The count lives OUTSIDE the button, as a sibling in the same form. Absent at zero by design, so it has
// to be created when a count appears and removed when it returns to zero — otherwise a freshly-liked
// post shows a heart with no number, or a stale "0" lingers.
const form = btn.closest('form')
if (!form) return
let el = form.querySelector(spec.countEl)
const n = Number(count)
if (!Number.isFinite(n) || n <= 0) { if (el) el.remove(); return }
if (!el) {
el = document.createElement('span')
el.className = spec.countEl.slice(1)
btn.insertAdjacentElement('afterend', el)
}
el.textContent = String(n)
}
document.addEventListener('submit', async (e) => {
const form = e.target
if (!form || !form.action) return
const path = new URL(form.action, location.href).pathname
const spec = ROUTES[path]
if (!spec) return
const btn = form.querySelector(spec.btn)
if (!btn) return
e.preventDefault()
const data = new FormData(form)
const wasOn = btn.classList.contains('on')
const countEl = spec.countEl ? form.querySelector(spec.countEl) : null
const wasCount = countEl ? Number(countEl.textContent) || 0 : 0
// Optimistic flip. A P2P write is not instant and the click must feel like it landed.
setState(btn, spec, !wasOn, wasOn ? wasCount - 1 : wasCount + 1)
btn.disabled = true
try {
const body = {}
for (const [k, v] of data.entries()) body[k] = v
const res = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
})
const out = await res.json().catch(() => null)
if (!res.ok || !out || out.ok !== true) throw new Error((out && out.error) || ('HTTP ' + res.status))
// The server's answer wins over the optimistic guess.
setState(btn, spec, out[spec.on], spec.count ? out[spec.count] : null)
} catch (err) {
setState(btn, spec, wasOn, wasCount) // put it back — never leave a lie on screen
btn.classList.add('failed')
const why = String((err && err.message) || 'failed')
btn.title = why
// ...and SAY it. The flash + title alone meant a failed write was invisible on touch (no hover) and
// to assistive tech, so a revert read as "my tap didn't register" and people tapped again.
toast(`${LABEL[path] || 'action'} failed — ${why}`)
setTimeout(() => btn.classList.remove('failed'), 1200)
} finally {
btn.disabled = false
}
})
// ── lightbox ─────────────────────────────────────────────────────────────────────────────────────
// Each image is a real <a> to its own bytes, so with no JS a click opens the full-size picture — already
// better than the inline-only view. With JS we keep the reader on the page instead.
//
// It must be a link regardless: the whole post is a stretched anchor to its thread, so an image with no
// element of its own would open the thread rather than the picture.
let lb = null
function closeLb () {
if (!lb) return
lb.remove(); lb = null
document.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
function onKey (e) { if (e.key === 'Escape') closeLb() }
document.addEventListener('click', (e) => {
const a = e.target.closest && e.target.closest('a.lb')
if (!a) return
// Let the browser do its normal thing for new-tab/window intents — never hijack a modified click.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
e.preventDefault()
e.stopPropagation() // do not also trigger the post's whole-entry navigation
closeLb()
lb = document.createElement('div')
lb.className = 'lbox'
lb.setAttribute('role', 'dialog')
lb.setAttribute('aria-modal', 'true')
const alt = a.getAttribute('data-alt') || ''
lb.setAttribute('aria-label', alt || 'image')
const img = document.createElement('img')
img.src = a.getAttribute('href')
img.alt = alt
lb.appendChild(img)
// ALT TEXT IS SHOWN, not just carried in the attribute. Someone wrote it to describe the image; a
// viewer is the one place there is room to actually display it.
if (alt) {
const cap = document.createElement('p')
cap.className = 'lbalt'
cap.textContent = alt
lb.appendChild(cap)
}
const close = document.createElement('button')
close.className = 'lbclose'
close.setAttribute('aria-label', 'close')
close.textContent = '✕'
lb.appendChild(close)
// Click anywhere outside the image closes — the expected gesture, and the only one available on touch.
lb.addEventListener('click', (ev) => { if (ev.target !== img) closeLb() })
document.body.appendChild(lb)
document.body.style.overflow = 'hidden' // no scrolling the page behind the overlay
document.addEventListener('keydown', onKey)
close.focus()
})