add comment

This commit is contained in:
2026-01-15 23:26:34 +09:00
parent 9980e596ca
commit 8945601aa1
16 changed files with 703 additions and 135 deletions

1
.gitignore vendored
View File

@@ -3,4 +3,3 @@ dist
node_modules
package-lock.json
repos
content/

View File

@@ -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"
}

View File

@@ -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"
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 `<ul class="post-list">${items}</ul>`
}
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 `
<article class="post-detail">
@@ -461,6 +436,15 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
</header>
<div class="post-content">${content}</div>
</article>
<div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2.546 20.2A1.5 1.5 0 0 0 4 22h.5l2.83-.892A9.96 9.96 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2z"/>
</svg>
Discuss on Bluesky
</a>
<div id="discussion-posts" class="discussion-posts" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}"></div>
</div>
`
}
@@ -500,7 +484,7 @@ function generatePostPageContent(profile: Profile, post: BlogPost, config: AppCo
${generateServicesHtml(profile.did, config.handle, collections)}
</section>
<section id="content">
${generatePostDetailHtml(post, config.handle, config.collection)}
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
</section>
</main>
${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')

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
async function renderServices(did: string, handle: string): Promise<string> {
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 = `
<div class="server-info">
<h3>Server</h3>
<dl class="server-details">
<div class="server-row">
<dt>DID</dt>
<dd class="did-value"><a href="${plcUrl}" target="_blank" rel="noopener">${escapeHtml(did)}</a></dd>
</div>
<div class="server-row">
<dt>PDS</dt>
<dd class="pds-value"><a href="${escapeHtml(pds)}" target="_blank" rel="noopener">${escapeHtml(pds)}</a></dd>
</div>
</dl>
</div>
`
if (collections.length === 0) {
return '<p class="no-data">No collections found</p>'
return serverHtml + '<p class="no-data">No collections found</p>'
}
// Group by service domain
@@ -57,6 +62,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
}).join('')
return `
${serverHtml}
<div class="services-list">
<h3>Services</h3>
<ul class="service-list">${items}</ul>
@@ -99,7 +105,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str
`
}
async function renderRecordList(did: string, handle: string, collection: string): Promise<string> {
async function renderRecordList(did: string, handle: string, collection: string, canDelete: boolean): Promise<string> {
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
? `<button class="delete-btn-small" data-collection="${collection}" data-rkey="${rkey}" title="Delete">×</button>`
: ''
return `
<li class="record-item">
<li class="record-item" data-rkey="${rkey}">
<a href="/at/${handle}/${collection}/${rkey}" class="record-link">
<span class="record-rkey">${rkey}</span>
<span class="record-preview">${preview}</span>
<span class="record-preview">${escapeHtml(preview)}</span>
</a>
${deleteBtn}
</li>
`
}).join('')
@@ -185,7 +195,7 @@ export async function mountAtBrowser(
const info = getServiceInfo(collection)
const backService = info ? info.domain : ''
nav = `<a href="/at/${handle}/${backService}" class="back-link">← ${info?.name || 'Back'}</a>`
content = await renderRecordList(did, handle, collection)
content = await renderRecordList(did, handle, collection, canDelete)
} else if (service) {
nav = `<a href="/at/${handle}" class="back-link">← Services</a>`
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 = `<p class="error">Failed to load: ${err}</p>`
}

View File

@@ -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 `
<div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2.546 20.2A1.5 1.5 0 0 0 4 22h.5l2.83-.892A9.96 9.96 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2z"/>
</svg>
Discuss on Bluesky
</a>
<div id="discussion-posts" class="discussion-posts" data-app-url="${escapeHtml(appUrl)}"></div>
</div>
`
}
export async function loadDiscussionPosts(container: HTMLElement, postUrl: string, appUrl: string = 'https://bsky.app'): Promise<void> {
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 = '<div class="loading-small">Loading...</div>'
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 `
<a href="${postLink}" target="_blank" rel="noopener" class="discussion-post">
<div class="discussion-author">
${avatar ? `<img src="${escapeHtml(avatar)}" class="discussion-avatar" alt="">` : ''}
<div class="discussion-author-info">
<span class="discussion-name">${escapeHtml(displayName)}</span>
<span class="discussion-handle">@${escapeHtml(handle)}</span>
</div>
<span class="discussion-date">${formatDate(createdAt)}</span>
</div>
<div class="discussion-text">${escapeHtml(text)}</div>
</a>
`
}).join('')
postsContainer.innerHTML = postsHtml
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
if (posts.length === 0) {
container.innerHTML = '<p class="no-posts">No posts yet</p>'
@@ -40,9 +34,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
container.innerHTML = `<ul class="post-list">${html}</ul>`
}
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 ? `<button class="edit-btn" id="edit-btn">edit</button>` : ''
@@ -59,6 +55,8 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
<div class="post-content" id="post-content">${renderMarkdown(post.content)}</div>
</article>
${renderDiscussionLink(postUrl, appUrl)}
<div class="edit-form-container" id="edit-form-container" style="display: none;">
<h3>Edit Post</h3>
<form class="edit-form" id="edit-form">
@@ -72,6 +70,9 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
</div>
`
// Load discussion posts
loadDiscussionPosts(container, postUrl)
if (canEdit) {
const editBtnEl = document.getElementById('edit-btn')
const editFormContainer = document.getElementById('edit-form-container')

View File

@@ -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,21 +24,67 @@ 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<string> {
const res = await fetch(`${getPlc()}/${did}`)
// 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')
return service?.serviceEndpoint || getBsky()
if (service?.serviceEndpoint) {
return service.serviceEndpoint
}
} catch {
continue
}
}
return getBsky()
}
export async function resolveHandle(handle: string): Promise<string> {
// 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<Profile> {
const agent = getAgent(getBsky())
// 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,
@@ -48,6 +94,11 @@ export async function getProfile(actor: string): Promise<Profile> {
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<string, { domain: string; icon?: string }> = {
'pub.leaflet': { domain: 'leaflet.pub' },
}
// Search Bluesky posts mentioning a URL
export async function searchPostsForUrl(url: string): Promise<any[]> {
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)) {

7
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}

View File

@@ -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 `<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${escapeHtml(key)}">
<span class="pds-name">${escapeHtml(key)}</span>
<span class="pds-check">✓</span>
</div>`
}).join('')
return `
<div class="pds-selector" id="pds-selector">
<button type="button" class="tab" id="pds-tab">PDS</button>
<div class="pds-dropdown" id="pds-dropdown">
${options}
</div>
</div>
`
}
function updatePdsSelector(): void {
const dropdown = document.getElementById('pds-dropdown')
if (!dropdown) return
const options = dropdown.querySelectorAll('.pds-option')
options.forEach(opt => {
const el = opt as HTMLElement
const network = el.dataset.network
const isSelected = network === browserNetwork
el.classList.toggle('selected', isSelected)
})
}
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
let tabs = `
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
@@ -52,6 +89,8 @@ function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean):
tabs += `<a href="/post" class="tab ${activeTab === 'new' ? 'active' : ''}">Post</a>`
}
tabs += renderPdsSelector()
return `<div class="mode-tabs">${tabs}</div>`
}
@@ -123,6 +162,12 @@ async function loadBrowserContent(): Promise<void> {
const contentEl = document.getElementById('content')
if (!contentEl) return
// Set network config for browser
const browserNetworkConfig = networks[browserNetwork]
if (browserNetworkConfig) {
setNetworkConfig(browserNetworkConfig)
}
const loginDid = authSession?.did || null
await mountAtBrowser(
contentEl,
@@ -217,14 +262,6 @@ async function addEditButtonToStaticPost(collection: string, rkey: string, sessi
})
}
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')
@@ -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<void> {
}
}, true)
// Update tabs to show Post tab if logged in
if (isLoggedIn) {
// Update tabs
const tabsEl = document.querySelector('.mode-tabs')
if (tabsEl && !tabsEl.querySelector('a[href="/post"]')) {
if (tabsEl) {
// Add Post tab if logged in
if (isLoggedIn && !tabsEl.querySelector('a[href="/post"]')) {
tabsEl.insertAdjacentHTML('beforeend', '<a href="/post" class="tab">Post</a>')
}
// Add PDS selector if not present
if (!tabsEl.querySelector('#pds-selector')) {
tabsEl.insertAdjacentHTML('beforeend', renderPdsSelector())
}
}
// For post pages, add edit button if logged in and can edit
@@ -445,6 +538,17 @@ async function render(): Promise<void> {
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<void> {
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 = '<p>Post not found</p>'
}
@@ -538,8 +642,9 @@ async function render(): Promise<void> {
}
async function init(): Promise<void> {
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<void> {
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

View File

@@ -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;

View File

@@ -26,6 +26,7 @@ export interface AppConfig {
collection: string
network: string
color?: string
siteUrl?: string
}
export type Networks = Record<string, NetworkConfig>