From 5b9ef2eae12f77eb372cec4c9c602173a962be93 Mon Sep 17 00:00:00 2001 From: syui Date: Fri, 16 Jan 2026 16:48:55 +0900 Subject: [PATCH] add @user --- .../ai.syui.log.post/3mchqlshygs2s.json | 12 +- .../favicons/atproto.com.png | Bin 0 -> 1482 bytes .../favicons/syu.is.png | Bin 0 -> 1005 bytes .../profile.json | 2 +- .../.well-known/lexicon/ai.syui.log.post.json | 36 ++++- .../.well-known/lexicon/ai/syui/log/post.json | 68 ++++++++++ public/_redirects | 1 - scripts/generate.ts | 45 +++++-- src/components/atbrowser.ts | 23 +++- src/components/browser.ts | 5 +- src/components/posts.ts | 6 +- src/lib/api.ts | 27 ++++ src/lib/router.ts | 16 ++- src/main.ts | 123 ++++++++++++++++-- 14 files changed, 323 insertions(+), 41 deletions(-) create mode 100644 content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/atproto.com.png create mode 100644 content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syu.is.png create mode 100644 public/.well-known/lexicon/ai/syui/log/post.json delete mode 100644 public/_redirects diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json index f675bd5..a3c91cf 100644 --- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json +++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json @@ -1,13 +1,13 @@ { - "cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e", + "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s", + "cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme", + "title": "ailogを作り直した", "content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```", "createdAt": "2026-01-15T13:59:52.367Z", - "title": "ailogを作り直した", "translations": { "en": { - "content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```", - "title": "recreated ailog" + "title": "recreated ailog", + "content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```" } - }, - "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s" + } } \ No newline at end of file diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/atproto.com.png b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/atproto.com.png new file mode 100644 index 0000000000000000000000000000000000000000..4c04c29d6bf4ac3e48066dceac2cb8202fac7c01 GIT binary patch literal 1482 zcmV;*1vUDKP)b_3z=Fea<>(MjgA*_q4yu`o7<{_S$Q$ z4gAl-u?EgtZ70QXaUm672Y6Eeoo#?gA_D-B^t*`fC~l(F#zP3c-4*fZKL(f`7N_{e zMu2W5Y3d#ez#Mm+q!`faDVtu=7xDK~2AKQ8PwhN<`dUNYeKN=q8PxzXDB+20Px_TH z1I+0yUg9Z!pX3z)8k&`~S03LpvG!QvH_{AB_Cf-eP+TBrM&nFX%2?U;m)hXLbrFjv z1eoYyFpUQR|KqO~A}h6I)og57fhkyx8>#>PyAJwS5uGqXNtf z6||asj^tFQ_>Z8sq`$lE`x6yzmab6#F*EoG;Xh3>o!Bk(bhbOaVpM=klz)}P(mF9d zw{hE|Tx1TON^w^@!~8rMZKy$5zzUt&DCh^AW!w>#uB;Td6;A2kaD2hd|D-qoY>k2~ zqVA{}3_znOQEM}h=^6T%hBqd9OfJr?YTwYDaNcS=DIP8@q0uZ!7ZNxCVP|Zx`A8h> z9Pbx10N{!H-Pe|hhCU*>B+<*ztZm)t?S7I}aj{lhLN3?cRQ#RWyv*)(D{Z;3${2b8 zK(f|`4`70q@1xiS09N)j01qX@t@T6Ci)#QLcrSqM1_0vW;v%;ei5HtY&+J~ehCmNl zDL$GImEzTB>-ut0>kdi#YX+$csICE0i|ckN9k*?2C?4hK6L{3M3EB?eRS7Rk+BFJK zc@$f1R!LrURj3JmRdc`Bof}6kyk)Lph2_~W{d}T#h4P0@{&kY~)`IBnhY56Q4;`*n zl+G~#An9}`?k9IpS12FQP4y($mJNgF-I%_JWz!NYlazNkn*pfdKV02|oltoNz(mJ> z#0@I=#q&w}^)|GzIWYiid7Os;e&c|S0021Hpc;-(_JQ$k%?8S{NjGE?4vhv-m3>KF zN^!H(LgN~BdpH3&SU2LV#(JrJCh=492rRA)R4QF4TnFHTBr+cdFOD;X2SA)tO+MKJ-h)(WSgddALj9Rs z-r^OOclNp;N79u45|GT7;fHmX1;u;Z;IAVdwib6v`m-TR(``c1prB1SiAZ`Q+uizQ z4IsNJ*h^`1#ce?1){frNbazlT3O0L&Qzh@+>IJ3o>IH2@yP45{`6r7kv_?{?8B$U# z>CJ%&!6PGj$VB-L8uTYg1#C;j?F;&vu22Bb8Rjo2`@E=@q>Nv3QTvZ>%*;?h$}e7R ztfmPH3~;#Yx4*q@g*o8HHB4e>l>ZP!eb=-4G^OB{(IEaUX}CPbw(Wih`)YKCHavt}Pn-)Q*+o_BW4lCG(6OuTo)Qwmbd7iQdhj-`_XGD%p1mVVKlUVE}J$Njg;MrNSDCw;Ar(IvUx_9%y3wXC}rr7W)> z3-p+OPG^Th=XsvF-pF1f*qMNt>>ksS21I;M;RnfVId*p2`t3_O{I3Cwkayr^b{8j0 k@oD3N_FbPjXUD0?U)6qUaf3D^^8f$<07*qoM6N<$f*Y*JZU6uP literal 0 HcmV?d00001 diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syu.is.png b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syu.is.png new file mode 100644 index 0000000000000000000000000000000000000000..2227ba3faaf2c1e2613b3609f8c6dc0d821d5f91 GIT binary patch literal 1005 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}6{!)PX}-P; zT0k}j0}G=R11m@g5Zf_I!`W_(8Vt-}aV7?a_DlvAs2V>Y4FYmN4AKLknHMlZRRhHq zFu_$TEMP{kLE3(;l`8>KoCO|{#S9EAb3vG~#w2?o0|PUkr;B4q1n1i@Uw`315!=rf z{iB4sq8x%32sk!79dat)74nafyH`(P!U-;ql18RZm&Mj4C!2cpB3?DwE9j*(day7% zopw*|nbO8|x$IWo`*S4%QYZFEP0ITHcbgL5#*Q0SC7lkQ zjf=Du{`-qAxyeqX*|nQMzP-&LMP^n0@<@Xbi+f4f0PPW8}+CSI;} zMoKTgygcxXE1c^^s;l6|Tf5pHrYl~#u5Mrx)PCOhsXzqB_WYw4?(b(Tj}$YWf6CCV zj?>%zPE6>*T&L;U-L0(ryLQdK@I8ZjAD8Gw+1YxZcYm)_5mK_b$FpqNx!0<*@`H>Y z{n6ge^z4|=u@kTVR`0)XW5t8Bx4Szwd|DiGan{{=tqF6hK3jO0nwlPN_m?rh!fxVw zV0xtfq%|hh)!PK_Pu;NXS<~S_35$cGE)7a5AGp-^FY@4MpF3mr=LMTTn`be5?v=b~ zHSvjT49kkAL4B)N2TRCAxW~)8>Lyp;W(yU4n7a4j$H}EvuBjQEp3-?db+75?2^+pJ zG)~?kD;ro+?pkngz1+Ore~%w~XBsl^cX+oY z%ZrmH(f95=DmRz2$+@=r;He4IZZ7KQep>mavf<=e0nRTP8#w39zMrC7k+Q|le1)jR z(F0EzJ53MlTpy=XqwTCPp<#d5(t|3Kc29C&w!BuNDysi#==Ga*Jsq08Azm$)XI`{U zihgakYSpA)&n-lXUOkJBD|>0%@K5>!pEv&#d6#8NLIOM|9r@xD&ZNt7!)E$!(b>`* yXEHYb37l2!zbk6ihb6x0mf=bPK?`1R{b6M^mdxhg`0O7j+jzSAxvX { try { - const res = await fetch(url) + const res = await fetch(url, { redirect: 'follow' }) if (!res.ok) return false const buffer = await res.arrayBuffer() + if (buffer.byteLength === 0) return false fs.writeFileSync(filepath, Buffer.from(buffer)) return true - } catch { + } catch (err) { + console.error(`Failed to download ${url}:`, err) return false } } @@ -226,8 +228,16 @@ function getServiceDomain(collection: string): string | null { return null } +// Common service domains to always download favicons for +const COMMON_SERVICE_DOMAINS = [ + 'bsky.app', + 'atproto.com', + 'syui.ai', + 'syu.is', +] + function getServiceDomains(collections: string[]): string[] { - const domains = new Set() + const domains = new Set(COMMON_SERVICE_DOMAINS) for (const col of collections) { const domain = getServiceDomain(col) if (domain) domains.add(domain) @@ -241,21 +251,22 @@ async function downloadFavicons(did: string, domains: string[]): Promise { fs.mkdirSync(faviconDir, { recursive: true }) } + // Known favicon URLs (prefer official sources over Google) + // Others will use Google's favicon API as fallback const faviconUrls: Record = { 'bsky.app': 'https://bsky.app/static/favicon-32x32.png', 'syui.ai': 'https://syui.ai/favicon.png', } for (const domain of domains) { - const url = faviconUrls[domain] - if (!url) continue - const filepath = path.join(faviconDir, `${domain}.png`) - if (!fs.existsSync(filepath)) { - const ok = await downloadFavicon(url, filepath) - if (ok) { - console.log(`Downloaded: ${domain}.png`) - } + if (fs.existsSync(filepath)) continue + + // Try known URL first, then fallback to Google's favicon API + const url = faviconUrls[domain] || `https://www.google.com/s2/favicons?domain=${domain}&sz=32` + const ok = await downloadFavicon(url, filepath) + if (ok) { + console.log(`Downloaded: ${domain}.png`) } } } @@ -801,12 +812,20 @@ async function generate() { console.log('Generated: /app.html') // Generate _redirects for Cloudflare Pages (SPA routes) - const redirects = `/app / 301 -/oauth/* /app.html 200 + // Static files (index.html, post/*/index.html) are served automatically + // Dynamic routes are rewritten to app.html + const redirects = `/oauth/* /app.html 200 +/at/* /app.html 200 +/new /app.html 200 +/app /app.html 200 ` fs.writeFileSync(path.join(distDir, '_redirects'), redirects) console.log('Generated: /_redirects') + // Generate 404.html as SPA fallback for unmatched routes (like /@handle) + fs.writeFileSync(path.join(distDir, '404.html'), spaHtml) + console.log('Generated: /404.html') + // Copy static files const filesToCopy = ['favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json', 'links.json'] for (const file of filesToCopy) { diff --git a/src/components/atbrowser.ts b/src/components/atbrowser.ts index 7a27eeb..5a08dd6 100644 --- a/src/components/atbrowser.ts +++ b/src/components/atbrowser.ts @@ -1,6 +1,17 @@ -import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js' +import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlcForPds } from '../lib/api.js' import { deleteRecord } from '../lib/auth.js' import { escapeHtml } from '../lib/utils.js' +import type { Networks } from '../types.js' + +// Cache networks config +let networksConfig: Networks | null = null + +async function loadNetworks(): Promise { + if (networksConfig) return networksConfig + const res = await fetch('/networks.json') + networksConfig = await res.json() + return networksConfig! +} function extractRkey(uri: string): string { const parts = uri.split('/') @@ -8,13 +19,15 @@ function extractRkey(uri: string): string { } async function renderServices(did: string, handle: string): Promise { - const [collections, pds] = await Promise.all([ + const [collections, pds, networks] = await Promise.all([ describeRepo(did), - resolvePds(did) + resolvePds(did), + loadNetworks() ]) - // Server info section - const plcUrl = `${getPlc()}/${did}/log` + // Server info section - use PLC based on PDS + const plc = getPlcForPds(pds, networks) + const plcUrl = `${plc}/${did}/log` const serverHtml = `

Server

diff --git a/src/components/browser.ts b/src/components/browser.ts index 26ed08b..bf51378 100644 --- a/src/components/browser.ts +++ b/src/components/browser.ts @@ -13,6 +13,9 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan ` + // Use logged-in user's handle for input if available + const inputHandle = isLoggedIn && userHandle ? userHandle : currentHandle + return `
@@ -21,7 +24,7 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan class="header-input" id="header-input" placeholder="handle (e.g., syui.ai)" - value="${currentHandle}" + value="${inputHandle}" > ${loginBtn} diff --git a/src/components/posts.ts b/src/components/posts.ts index 230c4c5..15e4f83 100644 --- a/src/components/posts.ts +++ b/src/components/posts.ts @@ -4,7 +4,7 @@ import { renderMarkdown } from '../lib/markdown.js' import { escapeHtml, formatDate } from '../lib/utils.js' import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js' -export function mountPostList(container: HTMLElement, posts: BlogPost[]): void { +export function mountPostList(container: HTMLElement, posts: BlogPost[], userHandle?: string): void { if (posts.length === 0) { container.innerHTML = '

No posts yet

' return @@ -12,9 +12,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void { const html = posts.map(post => { const rkey = post.uri.split('/').pop() + // Use /@handle/post/rkey for user pages, /post/rkey for own blog + const postUrl = userHandle ? `/@${userHandle}/post/${rkey}` : `/post/${rkey}` return `
  • - + ${escapeHtml(post.title)} diff --git a/src/lib/api.ts b/src/lib/api.ts index 809e1f5..0eefe4f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,6 +14,33 @@ export function getPlc(): string { return networkConfig?.plc || 'https://plc.directory' } +// Get PLC URL based on PDS endpoint +export function getPlcForPds(pds: string, networks: Record): string { + // Check if PDS matches any network + for (const [_key, config] of Object.entries(networks)) { + // Match by domain (e.g., "https://syu.is" or "https://bsky.syu.is") + try { + const pdsHost = new URL(pds).hostname + const bskyHost = new URL(config.bsky).hostname + // Check if PDS host matches network's bsky host + if (pdsHost === bskyHost || pdsHost.endsWith('.' + bskyHost)) { + return config.plc + } + // Also check web host if available + if (config.web) { + const webHost = new URL(config.web).hostname + if (pdsHost === webHost || pdsHost.endsWith('.' + webHost)) { + return config.plc + } + } + } catch { + continue + } + } + // Default to plc.directory + return 'https://plc.directory' +} + function getBsky(): string { return networkConfig?.bsky || 'https://public.api.bsky.app' } diff --git a/src/lib/router.ts b/src/lib/router.ts index 40c6d08..4412dcb 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -1,5 +1,5 @@ export interface Route { - type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new' + type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new' | 'user-blog' | 'user-post' handle?: string collection?: string rkey?: string @@ -33,6 +33,16 @@ export function parseRoute(pathname: string): Route { return { type: 'new' } } + // /@${handle} - User blog (any user's posts) + // /@${handle}/post/${rkey} - User post detail + if (parts[0].startsWith('@')) { + const handle = parts[0].slice(1) // Remove @ prefix + if (parts[1] === 'post' && parts[2]) { + return { type: 'user-post', handle, rkey: parts[2] } + } + return { type: 'user-blog', handle } + } + // /at/${handle} - Browser services // /at/${handle}/${service-or-collection} - Browser collections or records // /at/${handle}/${collection}/${rkey} - Browser record detail @@ -75,6 +85,10 @@ export function buildPath(route: Route): string { return '/new' case 'post': return `/post/${route.rkey}` + case 'user-blog': + return `/@${route.handle}` + case 'user-post': + return `/@${route.handle}/post/${route.rkey}` case 'browser-services': return `/at/${route.handle}` case 'browser-collections': diff --git a/src/main.ts b/src/main.ts index 65ed7ff..4dc70f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -94,6 +94,40 @@ function renderFooter(handle: string): string { ` } +// Detect network from handle domain +// e.g., syui.ai → bsky.social, syui.syui.ai → syu.is +function detectNetworkFromHandle(handle: string): string { + const parts = handle.split('.') + if (parts.length >= 2) { + // Get domain part (last 2 parts for most cases) + const domain = parts.slice(-2).join('.') + // Check if domain matches any network key + if (networks[domain]) { + return domain + } + // Check if it's a subdomain of a known network + for (const networkKey of Object.keys(networks)) { + if (handle.endsWith(`.${networkKey}`) || handle.endsWith(networkKey)) { + return networkKey + } + } + } + // Default to bsky.social + return 'bsky.social' +} + +function switchNetwork(newNetwork: string): void { + if (newNetwork === browserNetwork) return + browserNetwork = newNetwork + localStorage.setItem('browserNetwork', newNetwork) + const networkConfig = networks[newNetwork] + if (networkConfig) { + setNetworkConfig(networkConfig) + setAuthNetworkConfig(networkConfig) + } + updatePdsSelector() +} + function renderPdsSelector(): string { const networkKeys = Object.keys(networks) const options = networkKeys.map(key => { @@ -220,10 +254,11 @@ function applyTitleTranslations(): void { } } -function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string { +function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean, handle?: string): string { + const browserHandle = handle || config.handle let tabs = ` Blog - + ` if (isLoggedIn) { @@ -242,6 +277,10 @@ function openBrowser(handle: string, service: string | null = null, collection: if (!contentEl || !tabsEl) return + // Auto-detect and switch network based on handle + const detectedNetwork = detectNetworkFromHandle(handle) + switchNetwork(detectedNetwork) + // Save current content if not already in browser mode if (!browserMode) { savedContent = { @@ -678,8 +717,10 @@ async function render(): Promise { const handle = route.handle || config.handle // Skip re-rendering for static blog/post pages (but still mount header for login) + // Exception: if logged in on blog page, re-render to show user's blog const isStaticRoute = route.type === 'blog' || route.type === 'post' - if (isStatic && isStaticRoute) { + const shouldUseStatic = isStatic && isStaticRoute && !(isLoggedIn && route.type === 'blog') + if (shouldUseStatic) { // Only mount header for login functionality (pass isStatic=true to skip unnecessary re-render) mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, { onBrowse: (newHandle) => { @@ -826,21 +867,83 @@ async function render(): Promise { } break - case 'blog': - default: + case 'user-blog': + // /@{handle} - Any user's blog try { - const profile = await getProfile(config.handle) - const webUrl = networks[config.network]?.web - profileEl.innerHTML = renderTabs('blog', isLoggedIn) + const userHandle = route.handle! + // Auto-detect and switch network based on handle + const detectedNetwork = detectNetworkFromHandle(userHandle) + switchNetwork(detectedNetwork) + const profile = await getProfile(userHandle) + const webUrl = networks[browserNetwork]?.web + profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle) const profileContentEl = document.createElement('div') profileEl.appendChild(profileContentEl) mountProfile(profileContentEl, profile, webUrl) - const servicesHtml = await renderServices(config.handle) + const servicesHtml = await renderServices(userHandle) profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) const posts = await listRecords(profile.did, config.collection) - mountPostList(contentEl, posts) + mountPostList(contentEl, posts, userHandle) + } catch (err) { + console.error(err) + contentEl.innerHTML = `

    Failed to load: ${err}

    ` + } + break + + case 'user-post': + // /@{handle}/post/{rkey} - Any user's post detail + try { + const userHandle = route.handle! + // Auto-detect and switch network based on handle + const detectedNetwork = detectNetworkFromHandle(userHandle) + switchNetwork(detectedNetwork) + const profile = await getProfile(userHandle) + const webUrl = networks[browserNetwork]?.web + profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle) + const profileContentEl = document.createElement('div') + profileEl.appendChild(profileContentEl) + mountProfile(profileContentEl, profile, webUrl) + + const servicesHtml = await renderServices(userHandle) + profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) + + const post = await getRecord(profile.did, config.collection, route.rkey!) + if (post) { + const canEdit = isLoggedIn && authSession?.did === profile.did + mountPostDetail(contentEl, post, userHandle, config.collection, canEdit, undefined, browserNetwork) + } else { + contentEl.innerHTML = '

    Post not found

    ' + } + } catch (err) { + console.error(err) + contentEl.innerHTML = `

    Failed to load: ${err}

    ` + } + break + + case 'blog': + default: + try { + // If logged in, show logged-in user's blog instead of site owner's + const blogHandle = isLoggedIn ? authSession!.handle : config.handle + const detectedNetwork = isLoggedIn ? detectNetworkFromHandle(blogHandle) : config.network + if (isLoggedIn) { + switchNetwork(detectedNetwork) + } + const profile = await getProfile(blogHandle) + const webUrl = networks[detectedNetwork]?.web + profileEl.innerHTML = renderTabs('blog', isLoggedIn, blogHandle) + const profileContentEl = document.createElement('div') + profileEl.appendChild(profileContentEl) + mountProfile(profileContentEl, profile, webUrl) + + const servicesHtml = await renderServices(blogHandle) + profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) + + const posts = await listRecords(profile.did, config.collection) + // Use handle for post links if logged in user + mountPostList(contentEl, posts, isLoggedIn ? blogHandle : undefined) } catch (err) { console.error(err) contentEl.innerHTML = `

    Failed to load: ${err}

    `