fix post generate

This commit is contained in:
2026-01-15 19:46:01 +09:00
parent 162072d980
commit 9980e596ca
13 changed files with 1834 additions and 120 deletions

View File

@@ -6,9 +6,21 @@ 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 { parseRoute, type Route } from './lib/router.js'
import type { AppConfig, Networks } from './types.js'
let authSession: AuthSession | null = null
let config: AppConfig
// 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')
@@ -30,25 +42,504 @@ function renderFooter(handle: string): string {
`
}
function renderTabs(handle: string, mode: string | null, isLoggedIn: boolean): string {
const blogActive = !mode || mode === 'blog' ? 'active' : ''
const browserActive = mode === 'browser' ? 'active' : ''
const postActive = mode === 'post' ? 'active' : ''
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
let tabs = `
<a href="?handle=${handle}" class="tab ${blogActive}">Blog</a>
<a href="?mode=browser&handle=${handle}" class="tab ${browserActive}">Browser</a>
<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="${config.handle}">Browser</button>
`
if (isLoggedIn) {
tabs += `<a href="?mode=post&handle=${handle}" class="tab ${postActive}">Post</a>`
tabs += `<a href="/post" class="tab ${activeTab === 'new' ? 'active' : ''}">Post</a>`
}
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
// 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
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'
}
})
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// 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
}
// 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)
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', '<a href="/post" class="tab">Post</a>')
}
}
// 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 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)
} 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 {
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 = `<p class="error">Failed to load: ${err}</p>`
}
break
}
}
async function init(): Promise<void> {
const [config, networks] = await Promise.all([loadConfig(), loadNetworks()])
const [configData, networks] = await Promise.all([loadConfig(), loadNetworks()])
config = configData
// Set page title
document.title = config.title || 'ailog'
@@ -70,102 +561,17 @@ async function init(): Promise<void> {
if (callbackSession) {
authSession = callbackSession
} else {
// Try to restore existing session
authSession = await restoreSession()
}
const params = new URLSearchParams(window.location.search)
const mode = params.get('mode')
const rkey = params.get('rkey')
const collection = params.get('collection')
const service = params.get('service')
const handle = params.get('handle') || config.handle
// Setup event handlers
setupEventHandlers()
const profileEl = document.getElementById('profile')
const contentEl = document.getElementById('content')
const headerEl = document.getElementById('header')
const footerEl = document.getElementById('footer')
// Initial render
await render()
if (!profileEl || !contentEl || !headerEl) return
// Footer
if (footerEl) {
footerEl.innerHTML = renderFooter(config.handle)
}
const isLoggedIn = !!authSession
// Header with login
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => {
const currentMode = params.get('mode')
if (currentMode === 'browser') {
window.location.href = `?mode=browser&handle=${newHandle}`
} else {
window.location.href = `?handle=${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()
}
})
// Post mode (requires login)
if (mode === 'post' && isLoggedIn) {
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
mountPostForm(contentEl, config.collection, () => {
window.location.href = `?handle=${handle}`
})
return
}
// AT Browser mode
if (mode === 'browser') {
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
const loginDid = authSession?.did || null
await mountAtBrowser(contentEl, handle, collection, rkey, service, loginDid)
return
}
// Blog mode (default)
try {
const profile = await getProfile(handle)
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile)
// Add services
const servicesHtml = await renderServices(handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
if (rkey) {
const post = await getRecord(profile.did, config.collection, rkey)
if (post) {
const canEdit = isLoggedIn && authSession?.did === profile.did
mountPostDetail(contentEl, post, handle, config.collection, canEdit)
} else {
contentEl.innerHTML = '<p>Post not found</p>'
}
} else {
const posts = await listRecords(profile.did, config.collection)
mountPostList(contentEl, posts)
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
// Handle browser navigation
window.addEventListener('popstate', () => render())
}
init()