diff --git a/public/config.json b/public/config.json
index 353dab9..a483ce5 100644
--- a/public/config.json
+++ b/public/config.json
@@ -1,7 +1,7 @@
{
- "title": "syui.ai",
- "handle": "syui.ai",
+ "title": "syui.syui.ai",
+ "handle": "syui.syui.ai",
"collection": "ai.syui.log.post",
- "network": "bsky.social",
+ "network": "syu.is",
"color": "#0066cc"
}
diff --git a/scripts/generate.ts b/scripts/generate.ts
index 05330c4..3c5499d 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): string {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
const content = marked.parse(post.content) as string
+ // Construct URL from handle (e.g., syui.ai -> https://syui.ai)
+ const siteUrl = `https://${handle}`
+ const postUrl = `${siteUrl}/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)}
${generateFooterHtml(config.handle)}
diff --git a/src/components/atbrowser.ts b/src/components/atbrowser.ts
index 4176093..e160c30 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 } 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 = `https://plc.directory/${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
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..7d64ba4 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -190,6 +190,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..aaf71a2 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,56 @@ 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
+ 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) {
@@ -445,6 +532,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 +603,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, undefined, config.network)
} else {
contentEl.innerHTML = 'Post not found
'
}
@@ -538,8 +636,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 +648,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 for blog (uses config.network)
+ const blogNetworkConfig = networks[config.network]
+ if (blogNetworkConfig) {
+ setNetworkConfig(blogNetworkConfig)
+ setAuthNetworkConfig(blogNetworkConfig)
}
// Handle OAuth callback
diff --git a/src/styles/main.css b/src/styles/main.css
index 822fc1a..ff11764 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,