add comment
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,3 @@ dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
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",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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, '>')
|
||||
.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 `<ul class="post-list">${items}</ul>`
|
||||
}
|
||||
|
||||
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string): string {
|
||||
// Map network to app URL for discussion links
|
||||
function getAppUrl(network: string): string {
|
||||
if (network === 'syu.is') {
|
||||
return 'https://syu.is'
|
||||
}
|
||||
return 'https://bsky.app'
|
||||
}
|
||||
|
||||
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
|
||||
const rkey = post.uri.split('/').pop() || ''
|
||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
||||
const content = marked.parse(post.content) as string
|
||||
// Use siteUrl from config, or construct from handle
|
||||
const baseSiteUrl = siteUrl || `https://${handle}`
|
||||
const postUrl = `${baseSiteUrl}/post/${rkey}/`
|
||||
const appUrl = getAppUrl(network)
|
||||
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(postUrl)}`
|
||||
|
||||
return `
|
||||
<article class="post-detail">
|
||||
@@ -461,6 +436,15 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
|
||||
</header>
|
||||
<div class="post-content">${content}</div>
|
||||
</article>
|
||||
<div class="discussion-section">
|
||||
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2.546 20.2A1.5 1.5 0 0 0 4 22h.5l2.83-.892A9.96 9.96 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2z"/>
|
||||
</svg>
|
||||
Discuss on Bluesky
|
||||
</a>
|
||||
<div id="discussion-posts" class="discussion-posts" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -500,7 +484,7 @@ function generatePostPageContent(profile: Profile, post: BlogPost, config: AppCo
|
||||
${generateServicesHtml(profile.did, config.handle, collections)}
|
||||
</section>
|
||||
<section id="content">
|
||||
${generatePostDetailHtml(post, config.handle, config.collection)}
|
||||
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
|
||||
</section>
|
||||
</main>
|
||||
${generateFooterHtml(config.handle)}
|
||||
@@ -595,27 +579,20 @@ async function generate() {
|
||||
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
|
||||
console.log(`Found ${localPosts.length} posts from local`)
|
||||
|
||||
// Merge: API is the source of truth for what exists
|
||||
// - If post exists in API and local: use local (may have edits)
|
||||
// - If post exists in API only: use API
|
||||
// - If post exists in local only: skip (was deleted from API)
|
||||
// Merge: API is the source of truth
|
||||
// - If post exists in API: always use API (has latest edits)
|
||||
// - If post exists in local only: keep if not deleted (for posts beyond API limit)
|
||||
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
|
||||
const localRkeys = new Set(localPosts.map(p => p.uri.split('/').pop()))
|
||||
|
||||
// Local posts that still exist in API
|
||||
const validLocalPosts = localPosts.filter(p => apiRkeys.has(p.uri.split('/').pop()))
|
||||
// API posts that don't exist locally
|
||||
const newApiPosts = apiPosts.filter(p => !localRkeys.has(p.uri.split('/').pop()))
|
||||
// Local posts that don't exist in API (older posts beyond 100 limit)
|
||||
// Note: these might be deleted posts, so we keep them cautiously
|
||||
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
|
||||
|
||||
posts = [...validLocalPosts, ...newApiPosts].sort((a, b) =>
|
||||
posts = [...apiPosts, ...oldLocalPosts].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
|
||||
const deletedCount = localPosts.length - validLocalPosts.length
|
||||
if (deletedCount > 0) {
|
||||
console.log(`Skipped ${deletedCount} deleted posts (exist locally but not in API)`)
|
||||
}
|
||||
console.log(`Total ${posts.length} posts (${validLocalPosts.length} local + ${newApiPosts.length} new from API)`)
|
||||
console.log(`Total ${posts.length} posts (${apiPosts.length} from API + ${oldLocalPosts.length} old local)`)
|
||||
|
||||
// Create output directory
|
||||
const distDir = path.join(process.cwd(), 'dist')
|
||||
|
||||
@@ -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, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
async function renderServices(did: string, handle: string): Promise<string> {
|
||||
const collections = await describeRepo(did)
|
||||
const [collections, pds] = await Promise.all([
|
||||
describeRepo(did),
|
||||
resolvePds(did)
|
||||
])
|
||||
|
||||
// Server info section
|
||||
const plcUrl = `${getPlc()}/${did}/log`
|
||||
const serverHtml = `
|
||||
<div class="server-info">
|
||||
<h3>Server</h3>
|
||||
<dl class="server-details">
|
||||
<div class="server-row">
|
||||
<dt>DID</dt>
|
||||
<dd class="did-value"><a href="${plcUrl}" target="_blank" rel="noopener">${escapeHtml(did)}</a></dd>
|
||||
</div>
|
||||
<div class="server-row">
|
||||
<dt>PDS</dt>
|
||||
<dd class="pds-value"><a href="${escapeHtml(pds)}" target="_blank" rel="noopener">${escapeHtml(pds)}</a></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
`
|
||||
|
||||
if (collections.length === 0) {
|
||||
return '<p class="no-data">No collections found</p>'
|
||||
return serverHtml + '<p class="no-data">No collections found</p>'
|
||||
}
|
||||
|
||||
// Group by service domain
|
||||
@@ -57,6 +62,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
${serverHtml}
|
||||
<div class="services-list">
|
||||
<h3>Services</h3>
|
||||
<ul class="service-list">${items}</ul>
|
||||
@@ -99,7 +105,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str
|
||||
`
|
||||
}
|
||||
|
||||
async function renderRecordList(did: string, handle: string, collection: string): Promise<string> {
|
||||
async function renderRecordList(did: string, handle: string, collection: string, canDelete: boolean): Promise<string> {
|
||||
const records = await listRecordsRaw(did, collection)
|
||||
|
||||
if (records.length === 0) {
|
||||
@@ -109,12 +115,16 @@ async function renderRecordList(did: string, handle: string, collection: string)
|
||||
const items = records.map(rec => {
|
||||
const rkey = extractRkey(rec.uri)
|
||||
const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey
|
||||
const deleteBtn = canDelete
|
||||
? `<button class="delete-btn-small" data-collection="${collection}" data-rkey="${rkey}" title="Delete">×</button>`
|
||||
: ''
|
||||
return `
|
||||
<li class="record-item">
|
||||
<li class="record-item" data-rkey="${rkey}">
|
||||
<a href="/at/${handle}/${collection}/${rkey}" class="record-link">
|
||||
<span class="record-rkey">${rkey}</span>
|
||||
<span class="record-preview">${preview}</span>
|
||||
<span class="record-preview">${escapeHtml(preview)}</span>
|
||||
</a>
|
||||
${deleteBtn}
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
@@ -185,7 +195,7 @@ export async function mountAtBrowser(
|
||||
const info = getServiceInfo(collection)
|
||||
const backService = info ? info.domain : ''
|
||||
nav = `<a href="/at/${handle}/${backService}" class="back-link">← ${info?.name || 'Back'}</a>`
|
||||
content = await renderRecordList(did, handle, collection)
|
||||
content = await renderRecordList(did, handle, collection, canDelete)
|
||||
} else if (service) {
|
||||
nav = `<a href="/at/${handle}" class="back-link">← Services</a>`
|
||||
content = await renderCollections(did, handle, service)
|
||||
@@ -195,7 +205,7 @@ export async function mountAtBrowser(
|
||||
|
||||
container.innerHTML = nav + content
|
||||
|
||||
// Add delete button handler
|
||||
// Add delete button handler (record detail page)
|
||||
const deleteBtn = container.querySelector('.delete-btn')
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
@@ -206,14 +216,12 @@ export async function mountAtBrowser(
|
||||
|
||||
if (!col || !rk) return
|
||||
|
||||
if (!confirm('Delete this record?')) return
|
||||
|
||||
try {
|
||||
btn.disabled = true
|
||||
btn.textContent = 'Deleting...'
|
||||
await deleteRecord(col, rk)
|
||||
// Go back to collection
|
||||
window.location.href = `/at/${handle}/${col}`
|
||||
// Re-render collection list (stay in browser mode)
|
||||
await mountAtBrowser(container, handle, col, null, service, loginDid)
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err)
|
||||
btn.disabled = false
|
||||
@@ -221,6 +229,41 @@ export async function mountAtBrowser(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add delete button handlers for list items
|
||||
const deleteSmallBtns = container.querySelectorAll('.delete-btn-small')
|
||||
deleteSmallBtns.forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const button = e.target as HTMLButtonElement
|
||||
const col = button.dataset.collection
|
||||
const rk = button.dataset.rkey
|
||||
|
||||
if (!col || !rk) return
|
||||
|
||||
try {
|
||||
button.disabled = true
|
||||
button.textContent = '...'
|
||||
await deleteRecord(col, rk)
|
||||
// Remove the item from the list
|
||||
const listItem = button.closest('.record-item')
|
||||
if (listItem) {
|
||||
listItem.remove()
|
||||
// Update record count
|
||||
const countEl = container.querySelector('.record-count')
|
||||
if (countEl) {
|
||||
const remaining = container.querySelectorAll('.record-item').length
|
||||
countEl.textContent = `${remaining} records`
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err)
|
||||
button.disabled = false
|
||||
button.textContent = '×'
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||
}
|
||||
|
||||
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 { 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, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = '<p class="no-posts">No posts yet</p>'
|
||||
@@ -40,9 +34,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
||||
container.innerHTML = `<ul class="post-list">${html}</ul>`
|
||||
}
|
||||
|
||||
export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false): void {
|
||||
export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false, siteUrl?: string, network: string = 'bsky.social'): void {
|
||||
const rkey = post.uri.split('/').pop() || ''
|
||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}`
|
||||
const postUrl = siteUrl ? `${siteUrl}/post/${rkey}` : `${window.location.origin}/post/${rkey}`
|
||||
const appUrl = getAppUrl(network)
|
||||
|
||||
const editBtn = canEdit ? `<button class="edit-btn" id="edit-btn">edit</button>` : ''
|
||||
|
||||
@@ -59,6 +55,8 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
|
||||
<div class="post-content" id="post-content">${renderMarkdown(post.content)}</div>
|
||||
</article>
|
||||
|
||||
${renderDiscussionLink(postUrl, appUrl)}
|
||||
|
||||
<div class="edit-form-container" id="edit-form-container" style="display: none;">
|
||||
<h3>Edit Post</h3>
|
||||
<form class="edit-form" id="edit-form">
|
||||
@@ -72,6 +70,9 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
|
||||
</div>
|
||||
`
|
||||
|
||||
// Load discussion posts
|
||||
loadDiscussionPosts(container, postUrl)
|
||||
|
||||
if (canEdit) {
|
||||
const editBtnEl = document.getElementById('edit-btn')
|
||||
const editFormContainer = document.getElementById('edit-form-container')
|
||||
|
||||
125
src/lib/api.ts
125
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,21 +24,67 @@ export function getAgent(service: string): AtpAgent {
|
||||
return agents.get(service)!
|
||||
}
|
||||
|
||||
// Fallback PLC directories
|
||||
const FALLBACK_PLCS = [
|
||||
'https://plc.directory',
|
||||
'https://plc.syu.is',
|
||||
]
|
||||
|
||||
// Fallback endpoints for handle/profile resolution
|
||||
const FALLBACK_ENDPOINTS = [
|
||||
'https://public.api.bsky.app',
|
||||
'https://bsky.syu.is',
|
||||
]
|
||||
|
||||
export async function resolvePds(did: string): Promise<string> {
|
||||
const res = await fetch(`${getPlc()}/${did}`)
|
||||
// Try current PLC first, then fallbacks
|
||||
const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())]
|
||||
|
||||
for (const plc of plcs) {
|
||||
try {
|
||||
const res = await fetch(`${plc}/${did}`)
|
||||
if (!res.ok) continue
|
||||
const doc = await res.json()
|
||||
const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
|
||||
return service?.serviceEndpoint || getBsky()
|
||||
if (service?.serviceEndpoint) {
|
||||
return service.serviceEndpoint
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return getBsky()
|
||||
}
|
||||
|
||||
export async function resolveHandle(handle: string): Promise<string> {
|
||||
// Try current network first
|
||||
try {
|
||||
const agent = getAgent(getBsky())
|
||||
const res = await agent.resolveHandle({ handle })
|
||||
return res.data.did
|
||||
} catch {
|
||||
// Try fallback endpoints
|
||||
for (const endpoint of FALLBACK_ENDPOINTS) {
|
||||
if (endpoint === getBsky()) continue // Skip if same as current
|
||||
try {
|
||||
const agent = getAgent(endpoint)
|
||||
const res = await agent.resolveHandle({ handle })
|
||||
return res.data.did
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not resolve handle: ${handle}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfile(actor: string): Promise<Profile> {
|
||||
const agent = getAgent(getBsky())
|
||||
// Try current network first
|
||||
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const agent = getAgent(endpoint)
|
||||
const res = await agent.getProfile({ actor })
|
||||
return {
|
||||
did: res.data.did,
|
||||
@@ -48,6 +94,11 @@ export async function getProfile(actor: string): Promise<Profile> {
|
||||
avatar: res.data.avatar,
|
||||
banner: res.data.banner,
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not get profile: ${actor}`)
|
||||
}
|
||||
|
||||
export async function listRecords(
|
||||
@@ -190,6 +241,72 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
|
||||
'pub.leaflet': { domain: 'leaflet.pub' },
|
||||
}
|
||||
|
||||
// Search Bluesky posts mentioning a URL
|
||||
export async function searchPostsForUrl(url: string): Promise<any[]> {
|
||||
// Search ALL endpoints and merge results (different networks have different indexes)
|
||||
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
|
||||
|
||||
// Extract search-friendly patterns from URL
|
||||
// e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"]
|
||||
const searchQueries: string[] = []
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathWithDomain = urlObj.host + urlObj.pathname.replace(/\/$/, '')
|
||||
searchQueries.push(pathWithDomain) // syui.ai/post/abc123
|
||||
// Also try shorter path for broader search
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean)
|
||||
if (pathParts.length >= 1) {
|
||||
searchQueries.push(urlObj.host + '/' + pathParts[0]) // syui.ai/post
|
||||
}
|
||||
} catch {
|
||||
searchQueries.push(url)
|
||||
}
|
||||
|
||||
// Search all endpoints in parallel and collect results
|
||||
const allPosts: any[] = []
|
||||
const seenUris = new Set<string>()
|
||||
|
||||
const searchPromises = endpoints.flatMap(endpoint =>
|
||||
searchQueries.map(async query => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`
|
||||
)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
// Filter posts that actually link to the target URL
|
||||
return (data.posts || []).filter((post: any) => {
|
||||
const embedUri = post.record?.embed?.external?.uri
|
||||
const text = post.record?.text || ''
|
||||
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const results = await Promise.all(searchPromises)
|
||||
|
||||
// Merge results, removing duplicates by URI
|
||||
for (const posts of results) {
|
||||
for (const post of posts) {
|
||||
if (!seenUris.has(post.uri)) {
|
||||
seenUris.add(post.uri)
|
||||
allPosts.push(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
allPosts.sort((a, b) =>
|
||||
new Date(b.record?.createdAt || 0).getTime() - new Date(a.record?.createdAt || 0).getTime()
|
||||
)
|
||||
|
||||
return allPosts
|
||||
}
|
||||
|
||||
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
|
||||
// Try to find matching service prefix
|
||||
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
|
||||
|
||||
7
src/lib/utils.ts
Normal file
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, '"')
|
||||
}
|
||||
144
src/main.ts
144
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 `<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${escapeHtml(key)}">
|
||||
<span class="pds-name">${escapeHtml(key)}</span>
|
||||
<span class="pds-check">✓</span>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="pds-selector" id="pds-selector">
|
||||
<button type="button" class="tab" id="pds-tab">PDS</button>
|
||||
<div class="pds-dropdown" id="pds-dropdown">
|
||||
${options}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function updatePdsSelector(): void {
|
||||
const dropdown = document.getElementById('pds-dropdown')
|
||||
if (!dropdown) return
|
||||
|
||||
const options = dropdown.querySelectorAll('.pds-option')
|
||||
options.forEach(opt => {
|
||||
const el = opt as HTMLElement
|
||||
const network = el.dataset.network
|
||||
const isSelected = network === browserNetwork
|
||||
el.classList.toggle('selected', isSelected)
|
||||
})
|
||||
}
|
||||
|
||||
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
|
||||
let tabs = `
|
||||
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
||||
@@ -52,6 +89,8 @@ function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean):
|
||||
tabs += `<a href="/post" class="tab ${activeTab === 'new' ? 'active' : ''}">Post</a>`
|
||||
}
|
||||
|
||||
tabs += renderPdsSelector()
|
||||
|
||||
return `<div class="mode-tabs">${tabs}</div>`
|
||||
}
|
||||
|
||||
@@ -123,6 +162,12 @@ async function loadBrowserContent(): Promise<void> {
|
||||
const contentEl = document.getElementById('content')
|
||||
if (!contentEl) return
|
||||
|
||||
// Set network config for browser
|
||||
const browserNetworkConfig = networks[browserNetwork]
|
||||
if (browserNetworkConfig) {
|
||||
setNetworkConfig(browserNetworkConfig)
|
||||
}
|
||||
|
||||
const loginDid = authSession?.did || null
|
||||
await mountAtBrowser(
|
||||
contentEl,
|
||||
@@ -217,14 +262,6 @@ async function addEditButtonToStaticPost(collection: string, rkey: string, sessi
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// Refresh post list from API (for static pages)
|
||||
async function refreshPostListFromAPI(): Promise<void> {
|
||||
const contentEl = document.getElementById('content')
|
||||
@@ -327,6 +364,57 @@ function setupEventHandlers(): void {
|
||||
return
|
||||
}
|
||||
|
||||
// PDS tab button - toggle dropdown
|
||||
if (target.id === 'pds-tab' || target.closest('#pds-tab')) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const dropdown = document.getElementById('pds-dropdown')
|
||||
if (dropdown) {
|
||||
dropdown.classList.toggle('show')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PDS option selection
|
||||
const pdsOption = target.closest('.pds-option') as HTMLElement
|
||||
if (pdsOption) {
|
||||
e.preventDefault()
|
||||
const selectedNetwork = pdsOption.dataset.network
|
||||
if (selectedNetwork && selectedNetwork !== browserNetwork) {
|
||||
browserNetwork = selectedNetwork
|
||||
localStorage.setItem('browserNetwork', selectedNetwork)
|
||||
|
||||
// Update network config for API and Auth
|
||||
const networkConfig = networks[selectedNetwork]
|
||||
if (networkConfig) {
|
||||
setNetworkConfig(networkConfig)
|
||||
setAuthNetworkConfig(networkConfig)
|
||||
}
|
||||
|
||||
// Update UI
|
||||
updatePdsSelector()
|
||||
|
||||
// Reload browser if in browser mode
|
||||
if (browserMode) {
|
||||
loadBrowserContent()
|
||||
}
|
||||
}
|
||||
// Close dropdown
|
||||
const dropdown = document.getElementById('pds-dropdown')
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('show')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Close PDS dropdown when clicking outside
|
||||
if (!target.closest('#pds-selector')) {
|
||||
const dropdown = document.getElementById('pds-dropdown')
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('show')
|
||||
}
|
||||
}
|
||||
|
||||
// JSON button click (on post detail page)
|
||||
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
||||
if (jsonBtn) {
|
||||
@@ -432,12 +520,17 @@ async function render(): Promise<void> {
|
||||
}
|
||||
}, true)
|
||||
|
||||
// Update tabs to show Post tab if logged in
|
||||
if (isLoggedIn) {
|
||||
// Update tabs
|
||||
const tabsEl = document.querySelector('.mode-tabs')
|
||||
if (tabsEl && !tabsEl.querySelector('a[href="/post"]')) {
|
||||
if (tabsEl) {
|
||||
// Add Post tab if logged in
|
||||
if (isLoggedIn && !tabsEl.querySelector('a[href="/post"]')) {
|
||||
tabsEl.insertAdjacentHTML('beforeend', '<a href="/post" class="tab">Post</a>')
|
||||
}
|
||||
// Add PDS selector if not present
|
||||
if (!tabsEl.querySelector('#pds-selector')) {
|
||||
tabsEl.insertAdjacentHTML('beforeend', renderPdsSelector())
|
||||
}
|
||||
}
|
||||
|
||||
// For post pages, add edit button if logged in and can edit
|
||||
@@ -445,6 +538,17 @@ async function render(): Promise<void> {
|
||||
addEditButtonToStaticPost(config.collection, route.rkey, authSession!)
|
||||
}
|
||||
|
||||
// For post pages, load discussion posts
|
||||
if (route.type === 'post') {
|
||||
const discussionContainer = document.getElementById('discussion-posts')
|
||||
if (discussionContainer) {
|
||||
const postUrl = discussionContainer.dataset.postUrl
|
||||
if (postUrl) {
|
||||
loadDiscussionPosts(discussionContainer.parentElement!, postUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For blog top page, check for new posts from API and merge
|
||||
if (route.type === 'blog') {
|
||||
refreshPostListFromAPI()
|
||||
@@ -505,7 +609,7 @@ async function render(): Promise<void> {
|
||||
const post = await getRecord(profile.did, config.collection, route.rkey!)
|
||||
if (post) {
|
||||
const canEdit = isLoggedIn && authSession?.did === profile.did
|
||||
mountPostDetail(contentEl, post, config.handle, config.collection, canEdit)
|
||||
mountPostDetail(contentEl, post, config.handle, config.collection, canEdit, config.siteUrl, config.network)
|
||||
} else {
|
||||
contentEl.innerHTML = '<p>Post not found</p>'
|
||||
}
|
||||
@@ -538,8 +642,9 @@ async function render(): Promise<void> {
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
const [configData, networks] = await Promise.all([loadConfig(), loadNetworks()])
|
||||
const [configData, networksData] = await Promise.all([loadConfig(), loadNetworks()])
|
||||
config = configData
|
||||
networks = networksData
|
||||
|
||||
// Set page title
|
||||
document.title = config.title || 'ailog'
|
||||
@@ -549,11 +654,14 @@ async function init(): Promise<void> {
|
||||
document.documentElement.style.setProperty('--btn-color', config.color)
|
||||
}
|
||||
|
||||
// Set network config
|
||||
const networkConfig = networks[config.network]
|
||||
if (networkConfig) {
|
||||
setNetworkConfig(networkConfig)
|
||||
setAuthNetworkConfig(networkConfig)
|
||||
// Initialize browser network from localStorage or default to config.network
|
||||
browserNetwork = localStorage.getItem('browserNetwork') || config.network
|
||||
|
||||
// Set network config based on selected browser network
|
||||
const selectedNetworkConfig = networks[browserNetwork]
|
||||
if (selectedNetworkConfig) {
|
||||
setNetworkConfig(selectedNetworkConfig)
|
||||
setAuthNetworkConfig(selectedNetworkConfig)
|
||||
}
|
||||
|
||||
// Handle OAuth callback
|
||||
|
||||
@@ -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;
|
||||
@@ -838,11 +1078,14 @@ body {
|
||||
.record-link {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-rkey {
|
||||
color: var(--btn-color);
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.record-preview {
|
||||
@@ -850,6 +1093,8 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface AppConfig {
|
||||
collection: string
|
||||
network: string
|
||||
color?: string
|
||||
siteUrl?: string
|
||||
}
|
||||
|
||||
export type Networks = Record<string, NetworkConfig>
|
||||
|
||||
Reference in New Issue
Block a user