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 type { AppConfig, Networks } from './types.js' let authSession: AuthSession | null = null let config: AppConfig let networks: Networks = {} let browserNetwork: string = '' // Network for AT Browser // 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 { const res = await fetch('/config.json') return res.json() } async function loadNetworks(): Promise { const res = await fetch('/networks.json') return res.json() } function renderFooter(handle: string): string { const parts = handle.split('.') const username = parts[0] || handle return `

© ${username}

` } function renderPdsSelector(): string { const networkKeys = Object.keys(networks) const options = networkKeys.map(key => { const isSelected = key === browserNetwork return `
${escapeHtml(key)}
` }).join('') return `
${options}
` } 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 renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string { let tabs = ` Blog ` if (isLoggedIn) { tabs += `Post` } tabs += renderPdsSelector() return `
${tabs}
` } // 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 // 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 = `
` // 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 { 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 { 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 = `

Edit Post

` 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 { 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() 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() 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 `
  • ${escapeHtml(post.title)}
  • ` }).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 const networkConfig = networks[selectedNetwork] if (networkConfig) { setNetworkConfig(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') } } // 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 { 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) const isStaticRoute = route.type === 'blog' || route.type === 'post' if (isStatic && isStaticRoute) { // 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 to show Post tab if logged in if (isLoggedIn) { const tabsEl = document.querySelector('.mode-tabs') if (tabsEl && !tabsEl.querySelector('a[href="/post"]')) { tabsEl.insertAdjacentHTML('beforeend', 'Post') } } // 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() } 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) profileEl.innerHTML = renderTabs('blog', isLoggedIn) const profileContentEl = document.createElement('div') profileEl.appendChild(profileContentEl) mountProfile(profileContentEl, profile) 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 = '

    Post not found

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

    Failed to load: ${err}

    ` } break case 'blog': default: try { const profile = await getProfile(config.handle) profileEl.innerHTML = renderTabs('blog', isLoggedIn) const profileContentEl = document.createElement('div') profileEl.appendChild(profileContentEl) mountProfile(profileContentEl, profile) const servicesHtml = await renderServices(config.handle) profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) const posts = await listRecords(profile.did, config.collection) mountPostList(contentEl, posts) } catch (err) { console.error(err) contentEl.innerHTML = `

    Failed to load: ${err}

    ` } break } } async function init(): Promise { const [configData, networksData] = await Promise.all([loadConfig(), loadNetworks()]) config = configData networks = networksData // 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 // Set network config for blog (uses config.network) const blogNetworkConfig = networks[config.network] if (blogNetworkConfig) { setNetworkConfig(blogNetworkConfig) setAuthNetworkConfig(blogNetworkConfig) } // 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()