225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
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<string | undefined> {
|
|
const networks = await getNetworks()
|
|
// Check each network for matching handle domain
|
|
for (const [domain, network] of Object.entries(networks)) {
|
|
// Direct domain match (e.g., handle.syu.is -> syu.is)
|
|
if (handle.endsWith(`.${domain}`)) {
|
|
return network.web
|
|
}
|
|
// Check if handle domain matches network's web domain (e.g., syui.syui.ai -> syu.is via web: syu.is)
|
|
const webDomain = network.web?.replace(/^https?:\/\//, '')
|
|
if (webDomain && handle.endsWith(`.${webDomain}`)) {
|
|
return network.web
|
|
}
|
|
}
|
|
// Check for syui.ai handles -> syu.is network
|
|
if (handle.endsWith('.syui.ai')) {
|
|
return networks['syu.is']?.web
|
|
}
|
|
// Default to first network's web
|
|
const firstNetwork = Object.values(networks)[0]
|
|
return firstNetwork?.web
|
|
}
|
|
|
|
async function render(route: Route): Promise<void> {
|
|
try {
|
|
const config = await getConfig()
|
|
|
|
// Apply theme color from config
|
|
if (config.color) {
|
|
document.documentElement.style.setProperty('--btn-color', config.color)
|
|
}
|
|
|
|
// Handle OAuth callback if present (check both ? and #)
|
|
const searchParams = new URLSearchParams(window.location.search)
|
|
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
|
|
if (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state')) {
|
|
await handleCallback()
|
|
} else {
|
|
// Try to restore existing session
|
|
await restoreSession()
|
|
}
|
|
|
|
// Determine handle and whether to use local data
|
|
let handle: string
|
|
let localFirst: boolean
|
|
|
|
if (route.type === 'home') {
|
|
handle = config.handle
|
|
localFirst = true
|
|
} else if (route.handle) {
|
|
handle = route.handle
|
|
localFirst = handle === config.handle
|
|
} else {
|
|
handle = config.handle
|
|
localFirst = true
|
|
}
|
|
|
|
currentHandle = handle
|
|
|
|
// Resolve handle to DID
|
|
const did = await resolveHandle(handle)
|
|
|
|
if (!did) {
|
|
app.innerHTML = `
|
|
${renderHeader(handle)}
|
|
<div class="error">Could not resolve handle: ${handle}</div>
|
|
${renderFooter(handle)}
|
|
`
|
|
setupEventHandlers()
|
|
return
|
|
}
|
|
|
|
// Load profile
|
|
const profile = await getProfile(did, localFirst)
|
|
const webUrl = await getWebUrl(handle)
|
|
|
|
// Build page
|
|
let html = renderHeader(handle)
|
|
|
|
// Profile section
|
|
if (profile) {
|
|
html += await renderProfile(did, profile, handle, webUrl)
|
|
}
|
|
|
|
// Content section based on route type
|
|
if (route.type === 'record' && route.collection && route.rkey) {
|
|
// AT-Browser: Single record view
|
|
const record = await getRecord(did, route.collection, route.rkey)
|
|
if (record) {
|
|
html += `<div id="content">${renderRecordDetail(record, route.collection)}</div>`
|
|
} else {
|
|
html += `<div id="content" class="error">Record not found</div>`
|
|
}
|
|
html += `<nav class="back-nav"><a href="/@${handle}/at/collection/${route.collection}">${route.collection}</a></nav>`
|
|
|
|
} else if (route.type === 'collection' && route.collection) {
|
|
// AT-Browser: Collection records list
|
|
const records = await listRecords(did, route.collection)
|
|
html += `<div id="content">${renderRecordList(records, handle, route.collection)}</div>`
|
|
const parts = route.collection.split('.')
|
|
const service = parts.length >= 2 ? `${parts[1]}.${parts[0]}` : ''
|
|
html += `<nav class="back-nav"><a href="/@${handle}/at/service/${encodeURIComponent(service)}">${service}</a></nav>`
|
|
|
|
} else if (route.type === 'service' && route.service) {
|
|
// AT-Browser: Service collections list
|
|
const collections = await describeRepo(did)
|
|
const filtered = filterCollectionsByService(collections, route.service)
|
|
html += `<div id="content">${renderCollectionList(filtered, handle, route.service)}</div>`
|
|
html += `<nav class="back-nav"><a href="/@${handle}/at">at</a></nav>`
|
|
|
|
} else if (route.type === 'atbrowser') {
|
|
// AT-Browser: Main view with server info + service list
|
|
const pds = await getPds(did)
|
|
const collections = await describeRepo(did)
|
|
|
|
html += `<div id="browser">`
|
|
html += renderServerInfo(did, pds)
|
|
html += renderServiceList(collections, handle)
|
|
html += `</div>`
|
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
|
|
|
} else if (route.type === 'post' && route.rkey) {
|
|
// Post detail (config.collection with markdown)
|
|
const post = await getPost(did, config.collection, route.rkey, localFirst)
|
|
if (post) {
|
|
html += `<div id="content">${renderPostDetail(post, handle, config.collection)}</div>`
|
|
} else {
|
|
html += `<div id="content" class="error">Post not found</div>`
|
|
}
|
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
|
|
|
} else {
|
|
// User page: compact collection buttons + posts
|
|
const collections = await describeRepo(did)
|
|
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
|
|
|
|
const posts = await getPosts(did, config.collection, localFirst)
|
|
html += `<div id="content">${renderPostList(posts, handle)}</div>`
|
|
}
|
|
|
|
html += renderFooter(handle)
|
|
|
|
app.innerHTML = html
|
|
setupEventHandlers()
|
|
|
|
} catch (error) {
|
|
console.error('Render error:', error)
|
|
app.innerHTML = `
|
|
${renderHeader(currentHandle)}
|
|
<div class="error">Error: ${error}</div>
|
|
${renderFooter(currentHandle)}
|
|
`
|
|
setupEventHandlers()
|
|
}
|
|
}
|
|
|
|
function setupEventHandlers(): void {
|
|
// Header form
|
|
const form = document.getElementById('header-form') as HTMLFormElement
|
|
const input = document.getElementById('header-input') as HTMLInputElement
|
|
|
|
form?.addEventListener('submit', (e) => {
|
|
e.preventDefault()
|
|
const handle = input.value.trim()
|
|
if (handle) {
|
|
navigate({ type: 'user', handle })
|
|
}
|
|
})
|
|
|
|
// Login button
|
|
const loginBtn = document.getElementById('login-btn')
|
|
loginBtn?.addEventListener('click', async () => {
|
|
const handle = input.value.trim() || currentHandle
|
|
if (handle) {
|
|
try {
|
|
await login(handle)
|
|
} catch (e) {
|
|
console.error('Login failed:', e)
|
|
alert('Login failed. Please check your handle.')
|
|
}
|
|
} else {
|
|
alert('Please enter a handle first.')
|
|
}
|
|
})
|
|
|
|
// Logout button
|
|
const logoutBtn = document.getElementById('logout-btn')
|
|
logoutBtn?.addEventListener('click', async () => {
|
|
await logout()
|
|
})
|
|
}
|
|
|
|
// Initial render
|
|
render(parseRoute())
|
|
|
|
// Handle route changes
|
|
onRouteChange(render)
|