Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

web: properly paginate replies

+218 -97
+34 -6
core/records.py
··· 3 3 Framework-agnostic. Used by both web and TUI. 4 4 """ 5 5 6 + from dataclasses import dataclass 7 + 6 8 import httpx 7 9 8 10 from core import lexicon ··· 47 49 return threads, backlinks.cursor 48 50 49 51 52 + @dataclass 53 + class RepliesPage: 54 + """A page of hydrated replies with pagination info.""" 55 + 56 + replies: list[Reply] 57 + page: int 58 + total_pages: int 59 + total_replies: int 60 + 61 + 50 62 async def hydrate_replies( 51 63 client: httpx.AsyncClient, 52 64 bbs: BBS, 53 65 thread: Thread, 54 - cursor: str | None = None, 55 - ) -> tuple[list[Reply], str | None]: 56 - """Fetch and hydrate replies for a thread.""" 57 - backlinks = await get_replies(client, thread.uri, cursor=cursor) 58 - records = await get_records_batch(client, backlinks.records) 66 + page: int = 1, 67 + page_size: int = 10, 68 + ) -> RepliesPage: 69 + """Fetch all reply refs, then hydrate only the requested page (oldest first).""" 70 + # Fetch all refs (cheap — just did/collection/rkey) 71 + backlinks = await get_replies(client, thread.uri, limit=1000) 72 + all_refs = list(reversed(backlinks.records)) # oldest first 73 + 74 + total = len(all_refs) 75 + total_pages = max(1, (total + page_size - 1) // page_size) 76 + page = max(1, min(page, total_pages)) 77 + 78 + # Slice the page we need 79 + start = (page - 1) * page_size 80 + page_refs = all_refs[start : start + page_size] 81 + 82 + if not page_refs: 83 + return RepliesPage(replies=[], page=page, total_pages=total_pages, total_replies=total) 84 + 85 + # Hydrate only this page 86 + records = await get_records_batch(client, page_refs) 59 87 records = filter_moderated(records, bbs.site.banned_dids, bbs.site.hidden_posts) 60 88 61 89 parsed = {r.uri: AtUri.parse(r.uri) for r in records} ··· 77 105 if parsed[r.uri].did in authors 78 106 ] 79 107 replies.sort(key=lambda t: t.created_at) 80 - return replies, backlinks.cursor 108 + return RepliesPage(replies=replies, page=page, total_pages=total_pages, total_replies=total) 81 109 82 110 83 111 async def _try_refresh_token(client, session, session_updater):
+1 -1
justfile
··· 4 4 #!/bin/sh 5 5 trap 'kill 0' EXIT 6 6 npx @tailwindcss/cli -i web/static/input.css -o web/static/style.css --watch & 7 - npx esbuild web/ts/main.ts --bundle --outfile=web/static/app.js --watch & 7 + npx esbuild web/ts/main.ts --bundle --outfile=web/static/app.js --watch=forever & 8 8 PUBLIC_URL=http://localhost:5000 QUART_DEBUG=1 uv run quart --app "web.app:create_app()" run --reload & 9 9 wait 10 10
+15 -48
tui/screens/thread.py
··· 25 25 self.bbs = bbs 26 26 self.handle = handle 27 27 self.thread = thread 28 - self.next_cursor: str | None = None 28 + self._page: int = 1 29 + self._total_pages: int = 1 29 30 self._replies_map: dict[str, object] = {} 30 31 31 32 def compose(self) -> ComposeResult: ··· 63 64 self.load_replies() 64 65 65 66 @work(exclusive=True) 66 - async def load_replies(self, cursor: str | None = None) -> None: 67 + async def load_replies(self, page: int = 1) -> None: 67 68 client = self.app.http_client 68 69 try: 69 - replies, self.next_cursor = await fetch_replies( 70 + result = await fetch_replies( 70 71 client, 71 72 self.bbs, 72 73 self.thread, 73 - cursor=cursor, 74 + page=page, 74 75 ) 75 76 except Exception: 76 77 self.notify("Could not fetch replies.", severity="error") 77 78 return 78 79 80 + self._page = result.page 81 + self._total_pages = result.total_pages 79 82 scroll = self.query_one("#thread-scroll") 80 83 81 84 # Store for quote lookup 82 - for r in replies: 85 + for r in result.replies: 83 86 self._replies_map[r.uri] = r 84 87 85 - for r in replies: 88 + for r in result.replies: 86 89 quote_text = None 87 90 if r.quote and r.quote in self._replies_map: 88 91 q = self._replies_map[r.quote] ··· 103 106 ) 104 107 ) 105 108 106 - if self.next_cursor: 107 - await scroll.mount(Button("next page →", id="next-page")) 109 + if self._page < self._total_pages: 110 + await scroll.mount(Button(f"page {self._page + 1} of {self._total_pages} →", id="next-page")) 108 111 109 112 def refresh_data(self) -> None: 110 113 self._do_refresh() ··· 119 122 except Exception: 120 123 pass 121 124 122 - client = self.app.http_client 123 - try: 124 - replies, self.next_cursor = await fetch_replies( 125 - client, 126 - self.bbs, 127 - self.thread, 128 - ) 129 - except Exception: 130 - self.notify("Could not fetch replies.", severity="error") 131 - return 132 - 133 - scroll = self.query_one("#thread-scroll") 134 125 self._replies_map.clear() 135 - for r in replies: 136 - self._replies_map[r.uri] = r 137 - 138 - for r in replies: 139 - quote_text = None 140 - if r.quote and r.quote in self._replies_map: 141 - q = self._replies_map[r.quote] 142 - body_preview = q.body[:200] + ("..." if len(q.body) > 200 else "") 143 - quote_text = f"{q.author.handle}: {body_preview}" 144 - 145 - await scroll.mount( 146 - Post( 147 - author=r.author.handle, 148 - date=r.created_at, 149 - body=r.body, 150 - author_did=r.author.did, 151 - author_pds=r.author.pds, 152 - record_uri=r.uri, 153 - collection=lexicon.REPLY, 154 - attachments=r.attachments, 155 - quote_text=quote_text, 156 - ) 157 - ) 158 - 159 - if self.next_cursor: 160 - await scroll.mount(Button("next page →", id="next-page")) 126 + self._page = 1 127 + self.load_replies(page=1) 161 128 162 129 def on_button_pressed(self, event: Button.Pressed) -> None: 163 - if event.button.id == "next-page" and self.next_cursor: 130 + if event.button.id == "next-page" and self._page < self._total_pages: 164 131 event.button.remove() 165 - self.load_replies(cursor=self.next_cursor) 132 + self.load_replies(page=self._page + 1) 166 133 167 134 def action_reply(self) -> None: 168 135 session = require_session(self)
+9 -9
web/routes.py
··· 243 243 @bp.route("/api/replies/<did>/<tid>") 244 244 async def api_replies(did: str, tid: str): 245 245 client = current_app.http_client 246 - cursor = request.args.get("cursor") 246 + page = int(request.args.get("page", 1)) 247 247 handle = request.args.get("handle", "") 248 248 249 249 try: ··· 257 257 if bbs: 258 258 banned = await check_banned(bbs) 259 259 if banned: 260 - return {"replies": [], "cursor": None, "banned": True} 260 + return {"replies": [], "page": 1, "total_pages": 1, "total_replies": 0} 261 261 262 262 if not bbs: 263 - return {"replies": [], "cursor": None} 263 + return {"replies": [], "page": 1, "total_pages": 1, "total_replies": 0} 264 264 265 265 # Build a minimal Thread object for hydrate_replies 266 266 thread_uri = str(AtUri(did, lexicon.THREAD, tid)) ··· 276 276 ) 277 277 278 278 try: 279 - replies, next_cursor = await hydrate_replies( 280 - client, bbs, dummy_thread, cursor=cursor 281 - ) 279 + result = await hydrate_replies(client, bbs, dummy_thread, page=page) 282 280 except Exception: 283 - return {"replies": [], "cursor": None} 281 + return {"replies": [], "page": 1, "total_pages": 1, "total_replies": 0} 284 282 285 283 return { 286 284 "replies": [ ··· 295 293 "attachments": r.attachments or [], 296 294 "quote": r.quote, 297 295 } 298 - for r in replies 296 + for r in result.replies 299 297 ], 300 - "cursor": next_cursor, 298 + "page": result.page, 299 + "total_pages": result.total_pages, 300 + "total_replies": result.total_replies, 301 301 }
+8 -8
web/static/app.js
··· 1 - "use strict";(()=>{function d(e){let t=document.createElement("span");return t.textContent=e,t.innerHTML}function y(e){let t=new Date(e),n=r=>String(r).padStart(2,"0");return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}`}function f(e){let t=new Date(e),n=Math.floor((Date.now()-t.getTime())/1e3);return n<60?"just now":n<3600?`${Math.floor(n/60)}m ago`:n<86400?`${Math.floor(n/3600)}h ago`:n<604800?`${Math.floor(n/86400)}d ago`:y(e)}function u(e,t){return document.getElementById(e)?.dataset[t]??""}function k(){document.querySelectorAll(".localtime").forEach(e=>{let t=e.getAttribute("datetime");t&&(e.textContent=f(t),e.setAttribute("title",y(t)))})}async function g(e){let t=await fetch(e);if(!t.ok)throw new Error(`${t.status}`);return t.json()}var T="https://slingshot.microcosm.blue/xrpc",S="https://constellation.microcosm.blue/xrpc";function h(e){let t=e.split("/");return{did:t[2],collection:t[3],rkey:t[4]}}async function N(e){return g(`${T}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(e)}`)}async function b(e){let t=[...new Set(e)],n=await Promise.allSettled(t.map(N)),r={};for(let o of n)o.status==="fulfilled"&&(r[o.value.did]=o.value);return r}async function j(e,t,n){return g(`${T}/com.atproto.repo.getRecord?repo=${encodeURIComponent(e)}&collection=${encodeURIComponent(t)}&rkey=${encodeURIComponent(n)}`)}async function F(e){return(await Promise.allSettled(e.map(n=>j(n.did,n.collection,n.rkey)))).filter(n=>n.status==="fulfilled").map(n=>n.value)}async function K(e,t,n=25,r){let o=`${S}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(e)}&source=${encodeURIComponent(t)}&limit=${n}`;return r&&(o+=`&cursor=${encodeURIComponent(r)}`),g(o)}async function R(e,t,n){let r=n?.limit??50,o=await K(e,t,r,n?.cursor);if(!o.records.length)return{records:[],cursor:null};let s=(await F(o.records)).filter(c=>{let{did:a}=h(c.uri);return!(n?.excludeDid&&a===n.excludeDid||n?.bannedDids?.has(a)||n?.hiddenPosts?.has(c.uri))});if(!s.length)return{records:[],cursor:o.cursor??null};let l=s.map(c=>h(c.uri).did),m=await b(l);return{records:s.filter(c=>h(c.uri).did in m).map(c=>{let a=h(c.uri),p=m[a.did];return{uri:c.uri,did:a.did,rkey:a.rkey,handle:p.handle,pds:p.pds??"",value:c.value}}),cursor:o.cursor??null}}async function v(e,t,n,r=20){try{return(await g(`${e}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(t)}&collection=${encodeURIComponent(n)}&limit=${r}`)).records}catch{return[]}}var L="xyz.atboards.site";var B="xyz.atboards.thread",I="xyz.atboards.reply";function M(){let e=document.getElementById("handle-form");e.addEventListener("submit",t=>{t.preventDefault();let r=e.elements.namedItem("handle").value.trim();r&&(window.location.href="/bbs/"+encodeURIComponent(r))}),O()}async function O(){try{let e=await g(`https://ufos-api.microcosm.blue/records?collection=${L}&limit=50`);if(!e.length)return;e.length>5&&(e=e.sort(()=>Math.random()-.5).slice(0,5));let t=e.map(o=>o.did),n=await b(t),r=document.getElementById("discover-list");if(!r)return;for(let o of e){if(!(o.did in n))continue;let i=n[o.did].handle,s=o.record.name||i,l=o.record.description||"",m=document.createElement("a");m.href="/bbs/"+encodeURIComponent(i),m.className="flex items-baseline gap-3 px-3 py-2 -mx-3 rounded hover:bg-neutral-900 group",m.innerHTML=`<span class="text-neutral-200 group-hover:text-white">${d(s)}</span><span class="text-neutral-500">${d(l)}</span>`,r.appendChild(m)}document.getElementById("discover")?.classList.remove("hidden")}catch{}}function $(e){let t=null;function n(o){let i=e.fetchUrl(o);g(i).then(s=>{let l=document.getElementById(e.containerId),m=document.getElementById(e.loadingId);if(!l)return;m&&m.remove(),e.onData?.(s);let x=s[e.dataKey];for(let a of x){let p=e.renderItem(a);typeof p=="string"?l.insertAdjacentHTML("beforeend",p):l.appendChild(p)}t=s.cursor??null;let c=document.getElementById(e.nextContainerId);c&&c.classList.toggle("hidden",!t),l.children.length===0&&e.emptyMessage&&(l.innerHTML=`<p class="text-neutral-500">${e.emptyMessage}</p>`)}).catch(()=>{let s=document.getElementById(e.loadingId);s&&(s.textContent="Could not fetch data.")})}let r=document.getElementById(e.loadMoreId);r&&r.addEventListener("click",()=>{t&&n(t)}),n(null)}function w(){let e=u("threads","handle"),t=u("threads","slug");$({fetchUrl:n=>{let r=`/api/threads/${encodeURIComponent(e)}/${encodeURIComponent(t)}`;return n&&(r+=`?cursor=${encodeURIComponent(n)}`),r},containerId:"threads",loadingId:"threads-loading",nextContainerId:"threads-next",loadMoreId:"load-more",dataKey:"threads",emptyMessage:"No threads yet.",renderItem:n=>{let r=document.createElement("a");return r.href=`/bbs/${e}/thread/${n.did}/${n.rkey}`,r.className="flex items-baseline justify-between gap-4 px-3 py-2.5 -mx-3 rounded hover:bg-neutral-900 group",r.innerHTML=`<span class="text-neutral-300 group-hover:text-white truncate">${d(n.title)}</span><span class="shrink-0 text-xs text-neutral-500">${d(n.handle)} \xB7 ${f(n.created_at)}</span>`,r}})}var E={};function z(e,t){let n=document.getElementById("quote-uri"),r=document.getElementById("quote-preview-text");n&&(n.value=e),r&&(r.textContent=`quoting ${t}`),document.getElementById("quote-preview")?.classList.remove("hidden"),document.getElementById("reply-body")?.focus()}function J(){document.getElementById("quote-uri").value="",document.getElementById("quote-preview")?.classList.add("hidden")}function Y(e,t,n,r,o,i){let s=[];return t&&s.push(`<button type="button" class="quote-btn text-xs text-neutral-500 hover:text-neutral-300" data-uri="${e.uri}" data-handle="${d(e.handle)}">quote</button>`),t&&t===e.did&&s.push(`<form method="post" action="/bbs/${r}/thread/${o}/${i}/reply/${e.rkey}/delete" class="inline" onsubmit="return confirm('Delete this reply?')"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">delete</button></form>`),t&&t===n&&t!==e.did&&s.push(`<form method="post" action="/bbs/${r}/ban/${e.did}" class="inline" onsubmit="return confirm('Ban this user from your BBS?')"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">ban</button></form>`),t&&t===n&&s.push(`<form method="post" action="/bbs/${r}/hide" class="inline" onsubmit="return confirm('Hide this post?')"><input type="hidden" name="uri" value="${e.uri}"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">hide</button></form>`),s.length?`<span class="reply-actions flex items-center gap-3">${s.join(" ")}</span>`:""}function Q(e){if(!e.quote||!E[e.quote])return"";let t=E[e.quote],n=t.body.substring(0,200)+(t.body.length>200?"...":"");return`<div class="border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-sm text-neutral-500"><span class="text-neutral-400">${d(t.handle)}:</span> ${d(n)}</div>`}function G(e){return(e.attachments||[]).map(t=>`<a href="${e.pds_url}/xrpc/com.atproto.sync.getBlob?did=${e.did}&cid=${t.file.ref.$link}" target="_blank" class="text-xs text-neutral-500 hover:text-neutral-300 block mt-1">[${d(t.name)}]</a>`).join("")}function W(e,t,n,r,o,i){return`<div class="reply-card border border-neutral-800/50 rounded p-4"> 1 + "use strict";(()=>{function u(e){let t=document.createElement("span");return t.textContent=e,t.innerHTML}function x(e){let t=new Date(e),n=r=>String(r).padStart(2,"0");return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}`}function h(e){let t=new Date(e),n=Math.floor((Date.now()-t.getTime())/1e3);return n<60?"just now":n<3600?`${Math.floor(n/60)}m ago`:n<86400?`${Math.floor(n/3600)}h ago`:n<604800?`${Math.floor(n/86400)}d ago`:x(e)}function p(e,t){return document.getElementById(e)?.dataset[t]??""}function L(){document.querySelectorAll(".localtime").forEach(e=>{let t=e.getAttribute("datetime");t&&(e.textContent=h(t),e.setAttribute("title",x(t)))})}async function f(e){let t=await fetch(e);if(!t.ok)throw new Error(`${t.status}`);return t.json()}var w="https://slingshot.microcosm.blue/xrpc",j="https://constellation.microcosm.blue/xrpc";function b(e){let t=e.split("/");return{did:t[2],collection:t[3],rkey:t[4]}}async function F(e){return f(`${w}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(e)}`)}async function y(e){let t=[...new Set(e)],n=await Promise.allSettled(t.map(F)),r={};for(let s of n)s.status==="fulfilled"&&(r[s.value.did]=s.value);return r}async function O(e,t,n){return f(`${w}/com.atproto.repo.getRecord?repo=${encodeURIComponent(e)}&collection=${encodeURIComponent(t)}&rkey=${encodeURIComponent(n)}`)}async function K(e){return(await Promise.allSettled(e.map(n=>O(n.did,n.collection,n.rkey)))).filter(n=>n.status==="fulfilled").map(n=>n.value)}async function z(e,t,n=25,r){let s=`${j}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(e)}&source=${encodeURIComponent(t)}&limit=${n}`;return r&&(s+=`&cursor=${encodeURIComponent(r)}`),f(s)}async function v(e,t,n){let r=n?.limit??50,s=await z(e,t,r,n?.cursor);if(!s.records.length)return{records:[],cursor:null};let o=(await K(s.records)).filter(c=>{let{did:i}=b(c.uri);return!(n?.excludeDid&&i===n.excludeDid||n?.bannedDids?.has(i)||n?.hiddenPosts?.has(c.uri))});if(!o.length)return{records:[],cursor:s.cursor??null};let a=o.map(c=>b(c.uri).did),l=await y(a);return{records:o.filter(c=>b(c.uri).did in l).map(c=>{let i=b(c.uri),g=l[i.did];return{uri:c.uri,did:i.did,rkey:i.rkey,handle:g.handle,pds:g.pds??"",value:c.value}}),cursor:s.cursor??null}}async function R(e,t,n,r=20){try{return(await f(`${e}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(t)}&collection=${encodeURIComponent(n)}&limit=${r}`)).records}catch{return[]}}var T="xyz.atboards.site";var B="xyz.atboards.thread",I="xyz.atboards.reply";function k(){let e=document.getElementById("handle-form");e.addEventListener("submit",t=>{t.preventDefault();let r=e.elements.namedItem("handle").value.trim();r&&(window.location.href="/bbs/"+encodeURIComponent(r))}),J()}async function J(){try{let e=await f(`https://ufos-api.microcosm.blue/records?collection=${T}&limit=50`);if(!e.length)return;e.length>5&&(e=e.sort(()=>Math.random()-.5).slice(0,5));let t=e.map(s=>s.did),n=await y(t),r=document.getElementById("discover-list");if(!r)return;for(let s of e){if(!(s.did in n))continue;let d=n[s.did].handle,o=s.record.name||d,a=s.record.description||"",l=document.createElement("a");l.href="/bbs/"+encodeURIComponent(d),l.className="flex items-baseline gap-3 px-3 py-2 -mx-3 rounded hover:bg-neutral-900 group",l.innerHTML=`<span class="text-neutral-200 group-hover:text-white">${u(o)}</span><span class="text-neutral-500">${u(a)}</span>`,r.appendChild(l)}document.getElementById("discover")?.classList.remove("hidden")}catch{}}function M(e){let t=null;function n(s){let d=e.fetchUrl(s);f(d).then(o=>{let a=document.getElementById(e.containerId),l=document.getElementById(e.loadingId);if(!a)return;l&&l.remove(),e.onData?.(o);let m=o[e.dataKey];for(let i of m){let g=e.renderItem(i);typeof g=="string"?a.insertAdjacentHTML("beforeend",g):a.appendChild(g)}t=o.cursor??null;let c=document.getElementById(e.nextContainerId);c&&c.classList.toggle("hidden",!t),a.children.length===0&&e.emptyMessage&&(a.innerHTML=`<p class="text-neutral-500">${e.emptyMessage}</p>`)}).catch(()=>{let o=document.getElementById(e.loadingId);o&&(o.textContent="Could not fetch data.")})}let r=document.getElementById(e.loadMoreId);r&&r.addEventListener("click",()=>{t&&n(t)}),n(null)}function H(){let e=p("threads","handle"),t=p("threads","slug");M({fetchUrl:n=>{let r=`/api/threads/${encodeURIComponent(e)}/${encodeURIComponent(t)}`;return n&&(r+=`?cursor=${encodeURIComponent(n)}`),r},containerId:"threads",loadingId:"threads-loading",nextContainerId:"threads-next",loadMoreId:"load-more",dataKey:"threads",emptyMessage:"No threads yet.",renderItem:n=>{let r=document.createElement("a");return r.href=`/bbs/${e}/thread/${n.did}/${n.rkey}`,r.className="flex items-baseline justify-between gap-4 px-3 py-2.5 -mx-3 rounded hover:bg-neutral-900 group",r.innerHTML=`<span class="text-neutral-300 group-hover:text-white truncate">${u(n.title)}</span><span class="shrink-0 text-xs text-neutral-500">${u(n.handle)} \xB7 ${h(n.created_at)}</span>`,r}})}var $={};function Y(e,t){let n=document.getElementById("quote-uri"),r=document.getElementById("quote-preview-text");n&&(n.value=e),r&&(r.textContent=`quoting ${t}`),document.getElementById("quote-preview")?.classList.remove("hidden"),document.getElementById("reply-body")?.focus()}function Q(){document.getElementById("quote-uri").value="",document.getElementById("quote-preview")?.classList.add("hidden")}function G(e,t,n,r,s,d){let o=[];return t&&o.push(`<button type="button" class="quote-btn text-xs text-neutral-500 hover:text-neutral-300" data-uri="${e.uri}" data-handle="${u(e.handle)}">quote</button>`),t&&t===e.did&&o.push(`<form method="post" action="/bbs/${r}/thread/${s}/${d}/reply/${e.rkey}/delete" class="inline" onsubmit="return confirm('Delete this reply?')"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">delete</button></form>`),t&&t===n&&t!==e.did&&o.push(`<form method="post" action="/bbs/${r}/ban/${e.did}" class="inline" onsubmit="return confirm('Ban this user from your BBS?')"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">ban</button></form>`),t&&t===n&&o.push(`<form method="post" action="/bbs/${r}/hide" class="inline" onsubmit="return confirm('Hide this post?')"><input type="hidden" name="uri" value="${e.uri}"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">hide</button></form>`),o.length?`<span class="reply-actions flex items-center gap-3">${o.join(" ")}</span>`:""}function V(e){if(!e.quote||!$[e.quote])return"";let t=$[e.quote],n=t.body.substring(0,200)+(t.body.length>200?"...":"");return`<div class="border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-sm text-neutral-500"><span class="text-neutral-400">${u(t.handle)}:</span> ${u(n)}</div>`}function W(e){return(e.attachments||[]).map(t=>`<a href="${e.pds_url}/xrpc/com.atproto.sync.getBlob?did=${e.did}&cid=${t.file.ref.$link}" target="_blank" class="text-xs text-neutral-500 hover:text-neutral-300 block mt-1">[${u(t.name)}]</a>`).join("")}function X(e,t,n,r,s,d){return`<div class="reply-card border border-neutral-800/50 rounded p-4"> 2 2 <div class="flex items-baseline justify-between mb-2"> 3 3 <div class="flex items-baseline gap-2"> 4 - <span class="text-neutral-300">${d(e.handle)}</span> 4 + <span class="text-neutral-300">${u(e.handle)}</span> 5 5 <span class="text-neutral-600">&middot;</span> 6 - <span class="text-xs text-neutral-500" title="${y(e.created_at)}">${f(e.created_at)}</span> 6 + <span class="text-xs text-neutral-500" title="${x(e.created_at)}">${h(e.created_at)}</span> 7 7 </div> 8 - ${Y(e,o,i,t,n,r)} 8 + ${G(e,s,d,t,n,r)} 9 9 </div> 10 - ${Q(e)} 11 - <p class="text-neutral-400 whitespace-pre-wrap leading-relaxed">${d(e.body)}</p> 12 - ${G(e)} 13 - </div>`}function D(){let e=u("replies","threadDid"),t=u("replies","threadTid"),n=u("replies","handle"),r=u("replies","userDid"),o=u("replies","sysopDid");document.getElementById("quote-clear")?.addEventListener("click",J),document.getElementById("replies")?.addEventListener("click",i=>{let s=i.target.closest(".quote-btn");s&&z(s.dataset.uri,s.dataset.handle)}),$({fetchUrl:i=>{let s=`/api/replies/${e}/${t}?handle=${encodeURIComponent(n)}`;return i&&(s+=`&cursor=${encodeURIComponent(i)}`),s},containerId:"replies",loadingId:"replies-loading",nextContainerId:"replies-next",loadMoreId:"load-more",dataKey:"replies",emptyMessage:r?void 0:"No replies yet.",onData:i=>{let s=i.replies;for(let l of s)E[l.uri]=l},renderItem:i=>W(i,n,e,t,r,o)})}var C=50;function V(){let e=["inbox","bbs"];document.querySelectorAll(".tab-btn[data-tab]").forEach(t=>{t.addEventListener("click",()=>{let n=t.dataset.tab;document.querySelectorAll(".tab-btn").forEach(r=>{r.classList.remove("text-neutral-200","border-neutral-200"),r.classList.add("text-neutral-500","border-transparent")}),t.classList.remove("text-neutral-500","border-transparent"),t.classList.add("text-neutral-200","border-neutral-200");for(let r of e)document.getElementById(`panel-${r}`)?.classList.toggle("hidden",r!==n)})})}function X(e,t){let{did:n,rkey:r}=h(e.thread_uri),o=document.createElement("a");o.href=`/bbs/${t}/thread/${n}/${r}`,o.className="block border border-neutral-800/50 rounded p-4 mb-2 hover:bg-neutral-900";let i=e.type==="quote"?"quoted your reply":"on: "+d(e.thread_title);return o.innerHTML=`<div class="flex items-baseline justify-between mb-1"><span class="text-neutral-300">${d(e.handle)}</span><span class="text-xs text-neutral-500">${f(e.created_at)}</span></div><p class="text-xs text-neutral-500 mb-1">${i}</p><p class="text-neutral-400">${d(e.body)}</p>`,o}function A(e,t,n,r,o){return e.map(i=>({type:t,thread_title:n,thread_uri:r,handle:i.handle,body:(i.value.body??"").substring(0,200),created_at:i.value.createdAt??"",bbs_handle:o}))}function Z(e){let t=new Map;for(let n of e){let r=n.handle+n.body+n.created_at;(!t.has(r)||n.type==="quote")&&t.set(r,n)}return[...t.values()].sort((n,r)=>r.created_at.localeCompare(n.created_at))}async function ee(e,t,n){let r=document.getElementById("inbox"),o=document.getElementById("inbox-loading");try{let[i,s]=await Promise.all([v(t,e,B,C),v(t,e,I,C)]),l=[...new Set(i.map(a=>a.value.board??"").filter(Boolean).map(a=>h(a).did))],m=l.length?await b(l):{},x=await Promise.all([...i.map(async a=>{let p=a.value.board??"",U=p?h(p).did:e,_=m[U]?.handle??"";try{let{records:P}=await R(a.uri,`${I}:subject`,{limit:50,excludeDid:e});return A(P,"reply",a.value.title??"",a.uri,_)}catch{return[]}}),...s.map(async a=>{try{let{records:p}=await R(a.uri,`${I}:quote`,{limit:50,excludeDid:e});return A(p,"quote","",a.value.subject??"","")}catch{return[]}})]),c=Z(x.flat());if(o&&o.remove(),!c.length){r.innerHTML='<p class="text-neutral-500">No messages yet.</p>';return}for(let a of c.slice(0,50))r.appendChild(X(a,n))}catch{o&&(o.textContent="Failed to fetch messages.")}}function q(){V();let e=u("inbox","handle"),t=u("inbox","did"),n=u("inbox","pds");t&&n&&ee(t,n,e)}k();document.getElementById("handle-form")&&M();document.getElementById("threads")&&w();document.getElementById("replies")&&D();document.getElementById("inbox")&&q();})(); 10 + ${V(e)} 11 + <p class="text-neutral-400 whitespace-pre-wrap leading-relaxed">${u(e.body)}</p> 12 + ${W(e)} 13 + </div>`}function Z(e,t,n){let r=document.createElement("div");r.className="flex items-center justify-center gap-2 text-sm w-full";function s(l,m,c=!1){let i=document.createElement("button");i.textContent=l,c?i.className="text-neutral-200 bg-neutral-800 rounded px-3 py-1":m!==null?(i.className="text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 rounded px-3 py-1",i.addEventListener("click",()=>n(m))):(i.className="text-neutral-600 px-2 py-1 cursor-default",i.disabled=!0),r.appendChild(i)}e>1&&s("\u2190",e-1);let d=2,o=Math.max(1,e-d),a=Math.min(t,e+d);a-o<4&&(o===1?a=Math.min(t,o+4):a===t&&(o=Math.max(1,a-4))),o>1&&(s("1",1),o>2&&s("...",null));for(let l=o;l<=a;l++)s(String(l),l,l===e);return a<t&&(a<t-1&&s("...",null),s(String(t),t)),e<t&&s("\u2192",e+1),r}function ee(e,t,n){for(let r of["replies-nav-top","replies-nav-bottom"]){let s=document.getElementById(r);s&&(s.innerHTML="",s.appendChild(Z(e,t,n)),s.classList.remove("hidden"))}}function C(){for(let e of["replies-nav-top","replies-nav-bottom"])document.getElementById(e)?.classList.add("hidden")}async function E(e,t,n,r,s,d){let o=document.getElementById("replies"),a=document.getElementById("replies-loading");try{let l=await f(`/api/replies/${t}/${n}?handle=${encodeURIComponent(r)}&page=${e}`);a&&a.remove();for(let m of l.replies)$[m.uri]=m;for(let m of l.replies)o.insertAdjacentHTML("beforeend",X(m,r,t,n,s,d));if(l.total_pages>1){let m=c=>{o.innerHTML='<p id="replies-loading" class="text-neutral-500">Loading replies...</p>',C();let i=new URL(window.location.href);i.searchParams.set("page",String(c)),history.pushState(null,"",i.toString()),E(c,t,n,r,s,d),document.getElementById("replies-nav-top")?.scrollIntoView({behavior:"smooth"})};ee(l.page,l.total_pages,m)}o.children.length===0&&!s&&(o.innerHTML='<p class="text-neutral-500">No replies yet.</p>')}catch{a&&(a.textContent="Could not fetch replies.")}}function A(){let e=p("replies","threadDid"),t=p("replies","threadTid"),n=p("replies","handle"),r=p("replies","userDid"),s=p("replies","sysopDid");document.getElementById("quote-clear")?.addEventListener("click",Q),document.getElementById("replies")?.addEventListener("click",o=>{let a=o.target.closest(".quote-btn");a&&Y(a.dataset.uri,a.dataset.handle)});let d=parseInt(new URLSearchParams(window.location.search).get("page")??"1",10);E(d,e,t,n,r,s),window.addEventListener("popstate",()=>{let o=parseInt(new URLSearchParams(window.location.search).get("page")??"1",10),a=document.getElementById("replies");a.innerHTML='<p id="replies-loading" class="text-neutral-500">Loading replies...</p>',C(),E(o,e,t,n,r,s)})}var P=50;function te(){let e=["inbox","bbs"];document.querySelectorAll(".tab-btn[data-tab]").forEach(t=>{t.addEventListener("click",()=>{let n=t.dataset.tab;document.querySelectorAll(".tab-btn").forEach(r=>{r.classList.remove("text-neutral-200","border-neutral-200"),r.classList.add("text-neutral-500","border-transparent")}),t.classList.remove("text-neutral-500","border-transparent"),t.classList.add("text-neutral-200","border-neutral-200");for(let r of e)document.getElementById(`panel-${r}`)?.classList.toggle("hidden",r!==n)})})}function ne(e,t){let{did:n,rkey:r}=b(e.thread_uri),s=document.createElement("a");s.href=`/bbs/${t}/thread/${n}/${r}`,s.className="block border border-neutral-800/50 rounded p-4 mb-2 hover:bg-neutral-900";let d=e.type==="quote"?"quoted your reply":"on: "+u(e.thread_title);return s.innerHTML=`<div class="flex items-baseline justify-between mb-1"><span class="text-neutral-300">${u(e.handle)}</span><span class="text-xs text-neutral-500">${h(e.created_at)}</span></div><p class="text-xs text-neutral-500 mb-1">${d}</p><p class="text-neutral-400">${u(e.body)}</p>`,s}function _(e,t,n,r,s){return e.map(d=>({type:t,thread_title:n,thread_uri:r,handle:d.handle,body:(d.value.body??"").substring(0,200),created_at:d.value.createdAt??"",bbs_handle:s}))}function re(e){let t=new Map;for(let n of e){let r=n.handle+n.body+n.created_at;(!t.has(r)||n.type==="quote")&&t.set(r,n)}return[...t.values()].sort((n,r)=>r.created_at.localeCompare(n.created_at))}async function se(e,t,n){let r=document.getElementById("inbox"),s=document.getElementById("inbox-loading");try{let[d,o]=await Promise.all([R(t,e,B,P),R(t,e,I,P)]),a=[...new Set(d.map(i=>i.value.board??"").filter(Boolean).map(i=>b(i).did))],l=a.length?await y(a):{},m=await Promise.all([...d.map(async i=>{let g=i.value.board??"",U=g?b(g).did:e,q=l[U]?.handle??"";try{let{records:N}=await v(i.uri,`${I}:subject`,{limit:50,excludeDid:e});return _(N,"reply",i.value.title??"",i.uri,q)}catch{return[]}}),...o.map(async i=>{try{let{records:g}=await v(i.uri,`${I}:quote`,{limit:50,excludeDid:e});return _(g,"quote","",i.value.subject??"","")}catch{return[]}})]),c=re(m.flat());if(s&&s.remove(),!c.length){r.innerHTML='<p class="text-neutral-500">No messages yet.</p>';return}for(let i of c.slice(0,50))r.appendChild(ne(i,n))}catch{s&&(s.textContent="Failed to fetch messages.")}}function S(){te();let e=p("inbox","handle"),t=p("inbox","did"),n=p("inbox","pds");t&&n&&se(t,n,e)}L();document.getElementById("handle-form")&&k();document.getElementById("threads")&&H();document.getElementById("replies")&&A();document.getElementById("inbox")&&S();})();
+1
web/static/input.css
··· 1 1 @import "tailwindcss"; 2 2 @source "../../web/templates"; 3 + @source "../../web/ts"; 3 4 4 5 pre, code, kbd, samp { 5 6 font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+1 -1
web/static/style.css
··· 1 1 /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ 2 - @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-900:oklch(39.6% .141 25.723);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-bold:700;--tracking-wide:.025em;--leading-snug:1.375;--leading-relaxed:1.625;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.start{inset-inline-start:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.min-h-screen{min-height:100vh}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-700{border-color:var(--color-neutral-700)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-neutral-800\/50{border-color:#26262680}@supports (color:color-mix(in lab, red, red)){.border-neutral-800\/50{border-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}.border-transparent{border-color:#0000}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-950{background-color:var(--color-neutral-950)}.p-4{padding:calc(var(--spacing) * 4)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-red-500{color:var(--color-red-500)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-neutral-500::placeholder{color:var(--color-neutral-500)}@media (hover:hover){.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.hover\:border-neutral-700:hover{border-color:var(--color-neutral-700)}.hover\:border-red-900:hover{border-color:var(--color-red-900)}.hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.hover\:text-neutral-200:hover{color:var(--color-neutral-200)}.hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-neutral-600:focus{border-color:var(--color-neutral-600)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}pre,code,kbd,samp{font-family:Geist Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.reply-actions{display:none}.reply-card:hover .reply-actions{display:inline}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false} 2 + @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-900:oklch(39.6% .141 25.723);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-bold:700;--tracking-wide:.025em;--leading-snug:1.375;--leading-relaxed:1.625;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.min-h-screen{min-height:100vh}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-700{border-color:var(--color-neutral-700)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-neutral-800\/50{border-color:#26262680}@supports (color:color-mix(in lab, red, red)){.border-neutral-800\/50{border-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}.border-transparent{border-color:#0000}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-950{background-color:var(--color-neutral-950)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-red-500{color:var(--color-red-500)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-neutral-500::placeholder{color:var(--color-neutral-500)}@media (hover:hover){.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.hover\:border-neutral-700:hover{border-color:var(--color-neutral-700)}.hover\:border-red-900:hover{border-color:var(--color-red-900)}.hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.hover\:bg-neutral-800:hover{background-color:var(--color-neutral-800)}.hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.hover\:text-neutral-200:hover{color:var(--color-neutral-200)}.hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-neutral-600:focus{border-color:var(--color-neutral-600)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}pre,code,kbd,samp{font-family:Geist Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.reply-actions{display:none}.reply-card:hover .reply-actions{display:inline}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
+2 -3
web/templates/thread.html
··· 44 44 {% endif %} 45 45 </article> 46 46 47 + <div id="replies-nav-top" class="mb-4 text-center hidden"></div> 47 48 <div id="replies" class="space-y-2" 48 49 data-thread-did="{{ thread.author.did }}" 49 50 data-thread-tid="{{ thread.uri.split('/')[-1] }}" ··· 52 53 data-sysop-did="{{ bbs.identity.did }}"> 53 54 <p id="replies-loading" class="text-neutral-500">Loading replies...</p> 54 55 </div> 55 - <div id="replies-next" class="mt-6 text-center hidden"> 56 - <button id="load-more" class="text-neutral-500 hover:text-neutral-300">next page →</button> 57 - </div> 56 + <div id="replies-nav-bottom" class="mt-6 text-center hidden"></div> 58 57 59 58 {% if g.user %} 60 59 <form method="post" action="/bbs/{{ handle }}/thread/{{ thread.author.did }}/{{ thread.uri.split('/')[-1] }}/reply" enctype="multipart/form-data" class="mt-6 border border-neutral-800 rounded p-4">
+147 -21
web/ts/pages/thread.ts
··· 1 1 import { escapeHtml, relativeDate, formatFullDate, getData } from "../lib/util"; 2 - import { createPaginatedLoader } from "../lib/pagination"; 2 + import { fetchJson } from "../lib/api"; 3 3 import { REPLY } from "../lib/lexicon"; 4 4 5 5 interface Attachment { ··· 113 113 </div>`; 114 114 } 115 115 116 + // --- Data loading --- 117 + 118 + interface RepliesResponse { 119 + replies: ReplyItem[]; 120 + page: number; 121 + total_pages: number; 122 + total_replies: number; 123 + } 124 + 125 + function buildPageNav( 126 + current: number, 127 + total: number, 128 + goToPage: (page: number) => void, 129 + ): HTMLElement { 130 + const nav = document.createElement("div"); 131 + nav.className = "flex items-center justify-center gap-2 text-sm w-full"; 132 + 133 + function addBtn(label: string, page: number | null, active = false) { 134 + const btn = document.createElement("button"); 135 + btn.textContent = label; 136 + if (active) { 137 + btn.className = "text-neutral-200 bg-neutral-800 rounded px-3 py-1"; 138 + } else if (page !== null) { 139 + btn.className = "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 rounded px-3 py-1"; 140 + btn.addEventListener("click", () => goToPage(page)); 141 + } else { 142 + btn.className = "text-neutral-600 px-2 py-1 cursor-default"; 143 + btn.disabled = true; 144 + } 145 + nav.appendChild(btn); 146 + } 147 + 148 + // Prev 149 + if (current > 1) addBtn("←", current - 1); 150 + 151 + // Page numbers: show window of 5 around current 152 + const windowSize = 2; 153 + let start = Math.max(1, current - windowSize); 154 + let end = Math.min(total, current + windowSize); 155 + 156 + // Ensure at least 5 pages shown if available 157 + if (end - start < 4) { 158 + if (start === 1) end = Math.min(total, start + 4); 159 + else if (end === total) start = Math.max(1, end - 4); 160 + } 161 + 162 + if (start > 1) { 163 + addBtn("1", 1); 164 + if (start > 2) addBtn("...", null); 165 + } 166 + 167 + for (let i = start; i <= end; i++) { 168 + addBtn(String(i), i, i === current); 169 + } 170 + 171 + if (end < total) { 172 + if (end < total - 1) addBtn("...", null); 173 + addBtn(String(total), total); 174 + } 175 + 176 + // Next 177 + if (current < total) addBtn("→", current + 1); 178 + 179 + return nav; 180 + } 181 + 182 + function updateNavs( 183 + current: number, 184 + total: number, 185 + goToPage: (page: number) => void, 186 + ) { 187 + for (const id of ["replies-nav-top", "replies-nav-bottom"]) { 188 + const el = document.getElementById(id); 189 + if (!el) continue; 190 + el.innerHTML = ""; 191 + el.appendChild(buildPageNav(current, total, goToPage)); 192 + el.classList.remove("hidden"); 193 + } 194 + } 195 + 196 + function hideNavs() { 197 + for (const id of ["replies-nav-top", "replies-nav-bottom"]) { 198 + document.getElementById(id)?.classList.add("hidden"); 199 + } 200 + } 201 + 202 + async function loadReplyPage( 203 + page: number, 204 + threadDid: string, 205 + threadTid: string, 206 + handle: string, 207 + userDid: string, 208 + sysopDid: string, 209 + ) { 210 + const container = document.getElementById("replies")!; 211 + const loading = document.getElementById("replies-loading"); 212 + 213 + try { 214 + const data = await fetchJson<RepliesResponse>( 215 + `/api/replies/${threadDid}/${threadTid}?handle=${encodeURIComponent(handle)}&page=${page}`, 216 + ); 217 + 218 + if (loading) loading.remove(); 219 + 220 + for (const r of data.replies) { 221 + allReplies[r.uri] = r; 222 + } 223 + 224 + for (const r of data.replies) { 225 + container.insertAdjacentHTML( 226 + "beforeend", 227 + renderReply(r, handle, threadDid, threadTid, userDid, sysopDid), 228 + ); 229 + } 230 + 231 + if (data.total_pages > 1) { 232 + const goToPage = (p: number) => { 233 + container.innerHTML = 234 + '<p id="replies-loading" class="text-neutral-500">Loading replies...</p>'; 235 + hideNavs(); 236 + const url = new URL(window.location.href); 237 + url.searchParams.set("page", String(p)); 238 + history.pushState(null, "", url.toString()); 239 + loadReplyPage(p, threadDid, threadTid, handle, userDid, sysopDid); 240 + document.getElementById("replies-nav-top")?.scrollIntoView({ behavior: "smooth" }); 241 + }; 242 + updateNavs(data.page, data.total_pages, goToPage); 243 + } 244 + 245 + if (container.children.length === 0 && !userDid) { 246 + container.innerHTML = '<p class="text-neutral-500">No replies yet.</p>'; 247 + } 248 + } catch { 249 + if (loading) loading.textContent = "Could not fetch replies."; 250 + } 251 + } 252 + 116 253 // --- Init --- 117 254 118 255 export function initThread() { ··· 128 265 if (btn) quoteReply(btn.dataset.uri!, btn.dataset.handle!); 129 266 }); 130 267 131 - createPaginatedLoader<ReplyItem>({ 132 - fetchUrl: (cursor) => { 133 - let url = `/api/replies/${threadDid}/${threadTid}?handle=${encodeURIComponent(handle)}`; 134 - if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; 135 - return url; 136 - }, 137 - containerId: "replies", 138 - loadingId: "replies-loading", 139 - nextContainerId: "replies-next", 140 - loadMoreId: "load-more", 141 - dataKey: "replies", 142 - emptyMessage: userDid ? undefined : "No replies yet.", 143 - onData: (data) => { 144 - const replies = data.replies as ReplyItem[]; 145 - for (const r of replies) { 146 - allReplies[r.uri] = r; 147 - } 148 - }, 149 - renderItem: (r) => 150 - renderReply(r, handle, threadDid, threadTid, userDid, sysopDid), 268 + const initialPage = parseInt(new URLSearchParams(window.location.search).get("page") ?? "1", 10); 269 + loadReplyPage(initialPage, threadDid, threadTid, handle, userDid, sysopDid); 270 + 271 + window.addEventListener("popstate", () => { 272 + const p = parseInt(new URLSearchParams(window.location.search).get("page") ?? "1", 10); 273 + const container = document.getElementById("replies")!; 274 + container.innerHTML = '<p id="replies-loading" class="text-neutral-500">Loading replies...</p>'; 275 + hideNavs(); 276 + loadReplyPage(p, threadDid, threadTid, handle, userDid, sysopDid); 151 277 }); 152 278 }