${collection}
+URI: ${record.uri}
+CID: ${record.cid}
+${escapeHtml(JSON.stringify(record.value, null, 2))}
+ No collections found.
' + } + + const groups = groupCollectionsByService(collections) + + const items = Array.from(groups.entries()).map(([service, cols]) => { + const favicon = getFaviconUrl(service) + const count = cols.length + + return ` +No records found.
' + } + + const items = records.map(record => { + const rkey = record.uri.split('/').pop() || '' + const value = record.value as Record${records.length} records
+URI: ${record.uri}
+CID: ${record.cid}
+${escapeHtml(JSON.stringify(record.value, null, 2))}
+ No posts yet.
' + } + + const items = posts.map(post => { + const rkey = post.uri.split('/').pop() || '' + const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP') + + return ` +${escapeHtml(description)}
+${highlighted}`
+ }
+ const escaped = escapeHtml(text)
+ return `${escaped}`
+}
+
+marked.use({ renderer })
+
+// Escape HTML
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+// Render markdown to HTML
+export function renderMarkdown(content: string): string {
+ return marked(content) as string
+}
diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts
new file mode 100644
index 0000000..e61b8b3
--- /dev/null
+++ b/src/web/lib/router.ts
@@ -0,0 +1,95 @@
+export interface Route {
+ type: 'home' | 'user' | 'post' | 'atbrowser' | 'service' | 'collection' | 'record'
+ handle?: string
+ rkey?: string
+ service?: string
+ collection?: string
+}
+
+// Parse current URL to route
+export function parseRoute(): Route {
+ const path = window.location.pathname
+
+ // Home: / or /app
+ if (path === '/' || path === '' || path === '/app' || path === '/app/') {
+ return { type: 'home' }
+ }
+
+ // AT-Browser main: /@handle/at or /@handle/at/
+ const atBrowserMatch = path.match(/^\/@([^/]+)\/at\/?$/)
+ if (atBrowserMatch) {
+ return { type: 'atbrowser', handle: atBrowserMatch[1] }
+ }
+
+ // AT-Browser service: /@handle/at/service/domain.tld
+ const serviceMatch = path.match(/^\/@([^/]+)\/at\/service\/([^/]+)$/)
+ if (serviceMatch) {
+ return { type: 'service', handle: serviceMatch[1], service: decodeURIComponent(serviceMatch[2]) }
+ }
+
+ // AT-Browser collection: /@handle/at/collection/namespace.name
+ const collectionMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)$/)
+ if (collectionMatch) {
+ return { type: 'collection', handle: collectionMatch[1], collection: collectionMatch[2] }
+ }
+
+ // AT-Browser record: /@handle/at/collection/namespace.name/rkey
+ const recordMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)\/([^/]+)$/)
+ if (recordMatch) {
+ return { type: 'record', handle: recordMatch[1], collection: recordMatch[2], rkey: recordMatch[3] }
+ }
+
+ // User page: /@handle or /@handle/
+ const userMatch = path.match(/^\/@([^/]+)\/?$/)
+ if (userMatch) {
+ return { type: 'user', handle: userMatch[1] }
+ }
+
+ // Post page: /@handle/rkey (for config.collection)
+ const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
+ if (postMatch) {
+ return { type: 'post', handle: postMatch[1], rkey: postMatch[2] }
+ }
+
+ // Default to home
+ return { type: 'home' }
+}
+
+// Navigate to a route
+export function navigate(route: Route): void {
+ let path = '/'
+
+ if (route.type === 'user' && route.handle) {
+ path = `/@${route.handle}`
+ } else if (route.type === 'post' && route.handle && route.rkey) {
+ path = `/@${route.handle}/${route.rkey}`
+ } else if (route.type === 'atbrowser' && route.handle) {
+ path = `/@${route.handle}/at`
+ } else if (route.type === 'service' && route.handle && route.service) {
+ path = `/@${route.handle}/at/service/${encodeURIComponent(route.service)}`
+ } else if (route.type === 'collection' && route.handle && route.collection) {
+ path = `/@${route.handle}/at/collection/${route.collection}`
+ } else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
+ path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
+ }
+
+ window.history.pushState({}, '', path)
+ window.dispatchEvent(new PopStateEvent('popstate'))
+}
+
+// Subscribe to route changes
+export function onRouteChange(callback: (route: Route) => void): void {
+ const handler = () => callback(parseRoute())
+ window.addEventListener('popstate', handler)
+
+ // Handle link clicks
+ document.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement
+ const anchor = target.closest('a')
+ if (anchor && anchor.href.startsWith(window.location.origin)) {
+ e.preventDefault()
+ window.history.pushState({}, '', anchor.href)
+ handler()
+ }
+ })
+}
diff --git a/src/web/main.ts b/src/web/main.ts
new file mode 100644
index 0000000..00b1f1d
--- /dev/null
+++ b/src/web/main.ts
@@ -0,0 +1,224 @@
+import './styles/main.css'
+import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
+import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
+import { login, logout, handleCallback, restoreSession } from './lib/auth'
+import { renderHeader } from './components/header'
+import { renderProfile } from './components/profile'
+import { renderPostList, renderPostDetail } from './components/posts'
+import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
+import { renderFooter } from './components/footer'
+
+const app = document.getElementById('app')!
+
+let currentHandle = ''
+
+// Filter collections by service domain
+function filterCollectionsByService(collections: string[], service: string): string[] {
+ return collections.filter(col => {
+ const parts = col.split('.')
+ if (parts.length >= 2) {
+ const colService = `${parts[1]}.${parts[0]}`
+ return colService === service
+ }
+ return false
+ })
+}
+
+// Get web URL for handle from networks
+async function getWebUrl(handle: string): Promise