diff --git a/.gitignore b/.gitignore
index e87a534..0062c02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,3 @@ dist
node_modules
package-lock.json
repos
-content/
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json
new file mode 100644
index 0000000..2454a1b
--- /dev/null
+++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json
@@ -0,0 +1,7 @@
+{
+ "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
+ "cid": "bafyreiaww3o6uoayzosgkymc7cazaja6ebyw4ahtk3dpvqq5xd3m6juyz4",
+ "title": "ailogを作り直した",
+ "content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. 記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる\n2. コメントはurlの言及を検索して表示",
+ "createdAt": "2026-01-15T13:59:52.367Z"
+}
\ No newline at end of file
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/collections.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/collections.json
new file mode 100644
index 0000000..ce1adc2
--- /dev/null
+++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/collections.json
@@ -0,0 +1,11 @@
+[
+ "ai.syui.log",
+ "ai.syui.log.chat",
+ "ai.syui.log.post",
+ "ai.syui.rse.user",
+ "app.bsky.actor.profile",
+ "app.bsky.feed.post",
+ "app.bsky.feed.repost",
+ "app.bsky.graph.follow",
+ "chat.bsky.actor.declaration"
+]
\ No newline at end of file
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/bsky.app.png b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/bsky.app.png
new file mode 100644
index 0000000..a5ca7ee
Binary files /dev/null and b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/bsky.app.png differ
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syui.ai.png b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syui.ai.png
new file mode 100644
index 0000000..fae8d5a
Binary files /dev/null and b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syui.ai.png differ
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/profile.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/profile.json
new file mode 100644
index 0000000..c7441a2
--- /dev/null
+++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/profile.json
@@ -0,0 +1,21 @@
+{
+ "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
+ "handle": "syui.syui.ai",
+ "displayName": "syui",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u@jpeg",
+ "associated": {
+ "lists": 0,
+ "feedgens": 0,
+ "starterPacks": 0,
+ "labeler": false,
+ "activitySubscription": {
+ "allowSubscriptions": "followers"
+ }
+ },
+ "labels": [],
+ "createdAt": "2025-09-19T06:17:42.000Z",
+ "indexedAt": "2025-09-19T06:17:42.000Z",
+ "followersCount": 1,
+ "followsCount": 1,
+ "postsCount": 74
+}
\ No newline at end of file
diff --git a/public/config.json b/public/config.json
index 353dab9..946bd70 100644
--- a/public/config.json
+++ b/public/config.json
@@ -1,7 +1,8 @@
{
"title": "syui.ai",
- "handle": "syui.ai",
+ "handle": "syui.syui.ai",
"collection": "ai.syui.log.post",
- "network": "bsky.social",
- "color": "#0066cc"
+ "network": "syu.is",
+ "color": "#0066cc",
+ "siteUrl": "https://syui.ai"
}
diff --git a/scripts/generate.ts b/scripts/generate.ts
index 05330c4..c84918c 100644
--- a/scripts/generate.ts
+++ b/scripts/generate.ts
@@ -1,38 +1,8 @@
import * as fs from 'fs'
import * as path from 'path'
import { marked, Renderer } from 'marked'
-
-// Types
-interface AppConfig {
- title: string
- handle: string
- collection: string
- network: string
- color?: string
-}
-
-interface Networks {
- [key: string]: {
- plc: string
- bsky: string
- }
-}
-
-interface Profile {
- did: string
- handle: string
- displayName?: string
- description?: string
- avatar?: string
-}
-
-interface BlogPost {
- uri: string
- cid: string
- title: string
- content: string
- createdAt: string
-}
+import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts'
+import { escapeHtml } from '../src/lib/utils.ts'
// Highlight.js for syntax highlighting (core + common languages only)
let hljs: typeof import('highlight.js/lib/core').default
@@ -100,14 +70,6 @@ function setupMarked() {
})
}
-function escapeHtml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
-}
-
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
@@ -445,10 +407,23 @@ function generatePostListHtml(posts: BlogPost[]): string {
return `
`
}
-function generatePostDetailHtml(post: BlogPost, handle: string, collection: string): string {
+// Map network to app URL for discussion links
+function getAppUrl(network: string): string {
+ if (network === 'syu.is') {
+ return 'https://syu.is'
+ }
+ return 'https://bsky.app'
+}
+
+function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
const content = marked.parse(post.content) as string
+ // Use siteUrl from config, or construct from handle
+ const baseSiteUrl = siteUrl || `https://${handle}`
+ const postUrl = `${baseSiteUrl}/post/${rkey}/`
+ const appUrl = getAppUrl(network)
+ const searchUrl = `${appUrl}/search?q=${encodeURIComponent(postUrl)}`
return `
@@ -461,6 +436,15 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
${content}
+
`
}
@@ -500,7 +484,7 @@ function generatePostPageContent(profile: Profile, post: BlogPost, config: AppCo
${generateServicesHtml(profile.did, config.handle, collections)}
- ${generatePostDetailHtml(post, config.handle, config.collection)}
+ ${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
${generateFooterHtml(config.handle)}
@@ -595,27 +579,20 @@ async function generate() {
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
console.log(`Found ${localPosts.length} posts from local`)
- // Merge: API is the source of truth for what exists
- // - If post exists in API and local: use local (may have edits)
- // - If post exists in API only: use API
- // - If post exists in local only: skip (was deleted from API)
+ // Merge: API is the source of truth
+ // - If post exists in API: always use API (has latest edits)
+ // - If post exists in local only: keep if not deleted (for posts beyond API limit)
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
- const localRkeys = new Set(localPosts.map(p => p.uri.split('/').pop()))
- // Local posts that still exist in API
- const validLocalPosts = localPosts.filter(p => apiRkeys.has(p.uri.split('/').pop()))
- // API posts that don't exist locally
- const newApiPosts = apiPosts.filter(p => !localRkeys.has(p.uri.split('/').pop()))
+ // Local posts that don't exist in API (older posts beyond 100 limit)
+ // Note: these might be deleted posts, so we keep them cautiously
+ const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
- posts = [...validLocalPosts, ...newApiPosts].sort((a, b) =>
+ posts = [...apiPosts, ...oldLocalPosts].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
- const deletedCount = localPosts.length - validLocalPosts.length
- if (deletedCount > 0) {
- console.log(`Skipped ${deletedCount} deleted posts (exist locally but not in API)`)
- }
- console.log(`Total ${posts.length} posts (${validLocalPosts.length} local + ${newApiPosts.length} new from API)`)
+ console.log(`Total ${posts.length} posts (${apiPosts.length} from API + ${oldLocalPosts.length} old local)`)
// Create output directory
const distDir = path.join(process.cwd(), 'dist')
diff --git a/src/components/atbrowser.ts b/src/components/atbrowser.ts
index 4176093..3b1a58e 100644
--- a/src/components/atbrowser.ts
+++ b/src/components/atbrowser.ts
@@ -1,33 +1,38 @@
-import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo } from '../lib/api.js'
+import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js'
import { deleteRecord } from '../lib/auth.js'
+import { escapeHtml } from '../lib/utils.js'
function extractRkey(uri: string): string {
const parts = uri.split('/')
return parts[parts.length - 1]
}
-function formatDate(dateStr: string): string {
- const date = new Date(dateStr)
- return date.toLocaleDateString('ja-JP', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- })
-}
-
-function escapeHtml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
-}
-
async function renderServices(did: string, handle: string): Promise {
- const collections = await describeRepo(did)
+ const [collections, pds] = await Promise.all([
+ describeRepo(did),
+ resolvePds(did)
+ ])
+
+ // Server info section
+ const plcUrl = `${getPlc()}/${did}/log`
+ const serverHtml = `
+
+ `
if (collections.length === 0) {
- return 'No collections found
'
+ return serverHtml + 'No collections found
'
}
// Group by service domain
@@ -57,6 +62,7 @@ async function renderServices(did: string, handle: string): Promise {
}).join('')
return `
+ ${serverHtml}
Services
@@ -99,7 +105,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str
`
}
-async function renderRecordList(did: string, handle: string, collection: string): Promise
{
+async function renderRecordList(did: string, handle: string, collection: string, canDelete: boolean): Promise {
const records = await listRecordsRaw(did, collection)
if (records.length === 0) {
@@ -109,12 +115,16 @@ async function renderRecordList(did: string, handle: string, collection: string)
const items = records.map(rec => {
const rkey = extractRkey(rec.uri)
const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey
+ const deleteBtn = canDelete
+ ? ``
+ : ''
return `
-
+
${rkey}
- ${preview}
+ ${escapeHtml(preview)}
+ ${deleteBtn}
`
}).join('')
@@ -185,7 +195,7 @@ export async function mountAtBrowser(
const info = getServiceInfo(collection)
const backService = info ? info.domain : ''
nav = `← ${info?.name || 'Back'}`
- content = await renderRecordList(did, handle, collection)
+ content = await renderRecordList(did, handle, collection, canDelete)
} else if (service) {
nav = `← Services`
content = await renderCollections(did, handle, service)
@@ -195,7 +205,7 @@ export async function mountAtBrowser(
container.innerHTML = nav + content
- // Add delete button handler
+ // Add delete button handler (record detail page)
const deleteBtn = container.querySelector('.delete-btn')
if (deleteBtn) {
deleteBtn.addEventListener('click', async (e) => {
@@ -206,14 +216,12 @@ export async function mountAtBrowser(
if (!col || !rk) return
- if (!confirm('Delete this record?')) return
-
try {
btn.disabled = true
btn.textContent = 'Deleting...'
await deleteRecord(col, rk)
- // Go back to collection
- window.location.href = `/at/${handle}/${col}`
+ // Re-render collection list (stay in browser mode)
+ await mountAtBrowser(container, handle, col, null, service, loginDid)
} catch (err) {
alert('Delete failed: ' + err)
btn.disabled = false
@@ -221,6 +229,41 @@ export async function mountAtBrowser(
}
})
}
+
+ // Add delete button handlers for list items
+ const deleteSmallBtns = container.querySelectorAll('.delete-btn-small')
+ deleteSmallBtns.forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const button = e.target as HTMLButtonElement
+ const col = button.dataset.collection
+ const rk = button.dataset.rkey
+
+ if (!col || !rk) return
+
+ try {
+ button.disabled = true
+ button.textContent = '...'
+ await deleteRecord(col, rk)
+ // Remove the item from the list
+ const listItem = button.closest('.record-item')
+ if (listItem) {
+ listItem.remove()
+ // Update record count
+ const countEl = container.querySelector('.record-count')
+ if (countEl) {
+ const remaining = container.querySelectorAll('.record-item').length
+ countEl.textContent = `${remaining} records`
+ }
+ }
+ } catch (err) {
+ alert('Delete failed: ' + err)
+ button.disabled = false
+ button.textContent = '×'
+ }
+ })
+ })
} catch (err) {
container.innerHTML = `Failed to load: ${err}
`
}
diff --git a/src/components/discussion.ts b/src/components/discussion.ts
new file mode 100644
index 0000000..a94179e
--- /dev/null
+++ b/src/components/discussion.ts
@@ -0,0 +1,87 @@
+import { searchPostsForUrl } from '../lib/api.js'
+import { escapeHtml } from '../lib/utils.js'
+
+// Map network to app URL
+export function getAppUrl(network: string): string {
+ if (network === 'syu.is') {
+ return 'https://syu.is'
+ }
+ return 'https://bsky.app'
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr)
+ return date.toLocaleDateString('ja-JP', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ })
+}
+
+function getPostUrl(uri: string, appUrl: string): string {
+ // at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
+ const parts = uri.replace('at://', '').split('/')
+ if (parts.length >= 3) {
+ return `${appUrl}/profile/${parts[0]}/post/${parts[2]}`
+ }
+ return '#'
+}
+
+export function renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string {
+ const searchUrl = `${appUrl}/search?q=${encodeURIComponent(postUrl)}`
+ return `
+
+ `
+}
+
+export async function loadDiscussionPosts(container: HTMLElement, postUrl: string, appUrl: string = 'https://bsky.app'): Promise {
+ const postsContainer = container.querySelector('#discussion-posts') as HTMLElement
+ if (!postsContainer) return
+
+ // Get appUrl from data attribute if available
+ const dataAppUrl = postsContainer.dataset.appUrl
+ const effectiveAppUrl = dataAppUrl || appUrl
+
+ postsContainer.innerHTML = 'Loading...
'
+
+ const posts = await searchPostsForUrl(postUrl)
+
+ if (posts.length === 0) {
+ postsContainer.innerHTML = ''
+ return
+ }
+
+ const postsHtml = posts.slice(0, 10).map(post => {
+ const author = post.author
+ const avatar = author.avatar || ''
+ const displayName = author.displayName || author.handle
+ const handle = author.handle
+ const text = post.record?.text || ''
+ const createdAt = post.record?.createdAt || ''
+ const postLink = getPostUrl(post.uri, effectiveAppUrl)
+
+ return `
+
+
+ ${avatar ? `
})
` : ''}
+
+ ${escapeHtml(displayName)}
+ @${escapeHtml(handle)}
+
+
${formatDate(createdAt)}
+
+ ${escapeHtml(text)}
+
+ `
+ }).join('')
+
+ postsContainer.innerHTML = postsHtml
+}
diff --git a/src/components/posts.ts b/src/components/posts.ts
index 0ec8aa2..360103e 100644
--- a/src/components/posts.ts
+++ b/src/components/posts.ts
@@ -1,6 +1,8 @@
import type { BlogPost } from '../types.js'
import { putRecord } from '../lib/auth.js'
import { renderMarkdown } from '../lib/markdown.js'
+import { escapeHtml } from '../lib/utils.js'
+import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js'
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
@@ -11,14 +13,6 @@ function formatDate(dateStr: string): string {
})
}
-function escapeHtml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
-}
-
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
if (posts.length === 0) {
container.innerHTML = 'No posts yet
'
@@ -40,9 +34,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
container.innerHTML = ``
}
-export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false): void {
+export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false, siteUrl?: string, network: string = 'bsky.social'): void {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}`
+ const postUrl = siteUrl ? `${siteUrl}/post/${rkey}` : `${window.location.origin}/post/${rkey}`
+ const appUrl = getAppUrl(network)
const editBtn = canEdit ? `` : ''
@@ -59,6 +55,8 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
${renderMarkdown(post.content)}
+ ${renderDiscussionLink(postUrl, appUrl)}
+
Edit Post
`
+ // Load discussion posts
+ loadDiscussionPosts(container, postUrl)
+
if (canEdit) {
const editBtnEl = document.getElementById('edit-btn')
const editFormContainer = document.getElementById('edit-form-container')
diff --git a/src/lib/api.ts b/src/lib/api.ts
index c59f2a3..97afe66 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -9,7 +9,7 @@ export function setNetworkConfig(config: NetworkConfig): void {
networkConfig = config
}
-function getPlc(): string {
+export function getPlc(): string {
return networkConfig?.plc || 'https://plc.directory'
}
@@ -24,30 +24,81 @@ export function getAgent(service: string): AtpAgent {
return agents.get(service)!
}
+// Fallback PLC directories
+const FALLBACK_PLCS = [
+ 'https://plc.directory',
+ 'https://plc.syu.is',
+]
+
+// Fallback endpoints for handle/profile resolution
+const FALLBACK_ENDPOINTS = [
+ 'https://public.api.bsky.app',
+ 'https://bsky.syu.is',
+]
+
export async function resolvePds(did: string): Promise {
- const res = await fetch(`${getPlc()}/${did}`)
- const doc = await res.json()
- const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
- return service?.serviceEndpoint || getBsky()
+ // Try current PLC first, then fallbacks
+ const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())]
+
+ for (const plc of plcs) {
+ try {
+ const res = await fetch(`${plc}/${did}`)
+ if (!res.ok) continue
+ const doc = await res.json()
+ const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
+ if (service?.serviceEndpoint) {
+ return service.serviceEndpoint
+ }
+ } catch {
+ continue
+ }
+ }
+ return getBsky()
}
export async function resolveHandle(handle: string): Promise {
- const agent = getAgent(getBsky())
- const res = await agent.resolveHandle({ handle })
- return res.data.did
+ // Try current network first
+ try {
+ const agent = getAgent(getBsky())
+ const res = await agent.resolveHandle({ handle })
+ return res.data.did
+ } catch {
+ // Try fallback endpoints
+ for (const endpoint of FALLBACK_ENDPOINTS) {
+ if (endpoint === getBsky()) continue // Skip if same as current
+ try {
+ const agent = getAgent(endpoint)
+ const res = await agent.resolveHandle({ handle })
+ return res.data.did
+ } catch {
+ continue
+ }
+ }
+ throw new Error(`Could not resolve handle: ${handle}`)
+ }
}
export async function getProfile(actor: string): Promise {
- const agent = getAgent(getBsky())
- const res = await agent.getProfile({ actor })
- return {
- did: res.data.did,
- handle: res.data.handle,
- displayName: res.data.displayName,
- description: res.data.description,
- avatar: res.data.avatar,
- banner: res.data.banner,
+ // Try current network first
+ const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
+
+ for (const endpoint of endpoints) {
+ try {
+ const agent = getAgent(endpoint)
+ const res = await agent.getProfile({ actor })
+ return {
+ did: res.data.did,
+ handle: res.data.handle,
+ displayName: res.data.displayName,
+ description: res.data.description,
+ avatar: res.data.avatar,
+ banner: res.data.banner,
+ }
+ } catch {
+ continue
+ }
}
+ throw new Error(`Could not get profile: ${actor}`)
}
export async function listRecords(
@@ -190,6 +241,20 @@ const SERVICE_MAP: Record = {
'pub.leaflet': { domain: 'leaflet.pub' },
}
+// Search Bluesky posts mentioning a URL
+export async function searchPostsForUrl(url: string): Promise {
+ try {
+ const res = await fetch(
+ `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(url)}&limit=20`
+ )
+ if (!res.ok) return []
+ const data = await res.json()
+ return data.posts || []
+ } catch {
+ return []
+ }
+}
+
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
// Try to find matching service prefix
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..cf2a61a
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,7 @@
+export function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
diff --git a/src/main.ts b/src/main.ts
index 605dede..1efa29e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,11 +6,15 @@ 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
@@ -42,6 +46,39 @@ function renderFooter(handle: string): string {
`
}
+function renderPdsSelector(): string {
+ const networkKeys = Object.keys(networks)
+ const options = networkKeys.map(key => {
+ const isSelected = key === browserNetwork
+ return `
+ ${escapeHtml(key)}
+ ✓
+
`
+ }).join('')
+
+ return `
+
+ `
+}
+
+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
@@ -52,6 +89,8 @@ function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean):
tabs += `Post`
}
+ tabs += renderPdsSelector()
+
return `${tabs}
`
}
@@ -123,6 +162,12 @@ 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,
@@ -217,14 +262,6 @@ async function addEditButtonToStaticPost(collection: string, rkey: string, sessi
})
}
-function escapeHtml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
-}
-
// Refresh post list from API (for static pages)
async function refreshPostListFromAPI(): Promise {
const contentEl = document.getElementById('content')
@@ -327,6 +364,57 @@ function setupEventHandlers(): void {
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')
+ }
+ }
+
// JSON button click (on post detail page)
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
if (jsonBtn) {
@@ -432,12 +520,17 @@ async function render(): Promise {
}
}, 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"]')) {
+ // 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', 'Post')
}
+ // 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
@@ -445,6 +538,17 @@ async function render(): Promise {
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()
@@ -505,7 +609,7 @@ async function render(): Promise {
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)
+ mountPostDetail(contentEl, post, config.handle, config.collection, canEdit, config.siteUrl, config.network)
} else {
contentEl.innerHTML = 'Post not found
'
}
@@ -538,8 +642,9 @@ async function render(): Promise {
}
async function init(): Promise {
- const [configData, networks] = await Promise.all([loadConfig(), loadNetworks()])
+ const [configData, networksData] = await Promise.all([loadConfig(), loadNetworks()])
config = configData
+ networks = networksData
// Set page title
document.title = config.title || 'ailog'
@@ -549,11 +654,14 @@ async function init(): Promise {
document.documentElement.style.setProperty('--btn-color', config.color)
}
- // Set network config
- const networkConfig = networks[config.network]
- if (networkConfig) {
- setNetworkConfig(networkConfig)
- setAuthNetworkConfig(networkConfig)
+ // Initialize browser network from localStorage or default to config.network
+ browserNetwork = localStorage.getItem('browserNetwork') || config.network
+
+ // Set network config based on selected browser network
+ const selectedNetworkConfig = networks[browserNetwork]
+ if (selectedNetworkConfig) {
+ setNetworkConfig(selectedNetworkConfig)
+ setAuthNetworkConfig(selectedNetworkConfig)
}
// Handle OAuth callback
diff --git a/src/styles/main.css b/src/styles/main.css
index 822fc1a..598d3d7 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -436,6 +436,110 @@ body {
cursor: not-allowed;
}
+/* Discussion Section */
+.discussion-section {
+ margin-top: 48px;
+ padding-top: 24px;
+ border-top: 1px solid #eee;
+}
+
+.discussion-section h3 {
+ font-size: 18px;
+ margin-bottom: 16px;
+}
+
+.discuss-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ background: var(--btn-color);
+ color: #fff;
+ border-radius: 20px;
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.discuss-link:hover {
+ background: var(--btn-color);
+ filter: brightness(0.85);
+}
+
+.discuss-link svg {
+ width: 18px;
+ height: 18px;
+}
+
+.discussion-posts {
+ margin-top: 20px;
+}
+
+.loading-small {
+ color: #888;
+ font-size: 14px;
+}
+
+.no-discussion {
+ color: #888;
+ font-size: 14px;
+}
+
+.discussion-post {
+ display: block;
+ padding: 16px;
+ margin-bottom: 12px;
+ background: #f9f9f9;
+ border-radius: 8px;
+ text-decoration: none;
+ color: inherit;
+}
+
+.discussion-post:hover {
+ background: #f0f0f0;
+}
+
+.discussion-author {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.discussion-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+}
+
+.discussion-author-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.discussion-name {
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.discussion-handle {
+ font-size: 12px;
+ color: #888;
+}
+
+.discussion-date {
+ font-size: 12px;
+ color: #888;
+}
+
+.discussion-text {
+ font-size: 14px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
.post-content {
font-size: 16px;
line-height: 1.8;
@@ -729,7 +833,111 @@ body {
color: #fff;
}
+/* PDS Selector */
+.pds-selector {
+ position: relative;
+ margin-left: auto;
+}
+
+.pds-dropdown {
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 4px;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ min-width: 180px;
+ z-index: 100;
+ overflow: hidden;
+}
+
+.pds-dropdown.show {
+ display: block;
+}
+
+.pds-option {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background 0.15s;
+}
+
+.pds-option:hover {
+ background: #f5f5f5;
+}
+
+.pds-option.selected {
+ background: linear-gradient(135deg, #f0f7ff 0%, #e8f4ff 100%);
+}
+
+.pds-name {
+ color: #333;
+ font-weight: 500;
+}
+
+.pds-check {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 2px solid #ccc;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ transition: all 0.2s;
+}
+
+.pds-option.selected .pds-check {
+ background: var(--btn-color);
+ border-color: var(--btn-color);
+ color: #fff;
+}
+
+.pds-option:not(.selected) .pds-check {
+ color: transparent;
+}
+
/* AT Browser */
+.server-info {
+ padding: 16px 0;
+ border-bottom: 1px solid #eee;
+ margin-bottom: 8px;
+}
+
+.server-info h3 {
+ font-size: 18px;
+ margin-bottom: 12px;
+}
+
+.server-details {
+ font-size: 13px;
+}
+
+.server-row {
+ display: flex;
+ gap: 12px;
+ padding: 6px 0;
+}
+
+.server-row dt {
+ font-weight: 600;
+ min-width: 40px;
+ color: #666;
+}
+
+.server-row dd {
+ font-family: 'SF Mono', Monaco, monospace;
+ font-size: 12px;
+ word-break: break-all;
+ color: #333;
+}
+
.services-list,
.collections,
.records,
@@ -802,6 +1010,38 @@ body {
border-bottom: 1px solid #eee;
}
+.record-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.record-item .record-link {
+ flex: 1;
+}
+
+.delete-btn-small {
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ color: #999;
+ cursor: pointer;
+ font-size: 16px;
+ flex-shrink: 0;
+ margin-right: 8px;
+}
+
+.delete-btn-small:hover {
+ background: #fee;
+ border-color: #f88;
+ color: #c00;
+}
+
.collection-link,
.record-link {
display: flex;
diff --git a/src/types.ts b/src/types.ts
index 578ad7a..00d85fc 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -26,6 +26,7 @@ export interface AppConfig {
collection: string
network: string
color?: string
+ siteUrl?: string
}
export type Networks = Record