Files
log/src/web/main.ts
2026-01-23 00:29:09 +09:00

767 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './styles/main.css'
import './styles/card.css'
import './styles/card-migrate.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getRse, getLinks } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks } from './lib/auth'
import { validateRecord } from './lib/lexicon'
import { renderHeader } from './components/header'
import { renderProfile } from './components/profile'
import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
import { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer'
import { renderChatListPage, renderChatThreadPage, renderChatEditForm } from './components/chat'
import { renderCardPage } from './components/card'
import { renderRsePage } from './components/rse'
import { renderLinkPage, renderLinkButtons } from './components/link'
import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate'
import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')!
let currentHandle = ''
let isFirstRender = true
// 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)) {
// Check handleDomains if configured
if (network.handleDomains) {
for (const hd of network.handleDomains) {
if (handle.endsWith(`.${hd}`)) {
return network.web
}
}
}
// Check if handle domain matches network's web domain
const webDomain = network.web?.replace(/^https?:\/\//, '')
if (webDomain && handle.endsWith(`.${webDomain}`)) {
return network.web
}
}
// Default to first network's web
const firstNetwork = Object.values(networks)[0]
return firstNetwork?.web
}
async function render(route: Route): Promise<void> {
// Skip loading indicator on first render for faster perceived performance
if (!isFirstRender) {
showLoading(app)
}
try {
const config = await getConfig()
// Apply theme color from config
if (config.color) {
document.documentElement.style.setProperty('--btn-color', config.color)
}
// Set page title from config
if (config.title) {
document.title = config.title
}
// Check OAuth enabled
const oauthEnabled = config.oauth !== false
// 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 (oauthEnabled && (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state'))) {
await handleCallback()
}
// Restore session from storage (skip if oauth disabled)
if (oauthEnabled) {
await restoreSession()
}
// Redirect logged-in user from root to their user page
if (route.type === 'home' && isLoggedIn()) {
const loggedInHandle = getLoggedInHandle()
if (loggedInHandle) {
navigate({ type: 'user', handle: loggedInHandle })
return
}
}
// Determine handle and whether to use local data only (no API calls)
let handle: string
let localOnly: boolean
let did: string | null
if (route.type === 'home') {
handle = config.handle
localOnly = true
did = config.did || null
} else if (route.handle) {
handle = route.handle
localOnly = handle === config.handle
did = localOnly ? (config.did || null) : null
} else {
handle = config.handle
localOnly = true
did = config.did || null
}
currentHandle = handle
// Resolve handle to DID only for remote users
if (!did) {
did = await resolveHandle(handle)
}
if (!did) {
app.innerHTML = `
${renderHeader(handle, oauthEnabled)}
<div class="error">Could not resolve handle: ${handle}</div>
${renderFooter(handle, config.repoUrl)}
`
setupEventHandlers()
return
}
// Load profile and collections (local only for admin, remote for others)
const [profile, webUrl, collections] = await Promise.all([
getProfile(did, localOnly),
getWebUrl(handle),
describeRepo(did)
])
// Load posts (local only for admin, remote for others)
const posts = await getPosts(did, config.collection, localOnly)
// Collect available languages from posts (used for non-chat pages)
const availableLangs = new Set<string>()
for (const post of posts) {
// Add original language (default: ja for Japanese posts)
const postLang = post.value.lang || 'ja'
availableLangs.add(postLang)
// Add translation languages
if (post.value.translations) {
for (const lang of Object.keys(post.value.translations)) {
availableLangs.add(lang)
}
}
}
let langList = Array.from(availableLangs)
// Build page
let html = renderHeader(handle, oauthEnabled)
// Mode tabs (Blog/Browser/Post/Chat/Link/PDS)
const activeTab = route.type === 'postpage' ? 'post' :
(route.type === 'chat' || route.type === 'chat-thread' || route.type === 'chat-edit') ? 'chat' :
route.type === 'link' ? 'link' :
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab, localOnly)
// Check if logged-in user owns this content
const loggedInDid = getLoggedInDid()
const isOwner = isLoggedIn() && loggedInDid === did
// Profile section
if (profile) {
html += await renderProfile(did, profile, handle, webUrl, localOnly, collections)
}
// Content section based on route type
let currentRecord: { uri: string; cid: string; value: unknown } | null = null
let cardMigrationState: Awaited<ReturnType<typeof checkMigrationStatus>> | null = null
if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view
currentRecord = await getRecord(did, route.collection, route.rkey)
if (currentRecord) {
html += `<div id="content">${renderRecordDetail(currentRecord, route.collection, isOwner)}</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 (use pre-loaded collections)
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 (use pre-loaded collections)
const pds = await getPds(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, localOnly)
html += renderLangSelector(langList)
if (post) {
html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>`
} else {
html += `<div id="content" class="error">Post not found</div>`
}
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'postpage') {
// Post form page
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'card') {
// Card collection page
const cardCollection = config.cardCollection || 'ai.syui.card.user'
const cards = await getCards(did, cardCollection)
html += `<div id="content">${renderCardPage(cards, handle, cardCollection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'card-old') {
// Card migration page
cardMigrationState = await checkMigrationStatus(did)
html += `<div id="content">${renderMigrationPage(cardMigrationState, handle, isOwner)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'rse') {
// RSE page
const rseData = await getRse(did)
html += `<div id="content">${renderRsePage(rseData, handle)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'link') {
// Link page
const links = await getLinks(did)
html += `<div id="content">${renderLinkPage(links, handle, isOwner)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat') {
// Chat list page - show threads started by this user
if (!config.bot) {
html += `<div id="content" class="error">Bot not configured in config.json</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else {
const botDid = config.bot.did
const botHandle = config.bot.handle
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
// Load messages and profiles in parallel
const [chatMessages, botProfile, pds] = await Promise.all([
getChatMessages(did, botDid, chatCollection),
getProfile(botDid, false),
getPds(did)
])
// Collect available languages from chat messages
const chatLangs = new Set<string>()
for (const msg of chatMessages) {
const msgLang = msg.value.lang || 'ja'
chatLangs.add(msgLang)
if (msg.value.translations) {
for (const lang of Object.keys(msg.value.translations)) {
chatLangs.add(lang)
}
}
}
langList = Array.from(chatLangs)
html += renderLangSelector(langList)
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
}
} else if (route.type === 'chat-thread' && route.rkey) {
// Chat thread page - show full conversation
if (!config.bot) {
html += `<div id="content" class="error">Bot not configured in config.json</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else {
const botDid = config.bot.did
const botHandle = config.bot.handle
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
// Load messages and profiles in parallel
const [chatMessages, botProfile, pds] = await Promise.all([
getChatMessages(did, botDid, chatCollection),
getProfile(botDid, false),
getPds(did)
])
// Collect available languages from chat messages
const chatLangs = new Set<string>()
for (const msg of chatMessages) {
const msgLang = msg.value.lang || 'ja'
chatLangs.add(msgLang)
if (msg.value.translations) {
for (const lang of Object.keys(msg.value.translations)) {
chatLangs.add(lang)
}
}
}
langList = Array.from(chatLangs)
html += renderLangSelector(langList)
html += `<div id="content">${renderChatThreadPage(chatMessages, route.rkey, did, handle, botDid, botHandle, profile, botProfile, pds || undefined, chatCollection, loggedInDid)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
}
} else if (route.type === 'chat-edit' && route.rkey) {
// Chat edit page
if (!config.bot) {
html += `<div id="content" class="error">Bot not configured in config.json</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (!isOwner) {
html += `<div id="content" class="error">You can only edit your own messages</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
} else {
const botDid = config.bot.did
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
// Get the specific message
const chatMessages = await getChatMessages(did, botDid, chatCollection)
const targetUri = `at://${did}/${chatCollection}/${route.rkey}`
const message = chatMessages.find(m => m.uri === targetUri)
if (!message) {
html += `<div id="content" class="error">Message not found</div>`
} else {
html += `<div id="content">${renderChatEditForm(message, chatCollection, handle)}</div>`
}
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
}
} else {
// User page: compact collection buttons + link buttons + posts
const links = await getLinks(did)
html += `<div id="browser" class="browser-row">${renderCollectionButtons(collections, handle)}${renderLinkButtons(links)}</div>`
// Language selector above content
html += renderLangSelector(langList)
// Use pre-loaded posts
html += `<div id="content">${renderPostList(posts, handle)}</div>`
}
html += renderFooter(handle, config.repoUrl)
app.innerHTML = html
hideLoading(app)
setupEventHandlers()
// Setup mode tabs (PDS selector + Lang selector)
await setupModeTabs(
(_network) => {
// Refresh when network is changed
render(parseRoute())
},
langList,
(_lang) => {
// Refresh when language is changed
render(parseRoute())
}
)
// Setup post form on postpage
if (route.type === 'postpage' && isLoggedIn()) {
setupPostForm(config.collection, () => {
// Navigate to user page on success
navigate({ type: 'user', handle })
})
}
// Setup record delete button
if (isOwner) {
setupRecordDelete(handle, route)
setupPostEdit(config.collection)
}
// Setup chat edit form
if (route.type === 'chat-edit' && isOwner) {
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
setupChatEdit(chatCollection, handle)
}
// Setup link edit
if (route.type === 'link' && isOwner) {
setupLinkEdit()
}
// Setup validate button for record detail
if (currentRecord) {
setupValidateButton(currentRecord)
}
// Setup post detail (translation toggle, discussion)
if (route.type === 'post') {
const contentEl = document.getElementById('content')
if (contentEl) {
setupPostDetail(contentEl)
}
}
// Setup card migration button
if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) {
setupMigrationButton(
cardMigrationState.oldApiUser,
cardMigrationState.oldApiCards,
() => render(parseRoute()) // Refresh on success
)
}
} catch (error) {
console.error('Render error:', error)
app.innerHTML = `
${renderHeader(currentHandle, false)}
<div class="error">Error: ${error}</div>
${renderFooter(currentHandle, undefined)}
`
setupEventHandlers()
} finally {
hideLoading(app)
isFirstRender = false
}
}
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()
})
}
// Setup validate button for record detail
function setupValidateButton(record: { value: unknown }): void {
const validateBtn = document.getElementById('validate-btn')
const resultDiv = document.getElementById('validate-result')
if (!validateBtn || !resultDiv) return
validateBtn.addEventListener('click', async () => {
const collection = validateBtn.getAttribute('data-collection')
if (!collection) return
// Show loading state
validateBtn.textContent = 'Validating...'
;(validateBtn as HTMLButtonElement).disabled = true
resultDiv.innerHTML = ''
try {
const result = await validateRecord(collection, record.value)
if (result.valid) {
resultDiv.innerHTML = `<span class="validate-valid">✓ Valid</span>`
} else {
resultDiv.innerHTML = `
<span class="validate-invalid">✗ Invalid</span>
<span class="validate-error">${result.error || 'Unknown error'}</span>
`
}
} catch (err) {
resultDiv.innerHTML = `
<span class="validate-invalid">✗ Error</span>
<span class="validate-error">${err}</span>
`
}
validateBtn.textContent = 'Validate'
;(validateBtn as HTMLButtonElement).disabled = false
})
}
// Setup record delete button
function setupRecordDelete(handle: string, _route: Route): void {
const deleteBtn = document.getElementById('record-delete-btn')
if (!deleteBtn) return
deleteBtn.addEventListener('click', async () => {
const collection = deleteBtn.getAttribute('data-collection')
const rkey = deleteBtn.getAttribute('data-rkey')
if (!collection || !rkey) return
if (!confirm('Are you sure you want to delete this record?')) return
try {
deleteBtn.textContent = 'Deleting...'
;(deleteBtn as HTMLButtonElement).disabled = true
await deleteRecord(collection, rkey)
// Navigate back to collection list
navigate({ type: 'collection', handle, collection })
} catch (err) {
console.error('Delete failed:', err)
alert('Delete failed: ' + err)
deleteBtn.textContent = 'Delete'
;(deleteBtn as HTMLButtonElement).disabled = false
}
})
}
// Setup post edit form
function setupPostEdit(collection: string): void {
const editBtn = document.getElementById('post-edit-btn')
const editForm = document.getElementById('post-edit-form')
const postDisplay = document.getElementById('post-display')
const cancelBtn = document.getElementById('post-edit-cancel')
const saveBtn = document.getElementById('post-edit-save')
const titleInput = document.getElementById('post-edit-title') as HTMLInputElement
const contentInput = document.getElementById('post-edit-content') as HTMLTextAreaElement
if (!editBtn || !editForm) return
// Show edit form
editBtn.addEventListener('click', () => {
if (postDisplay) postDisplay.style.display = 'none'
editForm.style.display = 'block'
editBtn.style.display = 'none'
})
// Cancel edit
cancelBtn?.addEventListener('click', () => {
editForm.style.display = 'none'
if (postDisplay) postDisplay.style.display = ''
editBtn.style.display = ''
})
// Save edit
saveBtn?.addEventListener('click', async () => {
const rkey = saveBtn.getAttribute('data-rkey')
if (!rkey || !titleInput || !contentInput) return
const title = titleInput.value.trim()
const content = contentInput.value.trim()
if (!title || !content) {
alert('Title and content are required')
return
}
try {
saveBtn.textContent = 'Saving...'
;(saveBtn as HTMLButtonElement).disabled = true
await updatePost(collection, rkey, title, content)
// Refresh the page
render(parseRoute())
} catch (err) {
console.error('Update failed:', err)
alert('Update failed: ' + err)
saveBtn.textContent = 'Save'
;(saveBtn as HTMLButtonElement).disabled = false
}
})
}
// Setup chat edit form
function setupChatEdit(collection: string, handle: string): void {
const form = document.getElementById('chat-edit-form') as HTMLFormElement
const contentInput = document.getElementById('chat-edit-content') as HTMLTextAreaElement
const saveBtn = document.getElementById('chat-edit-save') as HTMLButtonElement
const statusEl = document.getElementById('chat-edit-status') as HTMLDivElement
if (!form || !saveBtn) return
form.addEventListener('submit', async (e) => {
e.preventDefault()
const rkey = saveBtn.getAttribute('data-rkey')
if (!rkey || !contentInput) return
const content = contentInput.value.trim()
if (!content) {
alert('Content is required')
return
}
try {
saveBtn.textContent = 'Saving...'
saveBtn.disabled = true
await updateChat(collection, rkey, content)
statusEl.innerHTML = '<span class="chat-edit-success">Saved!</span>'
// Navigate back to chat thread
setTimeout(() => {
navigate({ type: 'chat-thread', handle, rkey })
}, 1000)
} catch (err) {
console.error('Update failed:', err)
statusEl.innerHTML = `<span class="chat-edit-error">Error: ${err}</span>`
saveBtn.textContent = 'Save'
saveBtn.disabled = false
}
})
}
// Setup link edit
function setupLinkEdit(): void {
const editBtn = document.getElementById('link-edit-btn')
const editForm = document.getElementById('link-edit-form')
const linkDisplay = document.getElementById('link-display')
const editList = document.getElementById('link-edit-list')
const cancelBtn = document.getElementById('link-edit-cancel')
const saveBtn = document.getElementById('link-edit-save')
const addBtn = document.getElementById('link-add-btn')
const addService = document.getElementById('link-add-service') as HTMLSelectElement
const addUsername = document.getElementById('link-add-username') as HTMLInputElement
const statusEl = document.getElementById('link-edit-status')
if (!editBtn || !editForm || !editList) return
let linkIndex = editList.querySelectorAll('.link-edit-item').length
// Show edit form
editBtn.addEventListener('click', () => {
if (linkDisplay) linkDisplay.style.display = 'none'
editForm.style.display = 'block'
editBtn.style.display = 'none'
})
// Cancel edit
cancelBtn?.addEventListener('click', () => {
editForm.style.display = 'none'
if (linkDisplay) linkDisplay.style.display = ''
editBtn.style.display = ''
})
// Add new link
addBtn?.addEventListener('click', () => {
const service = addService.value
const username = addUsername.value.trim()
if (!username) return
const serviceNames: Record<string, string> = { github: 'GitHub', x: 'X', youtube: 'YouTube' }
const options = ['github', 'x', 'youtube'].map(s =>
`<option value="${s}" ${s === service ? 'selected' : ''}>${serviceNames[s]}</option>`
).join('')
const newItem = document.createElement('div')
newItem.className = 'link-edit-item'
newItem.dataset.index = String(linkIndex++)
newItem.innerHTML = `
<select class="link-edit-service" data-index="${newItem.dataset.index}">
${options}
</select>
<input type="text" class="link-edit-username" data-index="${newItem.dataset.index}" value="${username}" placeholder="username">
<button type="button" class="link-edit-remove" data-index="${newItem.dataset.index}">×</button>
`
editList.appendChild(newItem)
addUsername.value = ''
})
// Remove link (event delegation)
editList.addEventListener('click', (e) => {
const target = e.target as HTMLElement
if (target.classList.contains('link-edit-remove')) {
const item = target.closest('.link-edit-item')
item?.remove()
}
})
// Save links
saveBtn?.addEventListener('click', async () => {
const items = editList.querySelectorAll('.link-edit-item')
const links: { service: string; username: string }[] = []
items.forEach(item => {
const service = (item.querySelector('.link-edit-service') as HTMLSelectElement)?.value
const username = (item.querySelector('.link-edit-username') as HTMLInputElement)?.value.trim()
if (service && username) {
links.push({ service, username })
}
})
// Also include pending add form input (if user forgot to click +)
if (addService && addUsername) {
const pendingUsername = addUsername.value.trim()
if (pendingUsername) {
links.push({ service: addService.value, username: pendingUsername })
}
}
try {
saveBtn.textContent = 'Saving...'
;(saveBtn as HTMLButtonElement).disabled = true
await updateLinks(links)
if (statusEl) statusEl.innerHTML = '<span class="link-edit-success">Saved!</span>'
// Refresh page
setTimeout(() => {
render(parseRoute())
}, 1000)
} catch (err) {
console.error('Update failed:', err)
if (statusEl) statusEl.innerHTML = `<span class="link-edit-error">Error: ${err}</span>`
saveBtn.textContent = 'Save'
;(saveBtn as HTMLButtonElement).disabled = false
}
})
}
// Initial render
render(parseRoute())
// Handle route changes
onRouteChange(render)