add comment
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,3 @@ dist
|
|||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
repos
|
repos
|
||||||
content/
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
11
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/collections.json
Normal file
11
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/collections.json
Normal 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"
|
||||||
|
]
|
||||||
BIN
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/bsky.app.png
Normal file
BIN
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/bsky.app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syui.ai.png
Normal file
BIN
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/favicons/syui.ai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
21
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/profile.json
Normal file
21
content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/profile.json
Normal 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
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"title": "syui.ai",
|
"title": "syui.ai",
|
||||||
"handle": "syui.ai",
|
"handle": "syui.syui.ai",
|
||||||
"collection": "ai.syui.log.post",
|
"collection": "ai.syui.log.post",
|
||||||
"network": "bsky.social",
|
"network": "syu.is",
|
||||||
"color": "#0066cc"
|
"color": "#0066cc",
|
||||||
|
"siteUrl": "https://syui.ai"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,8 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { marked, Renderer } from 'marked'
|
import { marked, Renderer } from 'marked'
|
||||||
|
import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts'
|
||||||
// Types
|
import { escapeHtml } from '../src/lib/utils.ts'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlight.js for syntax highlighting (core + common languages only)
|
// Highlight.js for syntax highlighting (core + common languages only)
|
||||||
let hljs: typeof import('highlight.js/lib/core').default
|
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, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
return date.toLocaleDateString('ja-JP', {
|
return date.toLocaleDateString('ja-JP', {
|
||||||
@@ -445,10 +407,23 @@ function generatePostListHtml(posts: BlogPost[]): string {
|
|||||||
return `<ul class="post-list">${items}</ul>`
|
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 rkey = post.uri.split('/').pop() || ''
|
||||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
||||||
const content = marked.parse(post.content) as string
|
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 `
|
return `
|
||||||
<article class="post-detail">
|
<article class="post-detail">
|
||||||
@@ -461,6 +436,15 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
|
|||||||
</header>
|
</header>
|
||||||
<div class="post-content">${content}</div>
|
<div class="post-content">${content}</div>
|
||||||
</article>
|
</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)}
|
${generateServicesHtml(profile.did, config.handle, collections)}
|
||||||
</section>
|
</section>
|
||||||
<section id="content">
|
<section id="content">
|
||||||
${generatePostDetailHtml(post, config.handle, config.collection)}
|
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
${generateFooterHtml(config.handle)}
|
${generateFooterHtml(config.handle)}
|
||||||
@@ -595,27 +579,20 @@ async function generate() {
|
|||||||
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
|
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
|
||||||
console.log(`Found ${localPosts.length} posts from local`)
|
console.log(`Found ${localPosts.length} posts from local`)
|
||||||
|
|
||||||
// Merge: API is the source of truth for what exists
|
// Merge: API is the source of truth
|
||||||
// - If post exists in API and local: use local (may have edits)
|
// - If post exists in API: always use API (has latest edits)
|
||||||
// - If post exists in API only: use API
|
// - If post exists in local only: keep if not deleted (for posts beyond API limit)
|
||||||
// - If post exists in local only: skip (was deleted from API)
|
|
||||||
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
|
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
|
// Local posts that don't exist in API (older posts beyond 100 limit)
|
||||||
const validLocalPosts = localPosts.filter(p => apiRkeys.has(p.uri.split('/').pop()))
|
// Note: these might be deleted posts, so we keep them cautiously
|
||||||
// API posts that don't exist locally
|
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
|
||||||
const newApiPosts = apiPosts.filter(p => !localRkeys.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()
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
const deletedCount = localPosts.length - validLocalPosts.length
|
console.log(`Total ${posts.length} posts (${apiPosts.length} from API + ${oldLocalPosts.length} old local)`)
|
||||||
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)`)
|
|
||||||
|
|
||||||
// Create output directory
|
// Create output directory
|
||||||
const distDir = path.join(process.cwd(), 'dist')
|
const distDir = path.join(process.cwd(), 'dist')
|
||||||
|
|||||||
@@ -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 { deleteRecord } from '../lib/auth.js'
|
||||||
|
import { escapeHtml } from '../lib/utils.js'
|
||||||
|
|
||||||
function extractRkey(uri: string): string {
|
function extractRkey(uri: string): string {
|
||||||
const parts = uri.split('/')
|
const parts = uri.split('/')
|
||||||
return parts[parts.length - 1]
|
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, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderServices(did: string, handle: string): Promise<string> {
|
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) {
|
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
|
// Group by service domain
|
||||||
@@ -57,6 +62,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
|
|||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
${serverHtml}
|
||||||
<div class="services-list">
|
<div class="services-list">
|
||||||
<h3>Services</h3>
|
<h3>Services</h3>
|
||||||
<ul class="service-list">${items}</ul>
|
<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)
|
const records = await listRecordsRaw(did, collection)
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
@@ -109,12 +115,16 @@ async function renderRecordList(did: string, handle: string, collection: string)
|
|||||||
const items = records.map(rec => {
|
const items = records.map(rec => {
|
||||||
const rkey = extractRkey(rec.uri)
|
const rkey = extractRkey(rec.uri)
|
||||||
const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey
|
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 `
|
return `
|
||||||
<li class="record-item">
|
<li class="record-item" data-rkey="${rkey}">
|
||||||
<a href="/at/${handle}/${collection}/${rkey}" class="record-link">
|
<a href="/at/${handle}/${collection}/${rkey}" class="record-link">
|
||||||
<span class="record-rkey">${rkey}</span>
|
<span class="record-rkey">${rkey}</span>
|
||||||
<span class="record-preview">${preview}</span>
|
<span class="record-preview">${escapeHtml(preview)}</span>
|
||||||
</a>
|
</a>
|
||||||
|
${deleteBtn}
|
||||||
</li>
|
</li>
|
||||||
`
|
`
|
||||||
}).join('')
|
}).join('')
|
||||||
@@ -185,7 +195,7 @@ export async function mountAtBrowser(
|
|||||||
const info = getServiceInfo(collection)
|
const info = getServiceInfo(collection)
|
||||||
const backService = info ? info.domain : ''
|
const backService = info ? info.domain : ''
|
||||||
nav = `<a href="/at/${handle}/${backService}" class="back-link">← ${info?.name || 'Back'}</a>`
|
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) {
|
} else if (service) {
|
||||||
nav = `<a href="/at/${handle}" class="back-link">← Services</a>`
|
nav = `<a href="/at/${handle}" class="back-link">← Services</a>`
|
||||||
content = await renderCollections(did, handle, service)
|
content = await renderCollections(did, handle, service)
|
||||||
@@ -195,7 +205,7 @@ export async function mountAtBrowser(
|
|||||||
|
|
||||||
container.innerHTML = nav + content
|
container.innerHTML = nav + content
|
||||||
|
|
||||||
// Add delete button handler
|
// Add delete button handler (record detail page)
|
||||||
const deleteBtn = container.querySelector('.delete-btn')
|
const deleteBtn = container.querySelector('.delete-btn')
|
||||||
if (deleteBtn) {
|
if (deleteBtn) {
|
||||||
deleteBtn.addEventListener('click', async (e) => {
|
deleteBtn.addEventListener('click', async (e) => {
|
||||||
@@ -206,14 +216,12 @@ export async function mountAtBrowser(
|
|||||||
|
|
||||||
if (!col || !rk) return
|
if (!col || !rk) return
|
||||||
|
|
||||||
if (!confirm('Delete this record?')) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
btn.disabled = true
|
btn.disabled = true
|
||||||
btn.textContent = 'Deleting...'
|
btn.textContent = 'Deleting...'
|
||||||
await deleteRecord(col, rk)
|
await deleteRecord(col, rk)
|
||||||
// Go back to collection
|
// Re-render collection list (stay in browser mode)
|
||||||
window.location.href = `/at/${handle}/${col}`
|
await mountAtBrowser(container, handle, col, null, service, loginDid)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Delete failed: ' + err)
|
alert('Delete failed: ' + err)
|
||||||
btn.disabled = false
|
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) {
|
} catch (err) {
|
||||||
container.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
container.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||||
}
|
}
|
||||||
|
|||||||
87
src/components/discussion.ts
Normal file
87
src/components/discussion.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { BlogPost } from '../types.js'
|
import type { BlogPost } from '../types.js'
|
||||||
import { putRecord } from '../lib/auth.js'
|
import { putRecord } from '../lib/auth.js'
|
||||||
import { renderMarkdown } from '../lib/markdown.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 {
|
function formatDate(dateStr: string): string {
|
||||||
const date = new Date(dateStr)
|
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, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
container.innerHTML = '<p class="no-posts">No posts yet</p>'
|
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>`
|
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 rkey = post.uri.split('/').pop() || ''
|
||||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}`
|
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>` : ''
|
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>
|
<div class="post-content" id="post-content">${renderMarkdown(post.content)}</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
${renderDiscussionLink(postUrl, appUrl)}
|
||||||
|
|
||||||
<div class="edit-form-container" id="edit-form-container" style="display: none;">
|
<div class="edit-form-container" id="edit-form-container" style="display: none;">
|
||||||
<h3>Edit Post</h3>
|
<h3>Edit Post</h3>
|
||||||
<form class="edit-form" id="edit-form">
|
<form class="edit-form" id="edit-form">
|
||||||
@@ -72,6 +70,9 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Load discussion posts
|
||||||
|
loadDiscussionPosts(container, postUrl)
|
||||||
|
|
||||||
if (canEdit) {
|
if (canEdit) {
|
||||||
const editBtnEl = document.getElementById('edit-btn')
|
const editBtnEl = document.getElementById('edit-btn')
|
||||||
const editFormContainer = document.getElementById('edit-form-container')
|
const editFormContainer = document.getElementById('edit-form-container')
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function setNetworkConfig(config: NetworkConfig): void {
|
|||||||
networkConfig = config
|
networkConfig = config
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlc(): string {
|
export function getPlc(): string {
|
||||||
return networkConfig?.plc || 'https://plc.directory'
|
return networkConfig?.plc || 'https://plc.directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,30 +24,81 @@ export function getAgent(service: string): AtpAgent {
|
|||||||
return agents.get(service)!
|
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> {
|
export async function resolvePds(did: string): Promise<string> {
|
||||||
const res = await fetch(`${getPlc()}/${did}`)
|
// Try current PLC first, then fallbacks
|
||||||
const doc = await res.json()
|
const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())]
|
||||||
const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
|
|
||||||
return service?.serviceEndpoint || getBsky()
|
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<string> {
|
export async function resolveHandle(handle: string): Promise<string> {
|
||||||
const agent = getAgent(getBsky())
|
// Try current network first
|
||||||
const res = await agent.resolveHandle({ handle })
|
try {
|
||||||
return res.data.did
|
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> {
|
export async function getProfile(actor: string): Promise<Profile> {
|
||||||
const agent = getAgent(getBsky())
|
// Try current network first
|
||||||
const res = await agent.getProfile({ actor })
|
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
|
||||||
return {
|
|
||||||
did: res.data.did,
|
for (const endpoint of endpoints) {
|
||||||
handle: res.data.handle,
|
try {
|
||||||
displayName: res.data.displayName,
|
const agent = getAgent(endpoint)
|
||||||
description: res.data.description,
|
const res = await agent.getProfile({ actor })
|
||||||
avatar: res.data.avatar,
|
return {
|
||||||
banner: res.data.banner,
|
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(
|
export async function listRecords(
|
||||||
@@ -190,6 +241,20 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
|
|||||||
'pub.leaflet': { domain: 'leaflet.pub' },
|
'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 {
|
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
|
||||||
// Try to find matching service prefix
|
// Try to find matching service prefix
|
||||||
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
|
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
|
||||||
|
|||||||
7
src/lib/utils.ts
Normal file
7
src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
146
src/main.ts
146
src/main.ts
@@ -6,11 +6,15 @@ import { mountPostList, mountPostDetail } from './components/posts.js'
|
|||||||
import { mountHeader } from './components/browser.js'
|
import { mountHeader } from './components/browser.js'
|
||||||
import { mountAtBrowser } from './components/atbrowser.js'
|
import { mountAtBrowser } from './components/atbrowser.js'
|
||||||
import { mountPostForm } from './components/postform.js'
|
import { mountPostForm } from './components/postform.js'
|
||||||
|
import { loadDiscussionPosts } from './components/discussion.js'
|
||||||
import { parseRoute, type Route } from './lib/router.js'
|
import { parseRoute, type Route } from './lib/router.js'
|
||||||
|
import { escapeHtml } from './lib/utils.js'
|
||||||
import type { AppConfig, Networks } from './types.js'
|
import type { AppConfig, Networks } from './types.js'
|
||||||
|
|
||||||
let authSession: AuthSession | null = null
|
let authSession: AuthSession | null = null
|
||||||
let config: AppConfig
|
let config: AppConfig
|
||||||
|
let networks: Networks = {}
|
||||||
|
let browserNetwork: string = '' // Network for AT Browser
|
||||||
|
|
||||||
// Browser state
|
// Browser state
|
||||||
let browserMode = false
|
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 {
|
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
|
||||||
let tabs = `
|
let tabs = `
|
||||||
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
<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 += `<a href="/post" class="tab ${activeTab === 'new' ? 'active' : ''}">Post</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tabs += renderPdsSelector()
|
||||||
|
|
||||||
return `<div class="mode-tabs">${tabs}</div>`
|
return `<div class="mode-tabs">${tabs}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +162,12 @@ async function loadBrowserContent(): Promise<void> {
|
|||||||
const contentEl = document.getElementById('content')
|
const contentEl = document.getElementById('content')
|
||||||
if (!contentEl) return
|
if (!contentEl) return
|
||||||
|
|
||||||
|
// Set network config for browser
|
||||||
|
const browserNetworkConfig = networks[browserNetwork]
|
||||||
|
if (browserNetworkConfig) {
|
||||||
|
setNetworkConfig(browserNetworkConfig)
|
||||||
|
}
|
||||||
|
|
||||||
const loginDid = authSession?.did || null
|
const loginDid = authSession?.did || null
|
||||||
await mountAtBrowser(
|
await mountAtBrowser(
|
||||||
contentEl,
|
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, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh post list from API (for static pages)
|
// Refresh post list from API (for static pages)
|
||||||
async function refreshPostListFromAPI(): Promise<void> {
|
async function refreshPostListFromAPI(): Promise<void> {
|
||||||
const contentEl = document.getElementById('content')
|
const contentEl = document.getElementById('content')
|
||||||
@@ -327,6 +364,57 @@ function setupEventHandlers(): void {
|
|||||||
return
|
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)
|
// JSON button click (on post detail page)
|
||||||
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
||||||
if (jsonBtn) {
|
if (jsonBtn) {
|
||||||
@@ -432,12 +520,17 @@ async function render(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}, true)
|
}, true)
|
||||||
|
|
||||||
// Update tabs to show Post tab if logged in
|
// Update tabs
|
||||||
if (isLoggedIn) {
|
const tabsEl = document.querySelector('.mode-tabs')
|
||||||
const tabsEl = document.querySelector('.mode-tabs')
|
if (tabsEl) {
|
||||||
if (tabsEl && !tabsEl.querySelector('a[href="/post"]')) {
|
// Add Post tab if logged in
|
||||||
|
if (isLoggedIn && !tabsEl.querySelector('a[href="/post"]')) {
|
||||||
tabsEl.insertAdjacentHTML('beforeend', '<a href="/post" class="tab">Post</a>')
|
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
|
// 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!)
|
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
|
// For blog top page, check for new posts from API and merge
|
||||||
if (route.type === 'blog') {
|
if (route.type === 'blog') {
|
||||||
refreshPostListFromAPI()
|
refreshPostListFromAPI()
|
||||||
@@ -505,7 +609,7 @@ async function render(): Promise<void> {
|
|||||||
const post = await getRecord(profile.did, config.collection, route.rkey!)
|
const post = await getRecord(profile.did, config.collection, route.rkey!)
|
||||||
if (post) {
|
if (post) {
|
||||||
const canEdit = isLoggedIn && authSession?.did === profile.did
|
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 {
|
} else {
|
||||||
contentEl.innerHTML = '<p>Post not found</p>'
|
contentEl.innerHTML = '<p>Post not found</p>'
|
||||||
}
|
}
|
||||||
@@ -538,8 +642,9 @@ async function render(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init(): 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
|
config = configData
|
||||||
|
networks = networksData
|
||||||
|
|
||||||
// Set page title
|
// Set page title
|
||||||
document.title = config.title || 'ailog'
|
document.title = config.title || 'ailog'
|
||||||
@@ -549,11 +654,14 @@ async function init(): Promise<void> {
|
|||||||
document.documentElement.style.setProperty('--btn-color', config.color)
|
document.documentElement.style.setProperty('--btn-color', config.color)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set network config
|
// Initialize browser network from localStorage or default to config.network
|
||||||
const networkConfig = networks[config.network]
|
browserNetwork = localStorage.getItem('browserNetwork') || config.network
|
||||||
if (networkConfig) {
|
|
||||||
setNetworkConfig(networkConfig)
|
// Set network config based on selected browser network
|
||||||
setAuthNetworkConfig(networkConfig)
|
const selectedNetworkConfig = networks[browserNetwork]
|
||||||
|
if (selectedNetworkConfig) {
|
||||||
|
setNetworkConfig(selectedNetworkConfig)
|
||||||
|
setAuthNetworkConfig(selectedNetworkConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle OAuth callback
|
// Handle OAuth callback
|
||||||
|
|||||||
@@ -436,6 +436,110 @@ body {
|
|||||||
cursor: not-allowed;
|
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 {
|
.post-content {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
@@ -729,7 +833,111 @@ body {
|
|||||||
color: #fff;
|
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 */
|
/* 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,
|
.services-list,
|
||||||
.collections,
|
.collections,
|
||||||
.records,
|
.records,
|
||||||
@@ -802,6 +1010,38 @@ body {
|
|||||||
border-bottom: 1px solid #eee;
|
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,
|
.collection-link,
|
||||||
.record-link {
|
.record-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -838,11 +1078,14 @@ body {
|
|||||||
.record-link {
|
.record-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-rkey {
|
.record-rkey {
|
||||||
color: var(--btn-color);
|
color: var(--btn-color);
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-preview {
|
.record-preview {
|
||||||
@@ -850,6 +1093,8 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-count {
|
.record-count {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface AppConfig {
|
|||||||
collection: string
|
collection: string
|
||||||
network: string
|
network: string
|
||||||
color?: string
|
color?: string
|
||||||
|
siteUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Networks = Record<string, NetworkConfig>
|
export type Networks = Record<string, NetworkConfig>
|
||||||
|
|||||||
Reference in New Issue
Block a user