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}
+
+ + + + + Discuss on Bluesky + +
+
` } @@ -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 = ` +
+

Server

+
+
+
DID
+
${escapeHtml(did)}
+
+
+
PDS
+
${escapeHtml(pds)}
+
+
+
+ ` 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

    ${items}
@@ -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 = `
      ${html}
    ` } -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)} + ` + // 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 ` +
    + +
    + ${options} +
    +
    + ` +} + +function updatePdsSelector(): void { + const dropdown = document.getElementById('pds-dropdown') + if (!dropdown) return + + const options = dropdown.querySelectorAll('.pds-option') + options.forEach(opt => { + const el = opt as HTMLElement + const network = el.dataset.network + const isSelected = network === browserNetwork + el.classList.toggle('selected', isSelected) + }) +} + function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string { let tabs = ` Blog @@ -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