Files
log/src/main.ts
2026-01-16 16:57:55 +09:00

1001 lines
32 KiB
TypeScript

import { getProfile, listRecords, getRecord, setNetworkConfig } from './lib/api.js'
import { renderServices } from './components/services.js'
import { login, logout, restoreSession, handleOAuthCallback, setAuthNetworkConfig, type AuthSession } from './lib/auth.js'
import { mountProfile } from './components/profile.js'
import { mountPostList, mountPostDetail } from './components/posts.js'
import { mountHeader } from './components/browser.js'
import { mountAtBrowser } from './components/atbrowser.js'
import { mountPostForm } from './components/postform.js'
import { loadDiscussionPosts } from './components/discussion.js'
import { parseRoute, type Route } from './lib/router.js'
import { escapeHtml } from './lib/utils.js'
import { LANG_ICON, BUILTIN_ICONS } from './lib/icons.js'
import type { AppConfig, Networks } from './types.js'
let authSession: AuthSession | null = null
let config: AppConfig
let networks: Networks = {}
let browserNetwork: string = '' // Network for AT Browser
let currentLang: string = 'en' // Default language for translations
// Browser state
let browserMode = false
let browserState = {
handle: '',
collection: null as string | null,
rkey: null as string | null,
service: null as string | null
}
let savedContent: { profile: string; content: string } | null = null
async function loadConfig(): Promise<AppConfig> {
const res = await fetch('/config.json')
return res.json()
}
async function loadNetworks(): Promise<Networks> {
const res = await fetch('/networks.json')
return res.json()
}
interface FooterLink {
name: string
url: string
icon?: string
}
let footerLinks: FooterLink[] = []
async function loadLinks(): Promise<FooterLink[]> {
try {
const res = await fetch('/links.json')
if (!res.ok) return []
const data = await res.json()
return data.links || []
} catch {
return []
}
}
function renderFooterLinks(links: FooterLink[]): string {
if (links.length === 0) return ''
const items = links.map(link => {
let iconHtml = ''
if (link.icon && BUILTIN_ICONS[link.icon]) {
iconHtml = BUILTIN_ICONS[link.icon]
} else {
try {
const domain = new URL(link.url).hostname
iconHtml = `<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=32" alt="" class="footer-link-favicon">`
} catch {
iconHtml = ''
}
}
return `
<a href="${escapeHtml(link.url)}" class="footer-link-item" title="${escapeHtml(link.name)}" target="_blank" rel="noopener">
${iconHtml}
</a>
`
}).join('')
return `<div class="footer-links">${items}</div>`
}
function renderFooter(handle: string): string {
const parts = handle.split('.')
const username = parts[0] || handle
return `
${renderFooterLinks(footerLinks)}
<footer class="site-footer">
<p>&copy; ${username}</p>
</footer>
`
}
// 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 => {
const isSelected = key === browserNetwork
return `<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${escapeHtml(key)}">
<span class="pds-name">${escapeHtml(key)}</span>
<span class="pds-check">✓</span>
</div>`
}).join('')
return `
<div class="pds-selector" id="pds-selector">
<button type="button" class="tab" id="pds-tab">PDS</button>
<div class="pds-dropdown" id="pds-dropdown">
${options}
</div>
</div>
`
}
function updatePdsSelector(): void {
const dropdown = document.getElementById('pds-dropdown')
if (!dropdown) return
const options = dropdown.querySelectorAll('.pds-option')
options.forEach(opt => {
const el = opt as HTMLElement
const network = el.dataset.network
const isSelected = network === browserNetwork
el.classList.toggle('selected', isSelected)
})
}
function renderLangSelector(): string {
const langs = [
{ code: 'ja', name: '日本語' },
{ code: 'en', name: 'English' },
]
const options = langs.map(lang => {
const isSelected = lang.code === currentLang
return `<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang.code}">
<span class="lang-name">${lang.name}</span>
<span class="lang-check">✓</span>
</div>`
}).join('')
return `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language">
${LANG_ICON}
</button>
<div class="lang-dropdown" id="lang-dropdown">
${options}
</div>
</div>
`
}
function updateLangSelector(): void {
const dropdown = document.getElementById('lang-dropdown')
if (!dropdown) return
const options = dropdown.querySelectorAll('.lang-option')
options.forEach(opt => {
const el = opt as HTMLElement
const lang = el.dataset.lang
const isSelected = lang === currentLang
el.classList.toggle('selected', isSelected)
})
}
function applyTranslation(): void {
const contentEl = document.querySelector('.post-content')
const titleEl = document.getElementById('post-detail-title')
// Get translation data from script tag
const scriptEl = document.getElementById('translation-data')
if (!scriptEl) return
try {
const data = JSON.parse(scriptEl.textContent || '{}')
// Apply content translation
if (contentEl) {
if (currentLang === 'en' && data.translated) {
contentEl.innerHTML = data.translated
} else if (data.original) {
contentEl.innerHTML = data.original
}
}
// Apply title translation
if (titleEl) {
if (currentLang === 'en' && data.translatedTitle) {
titleEl.textContent = data.translatedTitle
} else if (data.originalTitle) {
titleEl.textContent = data.originalTitle
}
}
} catch {
// Invalid JSON, ignore
}
}
function applyTitleTranslations(): void {
// Get title translations from script tag
const scriptEl = document.getElementById('title-translations')
if (!scriptEl) return
try {
const translations = JSON.parse(scriptEl.textContent || '{}') as Record<string, { original: string; translated: string }>
// Update each post title
document.querySelectorAll('.post-title[data-rkey]').forEach(el => {
const rkey = (el as HTMLElement).dataset.rkey
if (rkey && translations[rkey]) {
const { original, translated } = translations[rkey]
el.textContent = currentLang === 'en' ? translated : original
}
})
} catch {
// Invalid JSON, ignore
}
}
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean, handle?: string): string {
const browserHandle = handle || config.handle
let tabs = `
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
<button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${browserHandle}">Browser</button>
`
if (isLoggedIn) {
tabs += `<a href="/post" class="tab ${activeTab === 'new' ? 'active' : ''}">Post</a>`
}
tabs += renderPdsSelector()
return `<div class="mode-tabs">${tabs}</div>`
}
// Browser functions (page-based, not modal)
function openBrowser(handle: string, service: string | null = null, collection: string | null = null, rkey: string | null = null): void {
const contentEl = document.getElementById('content')
const tabsEl = document.querySelector('.mode-tabs')
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 = {
profile: '', // Not used anymore
content: contentEl.innerHTML
}
}
browserMode = true
browserState = { handle, service, collection, rkey }
// Update tabs to show browser as active
const blogTab = tabsEl.querySelector('#blog-tab, a[href="/"]')
const browserTab = tabsEl.querySelector('#browser-tab')
if (blogTab) blogTab.classList.remove('active')
if (browserTab) browserTab.classList.add('active')
// Show skeleton UI immediately
contentEl.innerHTML = `
<div class="browser-skeleton">
<div class="skeleton-header">
<div class="skeleton-title"></div>
</div>
<ul class="skeleton-list">
<li class="skeleton-item"><div class="skeleton-icon"></div><div class="skeleton-text"></div></li>
<li class="skeleton-item"><div class="skeleton-icon"></div><div class="skeleton-text"></div></li>
<li class="skeleton-item"><div class="skeleton-icon"></div><div class="skeleton-text"></div></li>
</ul>
</div>
`
// Load browser content
loadBrowserContent()
}
function closeBrowser(): void {
if (!browserMode || !savedContent) return
const contentEl = document.getElementById('content')
const tabsEl = document.querySelector('.mode-tabs')
if (!contentEl || !tabsEl) return
// Restore saved content
contentEl.innerHTML = savedContent.content
// Update tabs to show blog as active
const blogTab = tabsEl.querySelector('#blog-tab, a[href="/"]')
const browserTab = tabsEl.querySelector('#browser-tab')
if (blogTab) blogTab.classList.add('active')
if (browserTab) browserTab.classList.remove('active')
browserMode = false
browserState = { handle: '', service: null, collection: null, rkey: null }
savedContent = null
}
async function loadBrowserContent(): Promise<void> {
const contentEl = document.getElementById('content')
if (!contentEl) return
// Set network config for browser
const browserNetworkConfig = networks[browserNetwork]
if (browserNetworkConfig) {
setNetworkConfig(browserNetworkConfig)
}
const loginDid = authSession?.did || null
await mountAtBrowser(
contentEl,
browserState.handle,
browserState.collection,
browserState.rkey,
browserState.service,
loginDid
)
}
// Add edit button to static post page
async function addEditButtonToStaticPost(collection: string, rkey: string, session: AuthSession): Promise<void> {
const postMeta = document.querySelector('.post-meta')
if (!postMeta) return
// Check if user owns this post
const profile = await getProfile(config.handle)
if (session.did !== profile.did) return
// Add edit button
const editBtn = document.createElement('button')
editBtn.className = 'edit-btn'
editBtn.id = 'edit-btn'
editBtn.textContent = 'edit'
postMeta.appendChild(editBtn)
// Get current post content
const titleEl = document.querySelector('.post-title') as HTMLElement
const contentEl = document.querySelector('.post-content') as HTMLElement
const postArticle = document.querySelector('.post-detail') as HTMLElement
if (!titleEl || !contentEl || !postArticle) return
const originalTitle = titleEl.textContent || ''
// Get original markdown from the record
const record = await getRecord(profile.did, collection, rkey)
if (!record) return
// Create edit form
const editFormContainer = document.createElement('div')
editFormContainer.className = 'edit-form-container'
editFormContainer.id = 'edit-form-container'
editFormContainer.style.display = 'none'
editFormContainer.innerHTML = `
<h3>Edit Post</h3>
<form class="edit-form" id="edit-form">
<input type="text" id="edit-title" class="edit-form-title" value="${escapeHtml(originalTitle)}" placeholder="Title" required>
<textarea id="edit-content" class="edit-form-body" placeholder="Content" required>${escapeHtml(record.content)}</textarea>
<div class="edit-form-footer">
<button type="button" id="edit-cancel" class="edit-cancel-btn">Cancel</button>
<button type="submit" id="edit-submit" class="edit-submit-btn">Save</button>
</div>
</form>
`
postArticle.parentNode?.insertBefore(editFormContainer, postArticle.nextSibling)
// Event listeners
editBtn.addEventListener('click', () => {
postArticle.style.display = 'none'
editFormContainer.style.display = 'block'
})
document.getElementById('edit-cancel')?.addEventListener('click', () => {
postArticle.style.display = 'block'
editFormContainer.style.display = 'none'
})
document.getElementById('edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault()
const title = (document.getElementById('edit-title') as HTMLInputElement).value
const content = (document.getElementById('edit-content') as HTMLTextAreaElement).value
const submitBtn = document.getElementById('edit-submit') as HTMLButtonElement
try {
submitBtn.disabled = true
submitBtn.textContent = 'Saving...'
const { putRecord } = await import('./lib/auth.js')
await putRecord(collection, rkey, {
title,
content,
createdAt: record.createdAt,
})
window.location.reload()
} catch (err) {
alert('Save failed: ' + err)
submitBtn.disabled = false
submitBtn.textContent = 'Save'
}
})
}
// Refresh post list from API (for static pages)
async function refreshPostListFromAPI(): Promise<void> {
const contentEl = document.getElementById('content')
if (!contentEl) return
try {
const profile = await getProfile(config.handle)
const apiPosts = await listRecords(profile.did, config.collection)
// Get current static post rkeys
const staticPostLinks = contentEl.querySelectorAll('.post-item a.post-link')
const staticRkeys = new Set<string>()
staticPostLinks.forEach(link => {
const href = link.getAttribute('href')
if (href) {
// Handle both /post/rkey and /post/rkey/ (trailing slash)
const parts = href.split('/').filter(Boolean)
const rkey = parts[parts.length - 1]
if (rkey) staticRkeys.add(rkey)
}
})
// Find new posts not in static content
const newPosts = apiPosts.filter(post => {
const rkey = post.uri.split('/').pop()
return rkey && !staticRkeys.has(rkey)
})
// Find deleted posts (in static but not in API)
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
const deletedRkeys = new Set<string>()
staticRkeys.forEach(rkey => {
if (!apiRkeys.has(rkey)) {
deletedRkeys.add(rkey)
}
})
// Remove deleted posts from DOM
if (deletedRkeys.size > 0) {
staticPostLinks.forEach(link => {
const href = link.getAttribute('href')
if (href) {
const parts = href.split('/').filter(Boolean)
const rkey = parts[parts.length - 1]
if (rkey && deletedRkeys.has(rkey)) {
const listItem = link.closest('.post-item')
listItem?.remove()
}
}
})
}
// Add new posts at the top
if (newPosts.length > 0) {
const postList = contentEl.querySelector('.post-list')
if (postList) {
const newPostsHtml = newPosts.map(post => {
const rkey = post.uri.split('/').pop()
const date = new Date(post.createdAt).toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
return `
<li class="post-item post-item-new">
<a href="/post/${rkey}" class="post-link">
<span class="post-title">${escapeHtml(post.title)}</span>
<span class="post-date">${date}</span>
</a>
</li>
`
}).join('')
postList.insertAdjacentHTML('afterbegin', newPostsHtml)
}
}
} catch (err) {
console.error('Failed to refresh posts from API:', err)
}
}
function setupEventHandlers(): void {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
// Blog tab click - close browser and go back
if (target.id === 'blog-tab' || target.closest('#blog-tab')) {
if (browserMode) {
e.preventDefault()
closeBrowser()
return
}
}
// Browser tab button
if (target.id === 'browser-tab' || target.closest('#browser-tab')) {
e.preventDefault()
const btn = (target.closest('#browser-tab') || target) as HTMLElement
const handle = btn.dataset.handle || config.handle
openBrowser(handle)
return
}
// PDS tab button - toggle dropdown
if (target.id === 'pds-tab' || target.closest('#pds-tab')) {
e.preventDefault()
e.stopPropagation()
const dropdown = document.getElementById('pds-dropdown')
if (dropdown) {
dropdown.classList.toggle('show')
}
return
}
// PDS option selection
const pdsOption = target.closest('.pds-option') as HTMLElement
if (pdsOption) {
e.preventDefault()
const selectedNetwork = pdsOption.dataset.network
if (selectedNetwork && selectedNetwork !== browserNetwork) {
browserNetwork = selectedNetwork
localStorage.setItem('browserNetwork', selectedNetwork)
// Update network config for API and Auth
const networkConfig = networks[selectedNetwork]
if (networkConfig) {
setNetworkConfig(networkConfig)
setAuthNetworkConfig(networkConfig)
}
// Update UI
updatePdsSelector()
// Reload browser if in browser mode
if (browserMode) {
loadBrowserContent()
}
}
// Close dropdown
const dropdown = document.getElementById('pds-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
return
}
// Close PDS dropdown when clicking outside
if (!target.closest('#pds-selector')) {
const dropdown = document.getElementById('pds-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
}
// Lang button - toggle dropdown
if (target.id === 'lang-btn' || target.closest('#lang-btn')) {
e.preventDefault()
e.stopPropagation()
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.toggle('show')
}
return
}
// Lang option selection
const langOption = target.closest('.lang-option') as HTMLElement
if (langOption) {
e.preventDefault()
const selectedLang = langOption.dataset.lang
if (selectedLang && selectedLang !== currentLang) {
currentLang = selectedLang
localStorage.setItem('preferredLang', selectedLang)
updateLangSelector()
applyTranslation()
applyTitleTranslations()
}
// Close dropdown
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
return
}
// Close lang dropdown when clicking outside
if (!target.closest('#lang-selector')) {
const dropdown = document.getElementById('lang-dropdown')
if (dropdown) {
dropdown.classList.remove('show')
}
}
// JSON button click (on post detail page)
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
if (jsonBtn) {
const href = jsonBtn.getAttribute('href')
if (href?.startsWith('/at/')) {
e.preventDefault()
const parts = href.split('/').filter(Boolean)
// /at/handle/collection/rkey
if (parts.length >= 4) {
openBrowser(parts[1], null, parts[2], parts[3])
}
return
}
}
// Service item click (on static page or in browser view)
if (target.closest('.service-item')) {
e.preventDefault()
const link = target.closest('.service-item') as HTMLAnchorElement
const href = link.getAttribute('href')
if (href) {
// Parse /at/handle/service from href
const parts = href.split('/').filter(Boolean)
if (parts[0] === 'at' && parts[1] && parts[2]) {
openBrowser(parts[1], parts[2])
}
}
return
}
// Links inside browser content
const contentEl = document.getElementById('content')
if (browserMode && contentEl?.contains(target)) {
const link = target.closest('a')
if (link) {
const href = link.getAttribute('href')
if (href?.startsWith('/at/')) {
e.preventDefault()
const parts = href.split('/').filter(Boolean)
// /at/handle, /at/handle/service, /at/handle/collection, /at/handle/collection/rkey
if (parts.length >= 2) {
const handle = parts[1]
let service = null
let collection = null
let rkey = null
if (parts.length === 3) {
// Could be service or collection
const segment = parts[2]
if (segment.split('.').length <= 2) {
service = segment
} else {
collection = segment
}
} else if (parts.length >= 4) {
collection = parts[2]
rkey = parts[3]
}
openBrowser(handle, service, collection, rkey)
}
}
}
}
})
}
async function render(): Promise<void> {
const route = parseRoute(window.location.pathname)
const appEl = document.getElementById('app')
const isStatic = appEl?.dataset.static === 'true'
const profileEl = document.getElementById('profile')
const contentEl = document.getElementById('content')
const headerEl = document.getElementById('header')
const footerEl = document.getElementById('footer')
if (!profileEl || !contentEl || !headerEl) return
const isLoggedIn = !!authSession
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'
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) => {
openBrowser(newHandle)
},
onLogin: async () => {
const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle
try {
await login(inputHandle)
} catch (err) {
console.error('Login error:', err)
alert('Login failed: ' + err)
}
},
onLogout: async () => {
await logout()
window.location.reload()
}
}, true)
// Update tabs
const tabsEl = document.querySelector('.mode-tabs')
if (tabsEl) {
// Add Post tab if logged in
if (isLoggedIn && !tabsEl.querySelector('a[href="/post"]')) {
tabsEl.insertAdjacentHTML('beforeend', '<a href="/post" class="tab">Post</a>')
}
// Add PDS selector if not present
if (!tabsEl.querySelector('#pds-selector')) {
tabsEl.insertAdjacentHTML('beforeend', renderPdsSelector())
}
}
// For post pages, add edit button if logged in and can edit
if (route.type === 'post' && isLoggedIn && route.rkey) {
addEditButtonToStaticPost(config.collection, route.rkey, authSession!)
}
// For post pages, load discussion posts
if (route.type === 'post') {
const discussionContainer = document.getElementById('discussion-posts')
if (discussionContainer) {
const postUrl = discussionContainer.dataset.postUrl
if (postUrl) {
loadDiscussionPosts(discussionContainer.parentElement!, postUrl)
}
}
}
// For blog top page, check for new posts from API and merge
if (route.type === 'blog') {
refreshPostListFromAPI()
// Add lang selector above post list
const postList = contentEl?.querySelector('.post-list')
if (postList && !document.getElementById('lang-selector')) {
const contentHeader = document.createElement('div')
contentHeader.className = 'content-header'
contentHeader.innerHTML = renderLangSelector()
postList.parentNode?.insertBefore(contentHeader, postList)
}
}
// For post detail page, sync lang selector state and apply translation
if (route.type === 'post') {
// Update lang selector to match current language
updateLangSelector()
// Apply translation based on current language preference
const translationScript = document.getElementById('translation-data')
if (translationScript) {
applyTranslation()
}
}
// For blog index page, sync lang selector state and apply title translations
if (route.type === 'blog') {
updateLangSelector()
applyTitleTranslations()
}
return // Skip content re-rendering
}
// Footer
if (footerEl) {
footerEl.innerHTML = renderFooter(config.handle)
}
// Header with login
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => {
openBrowser(newHandle)
},
onLogin: async () => {
const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle
try {
await login(inputHandle)
} catch (err) {
console.error('Login error:', err)
alert('Login failed: ' + err)
}
},
onLogout: async () => {
await logout()
window.location.reload()
}
})
// Route handling
switch (route.type) {
case 'new':
if (isLoggedIn) {
profileEl.innerHTML = renderTabs('new', isLoggedIn)
mountPostForm(contentEl, config.collection, () => {
window.location.href = '/'
})
} else {
window.location.href = '/'
}
break
case 'post':
try {
const profile = await getProfile(config.handle)
const webUrl = networks[config.network]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(config.handle)
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, config.handle, config.collection, canEdit, config.siteUrl, config.network)
} else {
contentEl.innerHTML = '<p>Post not found</p>'
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
break
case 'user-blog':
// /@{handle} - Any user's blog
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 posts = await listRecords(profile.did, config.collection)
mountPostList(contentEl, posts, userHandle)
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
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 = '<p>Post not found</p>'
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
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 = `<p class="error">Failed to load: ${err}</p>`
}
break
}
}
async function init(): Promise<void> {
const [configData, networksData, linksData] = await Promise.all([loadConfig(), loadNetworks(), loadLinks()])
config = configData
networks = networksData
footerLinks = linksData
// Set page title
document.title = config.title || 'ailog'
// Set theme color
if (config.color) {
document.documentElement.style.setProperty('--btn-color', config.color)
}
// Initialize browser network from localStorage or default to config.network
browserNetwork = localStorage.getItem('browserNetwork') || config.network
// Initialize language preference from localStorage (default: en)
currentLang = localStorage.getItem('preferredLang') || 'en'
// Set network config based on selected browser network
const selectedNetworkConfig = networks[browserNetwork]
if (selectedNetworkConfig) {
setNetworkConfig(selectedNetworkConfig)
setAuthNetworkConfig(selectedNetworkConfig)
}
// Handle OAuth callback
const callbackSession = await handleOAuthCallback()
if (callbackSession) {
authSession = callbackSession
} else {
authSession = await restoreSession()
}
// Setup event handlers
setupEventHandlers()
// Initial render
await render()
// Handle browser navigation
window.addEventListener('popstate', () => render())
}
init()