This commit is contained in:
2026-01-18 18:08:53 +09:00
commit 75b3f029d8
52 changed files with 6630 additions and 0 deletions

View File

@@ -0,0 +1,222 @@
// AT-Browser: Server info and collection hierarchy
// Group collections by service domain
function groupCollectionsByService(collections: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>()
for (const collection of collections) {
// Extract service from collection (e.g., "app.bsky.feed.post" -> "bsky.app")
const parts = collection.split('.')
let service: string
if (parts.length >= 2) {
// Reverse first two parts: app.bsky -> bsky.app, ai.syui -> syui.ai
service = `${parts[1]}.${parts[0]}`
} else {
service = collection
}
if (!groups.has(service)) {
groups.set(service, [])
}
groups.get(service)!.push(collection)
}
return groups
}
// Local favicon mappings
const localFavicons: Record<string, string> = {
'syui.ai': '/favicon/syui.ai.png',
'bsky.app': '/favicon/bsky.app.png',
'atproto.com': '/favicon/atproto.com.png',
}
// Get favicon URL for service
function getFaviconUrl(service: string): string {
if (localFavicons[service]) {
return localFavicons[service]
}
return `https://www.google.com/s2/favicons?domain=${service}&sz=32`
}
// Render compact collection buttons for user page (horizontal)
export function renderCollectionButtons(collections: string[], handle: string): string {
if (collections.length === 0) {
return ''
}
const groups = groupCollectionsByService(collections)
const buttons = Array.from(groups.keys()).map(service => {
const favicon = getFaviconUrl(service)
return `
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="collection-btn" title="${service}">
<img src="${favicon}" alt="" class="collection-btn-icon" onerror="this.style.display='none'">
<span>${service}</span>
</a>
`
}).join('')
return `<div class="collection-buttons">${buttons}</div>`
}
// Render server info section (for AT-Browser)
export function renderServerInfo(did: string, pds: string | null): string {
return `
<div class="server-info">
<h3>Server</h3>
<dl class="server-details">
<div class="server-row">
<dt>PDS</dt>
<dd>${pds || 'Unknown'}</dd>
</div>
<div class="server-row">
<dt>DID</dt>
<dd>${did}</dd>
</div>
</dl>
</div>
`
}
// Render service list (grouped collections) for AT-Browser
export function renderServiceList(collections: string[], handle: string): string {
if (collections.length === 0) {
return '<p class="no-collections">No collections found.</p>'
}
const groups = groupCollectionsByService(collections)
const items = Array.from(groups.entries()).map(([service, cols]) => {
const favicon = getFaviconUrl(service)
const count = cols.length
return `
<li class="service-list-item">
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="service-list-link">
<img src="${favicon}" alt="" class="service-list-favicon" onerror="this.style.display='none'">
<span class="service-list-name">${service}</span>
<span class="service-list-count">${count}</span>
</a>
</li>
`
}).join('')
return `
<div class="services-list">
<h3>Collections</h3>
<ul class="service-list">${items}</ul>
</div>
`
}
// Render collections for a specific service
export function renderCollectionList(
collections: string[],
handle: string,
service: string
): string {
const favicon = getFaviconUrl(service)
const items = collections.map(collection => {
return `
<li class="collection-item">
<a href="/@${handle}/at/collection/${collection}" class="collection-link">
<span class="collection-nsid">${collection}</span>
</a>
</li>
`
}).join('')
return `
<div class="collections">
<h3 class="collection-header">
<img src="${favicon}" alt="" class="collection-header-favicon" onerror="this.style.display='none'">
${service}
</h3>
<ul class="collection-list">${items}</ul>
</div>
`
}
// Render records list
export function renderRecordList(
records: { uri: string; cid: string; value: unknown }[],
handle: string,
collection: string
): string {
if (records.length === 0) {
return '<p class="no-records">No records found.</p>'
}
const items = records.map(record => {
const rkey = record.uri.split('/').pop() || ''
const value = record.value as Record<string, unknown>
const preview = getRecordPreview(value)
return `
<li class="record-item">
<a href="/@${handle}/at/collection/${collection}/${rkey}" class="record-link">
<span class="record-rkey">${rkey}</span>
<span class="record-preview">${escapeHtml(preview)}</span>
</a>
</li>
`
}).join('')
return `
<div class="records">
<h3>${collection}</h3>
<p class="record-count">${records.length} records</p>
<ul class="record-list">${items}</ul>
</div>
`
}
// Render single record detail
export function renderRecordDetail(
record: { uri: string; cid: string; value: unknown },
collection: string,
isOwner: boolean = false
): string {
const rkey = record.uri.split('/').pop() || ''
const deleteBtn = isOwner ? `
<button type="button" class="record-delete-btn" id="record-delete-btn" data-collection="${collection}" data-rkey="${rkey}">Delete</button>
` : ''
return `
<article class="record-detail">
<header class="record-header">
<h3>${collection}</h3>
<p class="record-uri">URI: ${record.uri}</p>
<p class="record-cid">CID: ${record.cid}</p>
${deleteBtn}
</header>
<div class="json-view">
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>
</div>
</article>
`
}
// Get preview text from record value
function getRecordPreview(value: Record<string, unknown>): string {
if (typeof value.text === 'string') return value.text.slice(0, 60)
if (typeof value.title === 'string') return value.title
if (typeof value.name === 'string') return value.name
if (typeof value.displayName === 'string') return value.displayName
if (typeof value.handle === 'string') return value.handle
if (typeof value.subject === 'string') return value.subject
if (typeof value.description === 'string') return value.description.slice(0, 60)
return ''
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

View File

@@ -0,0 +1,111 @@
import { searchPostsForUrl, getCurrentNetwork, type SearchPost } from '../lib/api'
const DISCUSSION_POST_LIMIT = 10
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 renderDiscussion(postUrl: string, appUrl: string = 'https://bsky.app'): string {
// Build search URL with host/@username only
let searchQuery = postUrl
try {
const urlObj = new URL(postUrl)
const pathParts = urlObj.pathname.split('/').filter(Boolean)
// pathParts[0] = @username.domain (e.g., @syui.syui.ai)
// Extract just @username
if (pathParts[0]?.startsWith('@')) {
const handlePart = pathParts[0].slice(1) // remove @
const username = handlePart.split('.')[0] // get first part before .
searchQuery = `${urlObj.host}/@${username}`
} else {
searchQuery = urlObj.host
}
} catch {
// Keep original
}
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
return `
<div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discussion-link">
<svg width="18" height="18" 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 network config (overrides default)
const network = await getCurrentNetwork()
const dataAppUrl = network.web || postsContainer.dataset.appUrl || appUrl
postsContainer.innerHTML = '<div class="loading-small">Loading comments...</div>'
const posts = await searchPostsForUrl(postUrl)
if (posts.length === 0) {
postsContainer.innerHTML = ''
return
}
const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map((post: SearchPost) => {
const author = post.author
const avatar = author.avatar || ''
const displayName = author.displayName || author.handle
const handle = author.handle
const record = post.record as { text?: string; createdAt?: string }
const text = record?.text || ''
const createdAt = record?.createdAt || ''
const postLink = getPostUrl(post.uri, dataAppUrl)
// Truncate text
const truncatedText = text.length > 200 ? text.slice(0, 200) + '...' : text
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-avatar-placeholder"></div>'}
<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(truncatedText)}</div>
</a>
`
}).join('')
postsContainer.innerHTML = postsHtml
}

View File

@@ -0,0 +1,17 @@
export function renderFooter(handle: string): string {
// Extract username from handle: {username}.{name}.{domain} -> username
const username = handle.split('.')[0] || handle
return `
<footer id="footer" class="footer">
<div class="license">
<a href="https://git.syui.ai/ai/log" target="_blank" rel="noopener">
<img src="/ai.svg" alt="ai" class="license-icon">
</a>
</div>
<div class="footer-content">
<span class="footer-copy">© ${username}</span>
</div>
</footer>
`
}

View File

@@ -0,0 +1,45 @@
import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
export function renderHeader(currentHandle: string): string {
const loggedIn = isLoggedIn()
const handle = getLoggedInHandle()
const loginBtn = loggedIn
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout">${handle || 'logout'}</button>`
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login"><img src="/icon/user.svg" alt="Login" class="login-icon"></button>`
return `
<header id="header">
<form class="header-form" id="header-form">
<input
type="text"
class="header-input"
id="header-input"
placeholder="handle (e.g., syui.ai)"
value="${currentHandle}"
>
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
${loginBtn}
</form>
</header>
`
}
export function mountHeader(
container: HTMLElement,
currentHandle: string,
onBrowse: (handle: string) => void
): void {
container.innerHTML = renderHeader(currentHandle)
const form = document.getElementById('header-form') as HTMLFormElement
const input = document.getElementById('header-input') as HTMLInputElement
form?.addEventListener('submit', (e) => {
e.preventDefault()
const handle = input.value.trim()
if (handle) {
onBrowse(handle)
}
})
}

View File

@@ -0,0 +1,22 @@
// Loading indicator component
export function showLoading(container: HTMLElement): void {
const existing = container.querySelector('.loading-overlay')
if (existing) return
const overlay = document.createElement('div')
overlay.className = 'loading-overlay'
overlay.innerHTML = '<div class="loading-spinner"></div>'
container.appendChild(overlay)
}
export function hideLoading(container: HTMLElement): void {
const overlay = container.querySelector('.loading-overlay')
if (overlay) {
overlay.remove()
}
}
export function renderLoadingSmall(): string {
return '<div class="loading-small">Loading...</div>'
}

View File

@@ -0,0 +1,155 @@
import { getNetworks } from '../lib/api'
import { isLoggedIn } from '../lib/auth'
let currentNetwork = 'bsky.social'
let currentLang = localStorage.getItem('preferred-lang') || 'en'
export function getCurrentNetwork(): string {
return currentNetwork
}
export function setCurrentNetwork(network: string): void {
currentNetwork = network
}
export function getCurrentLang(): string {
return currentLang
}
export function setCurrentLang(lang: string): void {
currentLang = lang
localStorage.setItem('preferred-lang', lang)
}
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string {
let tabs = `
<a href="/" class="tab">/</a>
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</a>
`
if (isLoggedIn()) {
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
}
tabs += `
<div class="pds-selector" id="pds-selector">
<button type="button" class="tab" id="pds-tab">pds</button>
<div class="pds-dropdown" id="pds-dropdown"></div>
</div>
`
return `<div class="mode-tabs">${tabs}</div>`
}
// Render language selector (above content)
export function renderLangSelector(langs: string[]): string {
if (langs.length < 2) return ''
return `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-tab">
<img src="/icon/language.svg" alt="Lang" class="lang-icon">
</button>
<div class="lang-dropdown" id="lang-dropdown"></div>
</div>
`
}
export async function setupModeTabs(onNetworkChange: (network: string) => void, availableLangs?: string[], onLangChange?: (lang: string) => void): Promise<void> {
const pdsTab = document.getElementById('pds-tab')
const pdsDropdown = document.getElementById('pds-dropdown')
if (pdsTab && pdsDropdown) {
// Load networks
const networks = await getNetworks()
// Build options
const optionsHtml = Object.keys(networks).map(key => {
const isSelected = key === currentNetwork
return `
<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${key}">
<span class="pds-name">${key}</span>
<span class="pds-check">✓</span>
</div>
`
}).join('')
pdsDropdown.innerHTML = optionsHtml
// Toggle dropdown
pdsTab.addEventListener('click', (e) => {
e.stopPropagation()
pdsDropdown.classList.toggle('show')
})
// Handle option selection
pdsDropdown.querySelectorAll('.pds-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation()
const network = (opt as HTMLElement).dataset.network || ''
currentNetwork = network
// Update UI
pdsDropdown.querySelectorAll('.pds-option').forEach(o => {
o.classList.remove('selected')
})
opt.classList.add('selected')
pdsDropdown.classList.remove('show')
onNetworkChange(network)
})
})
}
// Setup language selector
const langTab = document.getElementById('lang-tab')
const langDropdown = document.getElementById('lang-dropdown')
if (langTab && langDropdown && availableLangs && availableLangs.length > 0) {
// Build language options
const langOptionsHtml = availableLangs.map(lang => {
const isSelected = lang === currentLang
return `
<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang}">
<span class="lang-name">${lang.toUpperCase()}</span>
<span class="lang-check">✓</span>
</div>
`
}).join('')
langDropdown.innerHTML = langOptionsHtml
// Toggle dropdown
langTab.addEventListener('click', (e) => {
e.stopPropagation()
langDropdown.classList.toggle('show')
})
// Handle option selection
langDropdown.querySelectorAll('.lang-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation()
const lang = (opt as HTMLElement).dataset.lang || ''
setCurrentLang(lang)
// Update UI
langDropdown.querySelectorAll('.lang-option').forEach(o => {
o.classList.remove('selected')
})
opt.classList.add('selected')
langDropdown.classList.remove('show')
if (onLangChange) onLangChange(lang)
})
})
}
// Close dropdowns on outside click
document.addEventListener('click', () => {
pdsDropdown?.classList.remove('show')
langDropdown?.classList.remove('show')
})
}

View File

@@ -0,0 +1,91 @@
import { getNetworks } from '../lib/api'
let currentPds: string | null = null
export function getCurrentPds(): string | null {
return currentPds
}
export function setCurrentPds(pds: string): void {
currentPds = pds
}
export function renderPdsSelector(): string {
return `
<div class="pds-selector">
<button class="pds-trigger" id="pds-trigger">
<span>pds</span>
</button>
<div class="pds-dropdown" id="pds-dropdown" style="display: none;">
<div class="pds-dropdown-content" id="pds-dropdown-content">
<!-- Options loaded dynamically -->
</div>
</div>
</div>
`
}
export async function setupPdsSelector(onSelect: (pds: string, domain: string) => void): Promise<void> {
const trigger = document.getElementById('pds-trigger')
const dropdown = document.getElementById('pds-dropdown')
const content = document.getElementById('pds-dropdown-content')
if (!trigger || !dropdown || !content) return
// Load networks and build options
const networks = await getNetworks()
const firstDomain = Object.keys(networks)[0]
// Set default
if (!currentPds && firstDomain) {
currentPds = networks[firstDomain].bsky
}
const optionsHtml = Object.entries(networks).map(([domain, network]) => {
const isSelected = currentPds === network.bsky
return `
<button class="pds-option ${isSelected ? 'selected' : ''}" data-pds="${network.bsky}" data-domain="${domain}">
<span class="pds-option-name">${domain}</span>
<span class="pds-option-check">${isSelected ? '●' : '○'}</span>
</button>
`
}).join('')
content.innerHTML = optionsHtml
// Toggle dropdown
trigger.addEventListener('click', (e) => {
e.stopPropagation()
const isVisible = dropdown.style.display !== 'none'
dropdown.style.display = isVisible ? 'none' : 'block'
})
// Close on outside click
document.addEventListener('click', () => {
dropdown.style.display = 'none'
})
// Handle option selection
content.querySelectorAll('.pds-option').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation()
const pds = btn.getAttribute('data-pds') || ''
const domain = btn.getAttribute('data-domain') || ''
currentPds = pds
// Update UI
content.querySelectorAll('.pds-option').forEach(b => {
b.classList.remove('selected')
const check = b.querySelector('.pds-option-check')
if (check) check.textContent = '○'
})
btn.classList.add('selected')
const check = btn.querySelector('.pds-option-check')
if (check) check.textContent = '●'
dropdown.style.display = 'none'
onSelect(pds, domain)
})
})
}

View File

@@ -0,0 +1,69 @@
import { createPost } from '../lib/auth'
export function renderPostForm(collection: string): string {
return `
<div class="post-form-container">
<form class="post-form" id="post-form">
<input
type="text"
class="post-form-title"
id="post-title"
placeholder="Title"
required
>
<textarea
class="post-form-body"
id="post-body"
placeholder="Content (markdown)"
rows="6"
required
></textarea>
<div class="post-form-footer">
<span class="post-form-collection">${collection}</span>
<button type="submit" class="post-form-btn" id="post-submit">Post</button>
</div>
</form>
<div id="post-status" class="post-status"></div>
</div>
`
}
export function setupPostForm(collection: string, onSuccess: () => void): void {
const form = document.getElementById('post-form') as HTMLFormElement
const titleInput = document.getElementById('post-title') as HTMLInputElement
const bodyInput = document.getElementById('post-body') as HTMLTextAreaElement
const submitBtn = document.getElementById('post-submit') as HTMLButtonElement
const statusEl = document.getElementById('post-status') as HTMLDivElement
if (!form) return
form.addEventListener('submit', async (e) => {
e.preventDefault()
const title = titleInput.value.trim()
const body = bodyInput.value.trim()
if (!title || !body) return
submitBtn.disabled = true
submitBtn.textContent = 'Posting...'
statusEl.innerHTML = ''
try {
const result = await createPost(collection, title, body)
if (result) {
statusEl.innerHTML = `<span class="post-success">Posted!</span>`
titleInput.value = ''
bodyInput.value = ''
setTimeout(() => {
onSuccess()
}, 1000)
}
} catch (err) {
statusEl.innerHTML = `<span class="post-error">Error: ${err}</span>`
} finally {
submitBtn.disabled = false
submitBtn.textContent = 'Post'
}
})
}

132
src/web/components/posts.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { Post } from '../types'
import { renderMarkdown } from '../lib/markdown'
import { renderDiscussion, loadDiscussionPosts } from './discussion'
import { getCurrentLang } from './mode-tabs'
// Render post list
export function renderPostList(posts: Post[], handle: string): string {
if (posts.length === 0) {
return '<p class="no-posts">No posts yet.</p>'
}
const currentLang = getCurrentLang()
const items = posts.map(post => {
const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('en-US')
const originalLang = post.value.lang || 'ja'
const translations = post.value.translations
// Use translation if available
let displayTitle = post.value.title
if (translations && currentLang !== originalLang && translations[currentLang]) {
displayTitle = translations[currentLang].title || post.value.title
}
return `
<article class="post-item">
<a href="/@${handle}/${rkey}" class="post-link">
<h2 class="post-title">${escapeHtml(displayTitle)}</h2>
<time class="post-date">${date}</time>
</a>
</article>
`
}).join('')
return `<div class="post-list">${items}</div>`
}
// Render single post detail
export function renderPostDetail(
post: Post,
handle: string,
collection: string,
isOwner: boolean = false,
siteUrl?: string,
appUrl: string = 'https://bsky.app'
): string {
const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('en-US')
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}`
// Build post URL for discussion search
const postUrl = siteUrl ? `${siteUrl}/@${handle}/${rkey}` : `${window.location.origin}/@${handle}/${rkey}`
const editBtn = isOwner ? `<button type="button" class="post-edit-btn" id="post-edit-btn">Edit</button>` : ''
// Get current language and show appropriate content
const currentLang = getCurrentLang()
const translations = post.value.translations
const originalLang = post.value.lang || 'ja'
let displayTitle = post.value.title
let displayContent = post.value.content
// Use translation if available and not original language
if (translations && currentLang !== originalLang && translations[currentLang]) {
const trans = translations[currentLang]
displayTitle = trans.title || post.value.title
displayContent = trans.content
}
const content = renderMarkdown(displayContent)
const editForm = isOwner ? `
<div class="post-edit-form" id="post-edit-form" style="display: none;">
<input type="text" class="post-edit-title" id="post-edit-title" value="${escapeHtml(post.value.title)}" placeholder="Title">
<textarea class="post-edit-content" id="post-edit-content" rows="15">${escapeHtml(post.value.content)}</textarea>
<div class="post-edit-actions">
<button type="button" class="post-edit-cancel" id="post-edit-cancel">Cancel</button>
<button type="button" class="post-edit-save" id="post-edit-save" data-collection="${collection}" data-rkey="${rkey}">Save</button>
</div>
</div>
` : ''
return `
<article class="post-detail" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}">
<header class="post-header">
<div class="post-meta">
<time class="post-date">${date}</time>
<a href="${jsonUrl}" class="json-btn">json</a>
${editBtn}
</div>
</header>
${editForm}
<div id="post-display">
<h1 class="post-title">${escapeHtml(displayTitle)}</h1>
<div class="post-content">${content}</div>
</div>
</article>
${renderDiscussion(postUrl, appUrl)}
`
}
// Setup post detail interactions (discussion loading)
export function setupPostDetail(container: HTMLElement): void {
const article = container.querySelector('.post-detail') as HTMLElement
if (!article) return
// Load discussion posts
const postUrl = article.dataset.postUrl
const appUrl = article.dataset.appUrl || 'https://bsky.app'
if (postUrl) {
loadDiscussionPosts(container, postUrl, appUrl)
}
}
export function mountPostList(container: HTMLElement, html: string): void {
container.innerHTML = html
}
export function mountPostDetail(container: HTMLElement, html: string): void {
container.innerHTML = html
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

View File

@@ -0,0 +1,48 @@
import type { Profile } from '../types'
import { getAvatarUrl } from '../lib/api'
export async function renderProfile(
did: string,
profile: Profile,
handle: string,
webUrl?: string
): Promise<string> {
const avatarUrl = await getAvatarUrl(did, profile)
const displayName = profile.value.displayName || handle || 'Unknown'
const description = profile.value.description || ''
// Build profile link (e.g., https://bsky.app/profile/did:plc:xxx)
const profileLink = webUrl ? `${webUrl}/profile/${did}` : null
const handleHtml = profileLink
? `<a href="${profileLink}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(handle)}</a>`
: `<span>@${escapeHtml(handle)}</span>`
const avatarHtml = avatarUrl
? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
: `<div class="profile-avatar-placeholder"></div>`
return `
<div class="profile">
${avatarHtml}
<div class="profile-info">
<h1 class="profile-name">${escapeHtml(displayName)}</h1>
<p class="profile-handle">${handleHtml}</p>
${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
</div>
</div>
`
}
export function mountProfile(container: HTMLElement, html: string): void {
container.innerHTML = html
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

13
src/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ailog</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>

260
src/web/lexicons/index.ts Normal file
View File

@@ -0,0 +1,260 @@
// Auto-generated from ATProto lexicons
// Run `ailog gen` to regenerate
// Do not edit manually
export interface Endpoint {
nsid: string
method: 'GET' | 'POST'
}
/** Build XRPC URL for an endpoint */
export function xrpcUrl(pds: string, endpoint: Endpoint): string {
return `https://${pds}/xrpc/${endpoint.nsid}`
}
export const appBskyActor = {
getPreferences: { nsid: 'app.bsky.actor.getPreferences', method: 'GET' } as Endpoint,
getProfile: { nsid: 'app.bsky.actor.getProfile', method: 'GET' } as Endpoint,
getProfiles: { nsid: 'app.bsky.actor.getProfiles', method: 'GET' } as Endpoint,
getSuggestions: { nsid: 'app.bsky.actor.getSuggestions', method: 'GET' } as Endpoint,
putPreferences: { nsid: 'app.bsky.actor.putPreferences', method: 'POST' } as Endpoint,
searchActors: { nsid: 'app.bsky.actor.searchActors', method: 'GET' } as Endpoint,
searchActorsTypeahead: { nsid: 'app.bsky.actor.searchActorsTypeahead', method: 'GET' } as Endpoint,
} as const
export const appBskyAgeassurance = {
begin: { nsid: 'app.bsky.ageassurance.begin', method: 'POST' } as Endpoint,
getConfig: { nsid: 'app.bsky.ageassurance.getConfig', method: 'GET' } as Endpoint,
getState: { nsid: 'app.bsky.ageassurance.getState', method: 'GET' } as Endpoint,
} as const
export const appBskyBookmark = {
createBookmark: { nsid: 'app.bsky.bookmark.createBookmark', method: 'POST' } as Endpoint,
deleteBookmark: { nsid: 'app.bsky.bookmark.deleteBookmark', method: 'POST' } as Endpoint,
getBookmarks: { nsid: 'app.bsky.bookmark.getBookmarks', method: 'GET' } as Endpoint,
} as const
export const appBskyContact = {
dismissMatch: { nsid: 'app.bsky.contact.dismissMatch', method: 'POST' } as Endpoint,
getMatches: { nsid: 'app.bsky.contact.getMatches', method: 'GET' } as Endpoint,
getSyncStatus: { nsid: 'app.bsky.contact.getSyncStatus', method: 'GET' } as Endpoint,
importContacts: { nsid: 'app.bsky.contact.importContacts', method: 'POST' } as Endpoint,
removeData: { nsid: 'app.bsky.contact.removeData', method: 'POST' } as Endpoint,
sendNotification: { nsid: 'app.bsky.contact.sendNotification', method: 'POST' } as Endpoint,
startPhoneVerification: { nsid: 'app.bsky.contact.startPhoneVerification', method: 'POST' } as Endpoint,
verifyPhone: { nsid: 'app.bsky.contact.verifyPhone', method: 'POST' } as Endpoint,
} as const
export const appBskyDraft = {
createDraft: { nsid: 'app.bsky.draft.createDraft', method: 'POST' } as Endpoint,
deleteDraft: { nsid: 'app.bsky.draft.deleteDraft', method: 'POST' } as Endpoint,
getDrafts: { nsid: 'app.bsky.draft.getDrafts', method: 'GET' } as Endpoint,
updateDraft: { nsid: 'app.bsky.draft.updateDraft', method: 'POST' } as Endpoint,
} as const
export const appBskyFeed = {
describeFeedGenerator: { nsid: 'app.bsky.feed.describeFeedGenerator', method: 'GET' } as Endpoint,
getActorFeeds: { nsid: 'app.bsky.feed.getActorFeeds', method: 'GET' } as Endpoint,
getActorLikes: { nsid: 'app.bsky.feed.getActorLikes', method: 'GET' } as Endpoint,
getAuthorFeed: { nsid: 'app.bsky.feed.getAuthorFeed', method: 'GET' } as Endpoint,
getFeed: { nsid: 'app.bsky.feed.getFeed', method: 'GET' } as Endpoint,
getFeedGenerator: { nsid: 'app.bsky.feed.getFeedGenerator', method: 'GET' } as Endpoint,
getFeedGenerators: { nsid: 'app.bsky.feed.getFeedGenerators', method: 'GET' } as Endpoint,
getFeedSkeleton: { nsid: 'app.bsky.feed.getFeedSkeleton', method: 'GET' } as Endpoint,
getLikes: { nsid: 'app.bsky.feed.getLikes', method: 'GET' } as Endpoint,
getListFeed: { nsid: 'app.bsky.feed.getListFeed', method: 'GET' } as Endpoint,
getPostThread: { nsid: 'app.bsky.feed.getPostThread', method: 'GET' } as Endpoint,
getPosts: { nsid: 'app.bsky.feed.getPosts', method: 'GET' } as Endpoint,
getQuotes: { nsid: 'app.bsky.feed.getQuotes', method: 'GET' } as Endpoint,
getRepostedBy: { nsid: 'app.bsky.feed.getRepostedBy', method: 'GET' } as Endpoint,
getSuggestedFeeds: { nsid: 'app.bsky.feed.getSuggestedFeeds', method: 'GET' } as Endpoint,
getTimeline: { nsid: 'app.bsky.feed.getTimeline', method: 'GET' } as Endpoint,
searchPosts: { nsid: 'app.bsky.feed.searchPosts', method: 'GET' } as Endpoint,
sendInteractions: { nsid: 'app.bsky.feed.sendInteractions', method: 'POST' } as Endpoint,
} as const
export const appBskyGraph = {
getActorStarterPacks: { nsid: 'app.bsky.graph.getActorStarterPacks', method: 'GET' } as Endpoint,
getBlocks: { nsid: 'app.bsky.graph.getBlocks', method: 'GET' } as Endpoint,
getFollowers: { nsid: 'app.bsky.graph.getFollowers', method: 'GET' } as Endpoint,
getFollows: { nsid: 'app.bsky.graph.getFollows', method: 'GET' } as Endpoint,
getKnownFollowers: { nsid: 'app.bsky.graph.getKnownFollowers', method: 'GET' } as Endpoint,
getList: { nsid: 'app.bsky.graph.getList', method: 'GET' } as Endpoint,
getListBlocks: { nsid: 'app.bsky.graph.getListBlocks', method: 'GET' } as Endpoint,
getListMutes: { nsid: 'app.bsky.graph.getListMutes', method: 'GET' } as Endpoint,
getLists: { nsid: 'app.bsky.graph.getLists', method: 'GET' } as Endpoint,
getListsWithMembership: { nsid: 'app.bsky.graph.getListsWithMembership', method: 'GET' } as Endpoint,
getMutes: { nsid: 'app.bsky.graph.getMutes', method: 'GET' } as Endpoint,
getRelationships: { nsid: 'app.bsky.graph.getRelationships', method: 'GET' } as Endpoint,
getStarterPack: { nsid: 'app.bsky.graph.getStarterPack', method: 'GET' } as Endpoint,
getStarterPacks: { nsid: 'app.bsky.graph.getStarterPacks', method: 'GET' } as Endpoint,
getStarterPacksWithMembership: { nsid: 'app.bsky.graph.getStarterPacksWithMembership', method: 'GET' } as Endpoint,
getSuggestedFollowsByActor: { nsid: 'app.bsky.graph.getSuggestedFollowsByActor', method: 'GET' } as Endpoint,
muteActor: { nsid: 'app.bsky.graph.muteActor', method: 'POST' } as Endpoint,
muteActorList: { nsid: 'app.bsky.graph.muteActorList', method: 'POST' } as Endpoint,
muteThread: { nsid: 'app.bsky.graph.muteThread', method: 'POST' } as Endpoint,
searchStarterPacks: { nsid: 'app.bsky.graph.searchStarterPacks', method: 'GET' } as Endpoint,
unmuteActor: { nsid: 'app.bsky.graph.unmuteActor', method: 'POST' } as Endpoint,
unmuteActorList: { nsid: 'app.bsky.graph.unmuteActorList', method: 'POST' } as Endpoint,
unmuteThread: { nsid: 'app.bsky.graph.unmuteThread', method: 'POST' } as Endpoint,
} as const
export const appBskyLabeler = {
getServices: { nsid: 'app.bsky.labeler.getServices', method: 'GET' } as Endpoint,
} as const
export const appBskyNotification = {
getPreferences: { nsid: 'app.bsky.notification.getPreferences', method: 'GET' } as Endpoint,
getUnreadCount: { nsid: 'app.bsky.notification.getUnreadCount', method: 'GET' } as Endpoint,
listActivitySubscriptions: { nsid: 'app.bsky.notification.listActivitySubscriptions', method: 'GET' } as Endpoint,
listNotifications: { nsid: 'app.bsky.notification.listNotifications', method: 'GET' } as Endpoint,
putActivitySubscription: { nsid: 'app.bsky.notification.putActivitySubscription', method: 'POST' } as Endpoint,
putPreferences: { nsid: 'app.bsky.notification.putPreferences', method: 'POST' } as Endpoint,
putPreferencesV2: { nsid: 'app.bsky.notification.putPreferencesV2', method: 'POST' } as Endpoint,
registerPush: { nsid: 'app.bsky.notification.registerPush', method: 'POST' } as Endpoint,
unregisterPush: { nsid: 'app.bsky.notification.unregisterPush', method: 'POST' } as Endpoint,
updateSeen: { nsid: 'app.bsky.notification.updateSeen', method: 'POST' } as Endpoint,
} as const
export const appBskyUnspecced = {
getAgeAssuranceState: { nsid: 'app.bsky.unspecced.getAgeAssuranceState', method: 'GET' } as Endpoint,
getConfig: { nsid: 'app.bsky.unspecced.getConfig', method: 'GET' } as Endpoint,
getOnboardingSuggestedStarterPacks: { nsid: 'app.bsky.unspecced.getOnboardingSuggestedStarterPacks', method: 'GET' } as Endpoint,
getOnboardingSuggestedStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton', method: 'GET' } as Endpoint,
getPopularFeedGenerators: { nsid: 'app.bsky.unspecced.getPopularFeedGenerators', method: 'GET' } as Endpoint,
getPostThreadOtherV2: { nsid: 'app.bsky.unspecced.getPostThreadOtherV2', method: 'GET' } as Endpoint,
getPostThreadV2: { nsid: 'app.bsky.unspecced.getPostThreadV2', method: 'GET' } as Endpoint,
getSuggestedFeeds: { nsid: 'app.bsky.unspecced.getSuggestedFeeds', method: 'GET' } as Endpoint,
getSuggestedFeedsSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedFeedsSkeleton', method: 'GET' } as Endpoint,
getSuggestedStarterPacks: { nsid: 'app.bsky.unspecced.getSuggestedStarterPacks', method: 'GET' } as Endpoint,
getSuggestedStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedStarterPacksSkeleton', method: 'GET' } as Endpoint,
getSuggestedUsers: { nsid: 'app.bsky.unspecced.getSuggestedUsers', method: 'GET' } as Endpoint,
getSuggestedUsersSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedUsersSkeleton', method: 'GET' } as Endpoint,
getSuggestionsSkeleton: { nsid: 'app.bsky.unspecced.getSuggestionsSkeleton', method: 'GET' } as Endpoint,
getTaggedSuggestions: { nsid: 'app.bsky.unspecced.getTaggedSuggestions', method: 'GET' } as Endpoint,
getTrendingTopics: { nsid: 'app.bsky.unspecced.getTrendingTopics', method: 'GET' } as Endpoint,
getTrends: { nsid: 'app.bsky.unspecced.getTrends', method: 'GET' } as Endpoint,
getTrendsSkeleton: { nsid: 'app.bsky.unspecced.getTrendsSkeleton', method: 'GET' } as Endpoint,
initAgeAssurance: { nsid: 'app.bsky.unspecced.initAgeAssurance', method: 'POST' } as Endpoint,
searchActorsSkeleton: { nsid: 'app.bsky.unspecced.searchActorsSkeleton', method: 'GET' } as Endpoint,
searchPostsSkeleton: { nsid: 'app.bsky.unspecced.searchPostsSkeleton', method: 'GET' } as Endpoint,
searchStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.searchStarterPacksSkeleton', method: 'GET' } as Endpoint,
} as const
export const appBskyVideo = {
getJobStatus: { nsid: 'app.bsky.video.getJobStatus', method: 'GET' } as Endpoint,
getUploadLimits: { nsid: 'app.bsky.video.getUploadLimits', method: 'GET' } as Endpoint,
uploadVideo: { nsid: 'app.bsky.video.uploadVideo', method: 'POST' } as Endpoint,
} as const
export const comAtprotoAdmin = {
deleteAccount: { nsid: 'com.atproto.admin.deleteAccount', method: 'POST' } as Endpoint,
disableAccountInvites: { nsid: 'com.atproto.admin.disableAccountInvites', method: 'POST' } as Endpoint,
disableInviteCodes: { nsid: 'com.atproto.admin.disableInviteCodes', method: 'POST' } as Endpoint,
enableAccountInvites: { nsid: 'com.atproto.admin.enableAccountInvites', method: 'POST' } as Endpoint,
getAccountInfo: { nsid: 'com.atproto.admin.getAccountInfo', method: 'GET' } as Endpoint,
getAccountInfos: { nsid: 'com.atproto.admin.getAccountInfos', method: 'GET' } as Endpoint,
getInviteCodes: { nsid: 'com.atproto.admin.getInviteCodes', method: 'GET' } as Endpoint,
getSubjectStatus: { nsid: 'com.atproto.admin.getSubjectStatus', method: 'GET' } as Endpoint,
searchAccounts: { nsid: 'com.atproto.admin.searchAccounts', method: 'GET' } as Endpoint,
sendEmail: { nsid: 'com.atproto.admin.sendEmail', method: 'POST' } as Endpoint,
updateAccountEmail: { nsid: 'com.atproto.admin.updateAccountEmail', method: 'POST' } as Endpoint,
updateAccountHandle: { nsid: 'com.atproto.admin.updateAccountHandle', method: 'POST' } as Endpoint,
updateAccountPassword: { nsid: 'com.atproto.admin.updateAccountPassword', method: 'POST' } as Endpoint,
updateAccountSigningKey: { nsid: 'com.atproto.admin.updateAccountSigningKey', method: 'POST' } as Endpoint,
updateSubjectStatus: { nsid: 'com.atproto.admin.updateSubjectStatus', method: 'POST' } as Endpoint,
} as const
export const comAtprotoIdentity = {
getRecommendedDidCredentials: { nsid: 'com.atproto.identity.getRecommendedDidCredentials', method: 'GET' } as Endpoint,
refreshIdentity: { nsid: 'com.atproto.identity.refreshIdentity', method: 'POST' } as Endpoint,
requestPlcOperationSignature: { nsid: 'com.atproto.identity.requestPlcOperationSignature', method: 'POST' } as Endpoint,
resolveDid: { nsid: 'com.atproto.identity.resolveDid', method: 'GET' } as Endpoint,
resolveHandle: { nsid: 'com.atproto.identity.resolveHandle', method: 'GET' } as Endpoint,
resolveIdentity: { nsid: 'com.atproto.identity.resolveIdentity', method: 'GET' } as Endpoint,
signPlcOperation: { nsid: 'com.atproto.identity.signPlcOperation', method: 'POST' } as Endpoint,
submitPlcOperation: { nsid: 'com.atproto.identity.submitPlcOperation', method: 'POST' } as Endpoint,
updateHandle: { nsid: 'com.atproto.identity.updateHandle', method: 'POST' } as Endpoint,
} as const
export const comAtprotoLabel = {
queryLabels: { nsid: 'com.atproto.label.queryLabels', method: 'GET' } as Endpoint,
} as const
export const comAtprotoLexicon = {
resolveLexicon: { nsid: 'com.atproto.lexicon.resolveLexicon', method: 'GET' } as Endpoint,
} as const
export const comAtprotoModeration = {
createReport: { nsid: 'com.atproto.moderation.createReport', method: 'POST' } as Endpoint,
} as const
export const comAtprotoRepo = {
applyWrites: { nsid: 'com.atproto.repo.applyWrites', method: 'POST' } as Endpoint,
createRecord: { nsid: 'com.atproto.repo.createRecord', method: 'POST' } as Endpoint,
deleteRecord: { nsid: 'com.atproto.repo.deleteRecord', method: 'POST' } as Endpoint,
describeRepo: { nsid: 'com.atproto.repo.describeRepo', method: 'GET' } as Endpoint,
getRecord: { nsid: 'com.atproto.repo.getRecord', method: 'GET' } as Endpoint,
importRepo: { nsid: 'com.atproto.repo.importRepo', method: 'POST' } as Endpoint,
listMissingBlobs: { nsid: 'com.atproto.repo.listMissingBlobs', method: 'GET' } as Endpoint,
listRecords: { nsid: 'com.atproto.repo.listRecords', method: 'GET' } as Endpoint,
putRecord: { nsid: 'com.atproto.repo.putRecord', method: 'POST' } as Endpoint,
uploadBlob: { nsid: 'com.atproto.repo.uploadBlob', method: 'POST' } as Endpoint,
} as const
export const comAtprotoServer = {
activateAccount: { nsid: 'com.atproto.server.activateAccount', method: 'POST' } as Endpoint,
checkAccountStatus: { nsid: 'com.atproto.server.checkAccountStatus', method: 'GET' } as Endpoint,
confirmEmail: { nsid: 'com.atproto.server.confirmEmail', method: 'POST' } as Endpoint,
createAccount: { nsid: 'com.atproto.server.createAccount', method: 'POST' } as Endpoint,
createAppPassword: { nsid: 'com.atproto.server.createAppPassword', method: 'POST' } as Endpoint,
createInviteCode: { nsid: 'com.atproto.server.createInviteCode', method: 'POST' } as Endpoint,
createInviteCodes: { nsid: 'com.atproto.server.createInviteCodes', method: 'POST' } as Endpoint,
createSession: { nsid: 'com.atproto.server.createSession', method: 'POST' } as Endpoint,
deactivateAccount: { nsid: 'com.atproto.server.deactivateAccount', method: 'POST' } as Endpoint,
deleteAccount: { nsid: 'com.atproto.server.deleteAccount', method: 'POST' } as Endpoint,
deleteSession: { nsid: 'com.atproto.server.deleteSession', method: 'POST' } as Endpoint,
describeServer: { nsid: 'com.atproto.server.describeServer', method: 'GET' } as Endpoint,
getAccountInviteCodes: { nsid: 'com.atproto.server.getAccountInviteCodes', method: 'GET' } as Endpoint,
getServiceAuth: { nsid: 'com.atproto.server.getServiceAuth', method: 'GET' } as Endpoint,
getSession: { nsid: 'com.atproto.server.getSession', method: 'GET' } as Endpoint,
listAppPasswords: { nsid: 'com.atproto.server.listAppPasswords', method: 'GET' } as Endpoint,
refreshSession: { nsid: 'com.atproto.server.refreshSession', method: 'POST' } as Endpoint,
requestAccountDelete: { nsid: 'com.atproto.server.requestAccountDelete', method: 'POST' } as Endpoint,
requestEmailConfirmation: { nsid: 'com.atproto.server.requestEmailConfirmation', method: 'POST' } as Endpoint,
requestEmailUpdate: { nsid: 'com.atproto.server.requestEmailUpdate', method: 'POST' } as Endpoint,
requestPasswordReset: { nsid: 'com.atproto.server.requestPasswordReset', method: 'POST' } as Endpoint,
reserveSigningKey: { nsid: 'com.atproto.server.reserveSigningKey', method: 'POST' } as Endpoint,
resetPassword: { nsid: 'com.atproto.server.resetPassword', method: 'POST' } as Endpoint,
revokeAppPassword: { nsid: 'com.atproto.server.revokeAppPassword', method: 'POST' } as Endpoint,
updateEmail: { nsid: 'com.atproto.server.updateEmail', method: 'POST' } as Endpoint,
} as const
export const comAtprotoSync = {
getBlob: { nsid: 'com.atproto.sync.getBlob', method: 'GET' } as Endpoint,
getBlocks: { nsid: 'com.atproto.sync.getBlocks', method: 'GET' } as Endpoint,
getCheckout: { nsid: 'com.atproto.sync.getCheckout', method: 'GET' } as Endpoint,
getHead: { nsid: 'com.atproto.sync.getHead', method: 'GET' } as Endpoint,
getHostStatus: { nsid: 'com.atproto.sync.getHostStatus', method: 'GET' } as Endpoint,
getLatestCommit: { nsid: 'com.atproto.sync.getLatestCommit', method: 'GET' } as Endpoint,
getRecord: { nsid: 'com.atproto.sync.getRecord', method: 'GET' } as Endpoint,
getRepo: { nsid: 'com.atproto.sync.getRepo', method: 'GET' } as Endpoint,
getRepoStatus: { nsid: 'com.atproto.sync.getRepoStatus', method: 'GET' } as Endpoint,
listBlobs: { nsid: 'com.atproto.sync.listBlobs', method: 'GET' } as Endpoint,
listHosts: { nsid: 'com.atproto.sync.listHosts', method: 'GET' } as Endpoint,
listRepos: { nsid: 'com.atproto.sync.listRepos', method: 'GET' } as Endpoint,
listReposByCollection: { nsid: 'com.atproto.sync.listReposByCollection', method: 'GET' } as Endpoint,
notifyOfUpdate: { nsid: 'com.atproto.sync.notifyOfUpdate', method: 'POST' } as Endpoint,
requestCrawl: { nsid: 'com.atproto.sync.requestCrawl', method: 'POST' } as Endpoint,
} as const
export const comAtprotoTemp = {
addReservedHandle: { nsid: 'com.atproto.temp.addReservedHandle', method: 'POST' } as Endpoint,
checkHandleAvailability: { nsid: 'com.atproto.temp.checkHandleAvailability', method: 'GET' } as Endpoint,
checkSignupQueue: { nsid: 'com.atproto.temp.checkSignupQueue', method: 'GET' } as Endpoint,
dereferenceScope: { nsid: 'com.atproto.temp.dereferenceScope', method: 'GET' } as Endpoint,
fetchLabels: { nsid: 'com.atproto.temp.fetchLabels', method: 'GET' } as Endpoint,
requestPhoneVerification: { nsid: 'com.atproto.temp.requestPhoneVerification', method: 'POST' } as Endpoint,
revokeAccountCredentials: { nsid: 'com.atproto.temp.revokeAccountCredentials', method: 'POST' } as Endpoint,
} as const

340
src/web/lib/api.ts Normal file
View File

@@ -0,0 +1,340 @@
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse } from '../types'
// Cache
let configCache: AppConfig | null = null
let networksCache: Networks | null = null
// Load config.json
export async function getConfig(): Promise<AppConfig> {
if (configCache) return configCache
const res = await fetch('/config.json')
configCache = await res.json()
return configCache!
}
// Load networks.json
export async function getNetworks(): Promise<Networks> {
if (networksCache) return networksCache
const res = await fetch('/networks.json')
networksCache = await res.json()
return networksCache!
}
// Resolve handle to DID (try all networks)
export async function resolveHandle(handle: string): Promise<string | null> {
const networks = await getNetworks()
// Try each network until one succeeds
for (const network of Object.values(networks)) {
try {
const host = network.bsky.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoIdentity.resolveHandle)}?handle=${handle}`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
return data.did
}
} catch {
// Try next network
}
}
return null
}
// Get PDS endpoint for DID (try all networks)
export async function getPds(did: string): Promise<string | null> {
const networks = await getNetworks()
for (const network of Object.values(networks)) {
try {
const res = await fetch(`${network.plc}/${did}`)
if (res.ok) {
const didDoc = await res.json()
const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')
if (service?.serviceEndpoint) {
return service.serviceEndpoint
}
}
} catch {
// Try next network
}
}
return null
}
// Load local profile
async function getLocalProfile(did: string): Promise<Profile | null> {
try {
const res = await fetch(`/content/${did}/app.bsky.actor.profile/self.json`)
if (res.ok) return res.json()
} catch {
// Not found
}
return null
}
// Load profile (local first for admin, remote for others)
export async function getProfile(did: string, localFirst = true): Promise<Profile | null> {
if (localFirst) {
const local = await getLocalProfile(did)
if (local) return local
}
const pds = await getPds(did)
if (!pds) return null
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=app.bsky.actor.profile&rkey=self`
const res = await fetch(url)
if (res.ok) return res.json()
} catch {
// Failed
}
return null
}
// Get avatar URL
export async function getAvatarUrl(did: string, profile: Profile): Promise<string | null> {
if (!profile.value.avatar) return null
const pds = await getPds(did)
if (!pds) return null
return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}`
}
// Load local posts
async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
try {
const indexRes = await fetch(`/content/${did}/${collection}/index.json`)
if (indexRes.ok) {
const rkeys: string[] = await indexRes.json()
const posts: Post[] = []
for (const rkey of rkeys) {
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
if (res.ok) posts.push(await res.json())
}
return posts.sort((a, b) =>
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
)
}
} catch {
// Not found
}
return []
}
// Load posts (local first for admin, remote for others)
export async function getPosts(did: string, collection: string, localFirst = true): Promise<Post[]> {
if (localFirst) {
const local = await getLocalPosts(did, collection)
if (local.length > 0) return local
}
const pds = await getPds(did)
if (!pds) return []
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
const res = await fetch(url)
if (res.ok) {
const data: ListRecordsResponse<Post> = await res.json()
return data.records.sort((a, b) =>
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
)
}
} catch {
// Failed
}
return []
}
// Get single post
export async function getPost(did: string, collection: string, rkey: string, localFirst = true): Promise<Post | null> {
if (localFirst) {
try {
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
if (res.ok) return res.json()
} catch {
// Not found
}
}
const pds = await getPds(did)
if (!pds) return null
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
const res = await fetch(url)
if (res.ok) return res.json()
} catch {
// Failed
}
return null
}
// Describe repo - get collections list
export async function describeRepo(did: string): Promise<string[]> {
// Try local first
try {
const res = await fetch(`/content/${did}/describe.json`)
if (res.ok) {
const data = await res.json()
return data.collections || []
}
} catch {
// Not found
}
// Remote
const pds = await getPds(did)
if (!pds) return []
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
return data.collections || []
}
} catch {
// Failed
}
return []
}
// List records from any collection
export async function listRecords(did: string, collection: string, limit = 50): Promise<{ uri: string; cid: string; value: unknown }[]> {
const pds = await getPds(did)
if (!pds) return []
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=${limit}`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
return data.records || []
}
} catch {
// Failed
}
return []
}
// Get single record from any collection
export async function getRecord(did: string, collection: string, rkey: string): Promise<{ uri: string; cid: string; value: unknown } | null> {
const pds = await getPds(did)
if (!pds) return null
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
const res = await fetch(url)
if (res.ok) return res.json()
} catch {
// Failed
}
return null
}
// Constants for search
const SEARCH_TIMEOUT_MS = 5000
// Get current network config
export async function getCurrentNetwork(): Promise<{ plc: string; bsky: string; web: string }> {
const config = await getConfig()
const networks = await getNetworks()
const networkKey = config.network || 'bsky.social'
const network = networks[networkKey]
return {
plc: network?.plc || 'https://plc.directory',
bsky: network?.bsky || 'https://public.api.bsky.app',
web: network?.web || 'https://bsky.app'
}
}
// Get search endpoint for current network
async function getSearchEndpoint(): Promise<string> {
const network = await getCurrentNetwork()
return network.bsky
}
// Search posts that link to a URL
export async function searchPostsForUrl(url: string): Promise<SearchPost[]> {
// Use current network's endpoint for search
const endpoint = await getSearchEndpoint()
// Extract search-friendly patterns from URL
// Note: Search API doesn't index paths well, so search by domain and filter client-side
const searchQueries: string[] = []
try {
const urlObj = new URL(url)
// Search by domain only (paths with / don't return results)
searchQueries.push(urlObj.host)
} catch {
searchQueries.push(url)
}
const allPosts: SearchPost[] = []
const seenUris = new Set<string>()
for (const query of searchQueries) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
const res = await fetch(
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
{ signal: controller.signal }
)
clearTimeout(timeoutId)
if (!res.ok) continue
const data = await res.json()
const posts = (data.posts || []).filter((post: SearchPost) => {
const embedUri = (post.record as { embed?: { external?: { uri?: string } } })?.embed?.external?.uri
const text = (post.record as { text?: string })?.text || ''
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
})
for (const post of posts) {
if (!seenUris.has(post.uri)) {
seenUris.add(post.uri)
allPosts.push(post)
}
}
} catch {
// Timeout or network error
}
}
// Sort by date (newest first)
allPosts.sort((a, b) => {
const aDate = (a.record as { createdAt?: string })?.createdAt || ''
const bDate = (b.record as { createdAt?: string })?.createdAt || ''
return new Date(bDate).getTime() - new Date(aDate).getTime()
})
return allPosts
}
// Search post type
export interface SearchPost {
uri: string
cid: string
author: {
did: string
handle: string
displayName?: string
avatar?: string
}
record: unknown
}

293
src/web/lib/auth.ts Normal file
View File

@@ -0,0 +1,293 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
import { getNetworks } from './api'
let oauthClient: BrowserOAuthClient | null = null
let agent: Agent | null = null
let sessionDid: string | null = null
let sessionHandle: string | null = null
let currentNetworkConfig: { bsky: string; plc: string } | null = null
// Get client ID based on environment
function getClientId(): string {
const host = window.location.host
if (host.includes('localhost') || host.includes('127.0.0.1')) {
const port = window.location.port || '5173'
const redirectUri = `http://127.0.0.1:${port}/`
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
}
return `${window.location.origin}/client-metadata.json`
}
// Set network config (call before login)
export async function setNetworkConfig(handle: string): Promise<void> {
const networks = await getNetworks()
for (const [domain, network] of Object.entries(networks)) {
if (handle.endsWith(`.${domain}`)) {
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
oauthClient = null
return
}
}
// Check syui.ai -> syu.is
if (handle.endsWith('.syui.ai')) {
const network = networks['syu.is']
if (network) {
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
oauthClient = null
return
}
}
// Default to first network
const first = Object.values(networks)[0]
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
oauthClient = null
}
// Initialize OAuth client
async function initOAuthClient(): Promise<BrowserOAuthClient> {
if (oauthClient) return oauthClient
const handleResolver = currentNetworkConfig?.bsky || 'https://bsky.social'
const plcDirectoryUrl = currentNetworkConfig?.plc || 'https://plc.directory'
oauthClient = await BrowserOAuthClient.load({
clientId: getClientId(),
handleResolver,
plcDirectoryUrl,
})
return oauthClient
}
// Login with handle
export async function login(handle: string): Promise<void> {
await setNetworkConfig(handle)
try {
const client = await initOAuthClient()
await client.signIn(handle, {
scope: 'atproto transition:generic'
})
} catch (e) {
console.error('Login failed:', e)
throw e
}
}
// Handle OAuth callback
export async function handleCallback(): Promise<string | null> {
// Check query params first, then hash fragment
let params = new URLSearchParams(window.location.search)
if (!params.has('code') && !params.has('state')) {
// Try hash fragment
if (window.location.hash && window.location.hash.length > 1) {
params = new URLSearchParams(window.location.hash.slice(1))
}
}
if (!params.has('code') && !params.has('state')) {
return null
}
try {
// Detect network from issuer (iss param) and set config before init
const iss = params.get('iss') || ''
if (iss && !currentNetworkConfig) {
const networks = await getNetworks()
for (const [domain, network] of Object.entries(networks)) {
if (iss.includes(domain)) {
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
break
}
}
}
const client = await initOAuthClient()
// Initialize client to restore state from storage
await client.init()
const result = await client.callback(params)
sessionDid = result.session.did
// Create agent and get handle
agent = new Agent(result.session)
try {
const profile = await agent.getProfile({ actor: sessionDid })
sessionHandle = profile.data.handle
} catch {
// Could not get handle
}
// Clear URL params and hash
window.history.replaceState({}, '', window.location.pathname)
return sessionDid
} catch (e) {
console.error('OAuth callback error:', e)
return null
}
}
// Logout
export async function logout(): Promise<void> {
// Clear module state
sessionDid = null
sessionHandle = null
agent = null
oauthClient = null
currentNetworkConfig = null
// Clear all storage
sessionStorage.clear()
localStorage.clear()
// Clear IndexedDB (used by OAuth client)
try {
const databases = await indexedDB.databases()
for (const db of databases) {
if (db.name) {
indexedDB.deleteDatabase(db.name)
}
}
} catch (e) {
// IndexedDB.databases() not supported in some browsers
console.warn('Could not clear IndexedDB:', e)
}
window.location.reload()
}
// Restore session from storage
export async function restoreSession(): Promise<string | null> {
try {
// Try to initialize with default network first
const networks = await getNetworks()
const first = Object.values(networks)[0]
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
const client = await initOAuthClient()
const result = await client.init()
if (result?.session) {
sessionDid = result.session.did
// Create agent and get handle
agent = new Agent(result.session)
try {
const profile = await agent.getProfile({ actor: sessionDid })
sessionHandle = profile.data.handle
} catch {
// Could not get handle
}
return sessionDid
}
} catch (e) {
// Silently fail - no session to restore
}
return null
}
// Check if logged in
export function isLoggedIn(): boolean {
return sessionDid !== null
}
// Get logged in DID
export function getLoggedInDid(): string | null {
return sessionDid
}
// Get logged in handle
export function getLoggedInHandle(): string | null {
return sessionHandle
}
// Get agent
export function getAgent(): Agent | null {
return agent
}
// Create post
export async function createPost(
collection: string,
title: string,
content: string
): Promise<{ uri: string; cid: string } | null> {
if (!agent) return null
try {
const result = await agent.com.atproto.repo.createRecord({
repo: agent.assertDid,
collection,
record: {
$type: collection,
title,
content,
createdAt: new Date().toISOString(),
},
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Create post error:', err)
throw err
}
}
// Update post
export async function updatePost(
collection: string,
rkey: string,
title: string,
content: string
): Promise<{ uri: string; cid: string } | null> {
if (!agent) return null
try {
const result = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection,
rkey,
record: {
$type: collection,
title,
content,
createdAt: new Date().toISOString(),
},
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Update post error:', err)
throw err
}
}
// Delete record
export async function deleteRecord(
collection: string,
rkey: string
): Promise<boolean> {
if (!agent) return false
try {
await agent.com.atproto.repo.deleteRecord({
repo: agent.assertDid,
collection,
rkey,
})
return true
} catch (err) {
console.error('Delete record error:', err)
throw err
}
}

37
src/web/lib/markdown.ts Normal file
View File

@@ -0,0 +1,37 @@
import { marked } from 'marked'
import hljs from 'highlight.js'
// Configure marked
marked.setOptions({
breaks: true,
gfm: true,
})
// Custom renderer for syntax highlighting
const renderer = new marked.Renderer()
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
if (lang && hljs.getLanguage(lang)) {
const highlighted = hljs.highlight(text, { language: lang }).value
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`
}
const escaped = escapeHtml(text)
return `<pre><code>${escaped}</code></pre>`
}
marked.use({ renderer })
// Escape HTML
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// Render markdown to HTML
export function renderMarkdown(content: string): string {
return marked(content) as string
}

103
src/web/lib/router.ts Normal file
View File

@@ -0,0 +1,103 @@
export interface Route {
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
handle?: string
rkey?: string
service?: string
collection?: string
}
// Parse current URL to route
export function parseRoute(): Route {
const path = window.location.pathname
// Home: / or /app
if (path === '/' || path === '' || path === '/app' || path === '/app/') {
return { type: 'home' }
}
// AT-Browser main: /@handle/at or /@handle/at/
const atBrowserMatch = path.match(/^\/@([^/]+)\/at\/?$/)
if (atBrowserMatch) {
return { type: 'atbrowser', handle: atBrowserMatch[1] }
}
// AT-Browser service: /@handle/at/service/domain.tld
const serviceMatch = path.match(/^\/@([^/]+)\/at\/service\/([^/]+)$/)
if (serviceMatch) {
return { type: 'service', handle: serviceMatch[1], service: decodeURIComponent(serviceMatch[2]) }
}
// AT-Browser collection: /@handle/at/collection/namespace.name
const collectionMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)$/)
if (collectionMatch) {
return { type: 'collection', handle: collectionMatch[1], collection: collectionMatch[2] }
}
// AT-Browser record: /@handle/at/collection/namespace.name/rkey
const recordMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)\/([^/]+)$/)
if (recordMatch) {
return { type: 'record', handle: recordMatch[1], collection: recordMatch[2], rkey: recordMatch[3] }
}
// User page: /@handle or /@handle/
const userMatch = path.match(/^\/@([^/]+)\/?$/)
if (userMatch) {
return { type: 'user', handle: userMatch[1] }
}
// Post form page: /@handle/at/post
const postPageMatch = path.match(/^\/@([^/]+)\/at\/post\/?$/)
if (postPageMatch) {
return { type: 'postpage', handle: postPageMatch[1] }
}
// Post detail page: /@handle/rkey (for config.collection)
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
if (postMatch) {
return { type: 'post', handle: postMatch[1], rkey: postMatch[2] }
}
// Default to home
return { type: 'home' }
}
// Navigate to a route
export function navigate(route: Route): void {
let path = '/'
if (route.type === 'user' && route.handle) {
path = `/@${route.handle}`
} else if (route.type === 'postpage' && route.handle) {
path = `/@${route.handle}/at/post`
} else if (route.type === 'post' && route.handle && route.rkey) {
path = `/@${route.handle}/${route.rkey}`
} else if (route.type === 'atbrowser' && route.handle) {
path = `/@${route.handle}/at`
} else if (route.type === 'service' && route.handle && route.service) {
path = `/@${route.handle}/at/service/${encodeURIComponent(route.service)}`
} else if (route.type === 'collection' && route.handle && route.collection) {
path = `/@${route.handle}/at/collection/${route.collection}`
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
}
window.history.pushState({}, '', path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
// Subscribe to route changes
export function onRouteChange(callback: (route: Route) => void): void {
const handler = () => callback(parseRoute())
window.addEventListener('popstate', handler)
// Handle link clicks
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
const anchor = target.closest('a')
if (anchor && anchor.href.startsWith(window.location.origin)) {
e.preventDefault()
window.history.pushState({}, '', anchor.href)
handler()
}
})
}

397
src/web/main.ts Normal file
View File

@@ -0,0 +1,397 @@
import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
import { renderHeader } from './components/header'
import { renderProfile } from './components/profile'
import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
import { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer'
import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')!
let currentHandle = ''
// Filter collections by service domain
function filterCollectionsByService(collections: string[], service: string): string[] {
return collections.filter(col => {
const parts = col.split('.')
if (parts.length >= 2) {
const colService = `${parts[1]}.${parts[0]}`
return colService === service
}
return false
})
}
// Get web URL for handle from networks
async function getWebUrl(handle: string): Promise<string | undefined> {
const networks = await getNetworks()
// Check each network for matching handle domain
for (const [domain, network] of Object.entries(networks)) {
// Direct domain match (e.g., handle.syu.is -> syu.is)
if (handle.endsWith(`.${domain}`)) {
return network.web
}
// Check if handle domain matches network's web domain (e.g., syui.syui.ai -> syu.is via web: syu.is)
const webDomain = network.web?.replace(/^https?:\/\//, '')
if (webDomain && handle.endsWith(`.${webDomain}`)) {
return network.web
}
}
// Check for syui.ai handles -> syu.is network
if (handle.endsWith('.syui.ai')) {
return networks['syu.is']?.web
}
// Default to first network's web
const firstNetwork = Object.values(networks)[0]
return firstNetwork?.web
}
async function render(route: Route): Promise<void> {
showLoading(app)
try {
const config = await getConfig()
// Apply theme color from config
if (config.color) {
document.documentElement.style.setProperty('--btn-color', config.color)
}
// Handle OAuth callback if present (check both ? and #)
const searchParams = new URLSearchParams(window.location.search)
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
if (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state')) {
await handleCallback()
}
// Restore session from storage
await restoreSession()
// Redirect logged-in user from root to their user page
if (route.type === 'home' && isLoggedIn()) {
const loggedInHandle = getLoggedInHandle()
if (loggedInHandle) {
navigate({ type: 'user', handle: loggedInHandle })
return
}
}
// Determine handle and whether to use local data
let handle: string
let localFirst: boolean
if (route.type === 'home') {
handle = config.handle
localFirst = true
} else if (route.handle) {
handle = route.handle
localFirst = handle === config.handle
} else {
handle = config.handle
localFirst = true
}
currentHandle = handle
// Resolve handle to DID
const did = await resolveHandle(handle)
if (!did) {
app.innerHTML = `
${renderHeader(handle)}
<div class="error">Could not resolve handle: ${handle}</div>
${renderFooter(handle)}
`
setupEventHandlers()
return
}
// Load profile
const profile = await getProfile(did, localFirst)
const webUrl = await getWebUrl(handle)
// Load posts to check for translations
const posts = await getPosts(did, config.collection, localFirst)
// Collect available languages from posts
const availableLangs = new Set<string>()
for (const post of posts) {
// Add original language (default: ja for Japanese posts)
const postLang = post.value.lang || 'ja'
availableLangs.add(postLang)
// Add translation languages
if (post.value.translations) {
for (const lang of Object.keys(post.value.translations)) {
availableLangs.add(lang)
}
}
}
const langList = Array.from(availableLangs)
// Build page
let html = renderHeader(handle)
// Mode tabs (Blog/Browser/Post/PDS)
const activeTab = route.type === 'postpage' ? 'post' :
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab)
// Profile section
if (profile) {
html += await renderProfile(did, profile, handle, webUrl)
}
// Check if logged-in user owns this content
const loggedInDid = getLoggedInDid()
const isOwner = isLoggedIn() && loggedInDid === did
// Content section based on route type
if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view
const record = await getRecord(did, route.collection, route.rkey)
if (record) {
html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>`
} else {
html += `<div id="content" class="error">Record not found</div>`
}
html += `<nav class="back-nav"><a href="/@${handle}/at/collection/${route.collection}">${route.collection}</a></nav>`
} else if (route.type === 'collection' && route.collection) {
// AT-Browser: Collection records list
const records = await listRecords(did, route.collection)
html += `<div id="content">${renderRecordList(records, handle, route.collection)}</div>`
const parts = route.collection.split('.')
const service = parts.length >= 2 ? `${parts[1]}.${parts[0]}` : ''
html += `<nav class="back-nav"><a href="/@${handle}/at/service/${encodeURIComponent(service)}">${service}</a></nav>`
} else if (route.type === 'service' && route.service) {
// AT-Browser: Service collections list
const collections = await describeRepo(did)
const filtered = filterCollectionsByService(collections, route.service)
html += `<div id="content">${renderCollectionList(filtered, handle, route.service)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at">at</a></nav>`
} else if (route.type === 'atbrowser') {
// AT-Browser: Main view with server info + service list
const pds = await getPds(did)
const collections = await describeRepo(did)
html += `<div id="browser">`
html += renderServerInfo(did, pds)
html += renderServiceList(collections, handle)
html += `</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'post' && route.rkey) {
// Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst)
html += renderLangSelector(langList)
if (post) {
html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>`
} else {
html += `<div id="content" class="error">Post not found</div>`
}
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'postpage') {
// Post form page
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else {
// User page: compact collection buttons + posts
const collections = await describeRepo(did)
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
// Language selector above content
html += renderLangSelector(langList)
// Use pre-loaded posts
html += `<div id="content">${renderPostList(posts, handle)}</div>`
}
html += renderFooter(handle)
app.innerHTML = html
hideLoading(app)
setupEventHandlers()
// Setup mode tabs (PDS selector + Lang selector)
await setupModeTabs(
(_network) => {
// Refresh when network is changed
render(parseRoute())
},
langList,
(_lang) => {
// Refresh when language is changed
render(parseRoute())
}
)
// Setup post form on postpage
if (route.type === 'postpage' && isLoggedIn()) {
setupPostForm(config.collection, () => {
// Navigate to user page on success
navigate({ type: 'user', handle })
})
}
// Setup record delete button
if (isOwner) {
setupRecordDelete(handle, route)
setupPostEdit(config.collection)
}
// Setup post detail (translation toggle, discussion)
if (route.type === 'post') {
const contentEl = document.getElementById('content')
if (contentEl) {
setupPostDetail(contentEl)
}
}
} catch (error) {
console.error('Render error:', error)
app.innerHTML = `
${renderHeader(currentHandle)}
<div class="error">Error: ${error}</div>
${renderFooter(currentHandle)}
`
hideLoading(app)
setupEventHandlers()
}
}
function setupEventHandlers(): void {
// Header form
const form = document.getElementById('header-form') as HTMLFormElement
const input = document.getElementById('header-input') as HTMLInputElement
form?.addEventListener('submit', (e) => {
e.preventDefault()
const handle = input.value.trim()
if (handle) {
navigate({ type: 'user', handle })
}
})
// Login button
const loginBtn = document.getElementById('login-btn')
loginBtn?.addEventListener('click', async () => {
const handle = input.value.trim() || currentHandle
if (handle) {
try {
await login(handle)
} catch (e) {
console.error('Login failed:', e)
alert('Login failed. Please check your handle.')
}
} else {
alert('Please enter a handle first.')
}
})
// Logout button
const logoutBtn = document.getElementById('logout-btn')
logoutBtn?.addEventListener('click', async () => {
await logout()
})
}
// Setup record delete button
function setupRecordDelete(handle: string, _route: Route): void {
const deleteBtn = document.getElementById('record-delete-btn')
if (!deleteBtn) return
deleteBtn.addEventListener('click', async () => {
const collection = deleteBtn.getAttribute('data-collection')
const rkey = deleteBtn.getAttribute('data-rkey')
if (!collection || !rkey) return
if (!confirm('Are you sure you want to delete this record?')) return
try {
deleteBtn.textContent = 'Deleting...'
;(deleteBtn as HTMLButtonElement).disabled = true
await deleteRecord(collection, rkey)
// Navigate back to collection list
navigate({ type: 'collection', handle, collection })
} catch (err) {
console.error('Delete failed:', err)
alert('Delete failed: ' + err)
deleteBtn.textContent = 'Delete'
;(deleteBtn as HTMLButtonElement).disabled = false
}
})
}
// Setup post edit form
function setupPostEdit(collection: string): void {
const editBtn = document.getElementById('post-edit-btn')
const editForm = document.getElementById('post-edit-form')
const postDisplay = document.getElementById('post-display')
const cancelBtn = document.getElementById('post-edit-cancel')
const saveBtn = document.getElementById('post-edit-save')
const titleInput = document.getElementById('post-edit-title') as HTMLInputElement
const contentInput = document.getElementById('post-edit-content') as HTMLTextAreaElement
if (!editBtn || !editForm) return
// Show edit form
editBtn.addEventListener('click', () => {
if (postDisplay) postDisplay.style.display = 'none'
editForm.style.display = 'block'
editBtn.style.display = 'none'
})
// Cancel edit
cancelBtn?.addEventListener('click', () => {
editForm.style.display = 'none'
if (postDisplay) postDisplay.style.display = ''
editBtn.style.display = ''
})
// Save edit
saveBtn?.addEventListener('click', async () => {
const rkey = saveBtn.getAttribute('data-rkey')
if (!rkey || !titleInput || !contentInput) return
const title = titleInput.value.trim()
const content = contentInput.value.trim()
if (!title || !content) {
alert('Title and content are required')
return
}
try {
saveBtn.textContent = 'Saving...'
;(saveBtn as HTMLButtonElement).disabled = true
await updatePost(collection, rkey, title, content)
// Refresh the page
render(parseRoute())
} catch (err) {
console.error('Update failed:', err)
alert('Update failed: ' + err)
saveBtn.textContent = 'Save'
;(saveBtn as HTMLButtonElement).disabled = false
}
})
}
// Initial render
render(parseRoute())
// Handle route changes
onRouteChange(render)

2190
src/web/styles/main.css Normal file

File diff suppressed because it is too large Load Diff

64
src/web/types.ts Normal file
View File

@@ -0,0 +1,64 @@
// Config types
export interface AppConfig {
title: string
handle: string
collection: string
network: string
color: string
siteUrl: string
}
export interface Networks {
[domain: string]: {
plc: string
bsky: string
web: string
}
}
// ATProto types
export interface DescribeRepo {
did: string
handle: string
collections: string[]
}
export interface Profile {
cid: string
uri: string
value: {
$type: string
avatar?: {
$type: string
mimeType: string
ref: { $link: string }
size: number
}
displayName?: string
description?: string
createdAt?: string
}
}
export interface Post {
cid: string
uri: string
value: {
$type: string
title: string
content: string
createdAt: string
lang?: string
translations?: {
[lang: string]: {
title: string
content: string
}
}
}
}
export interface ListRecordsResponse<T> {
records: T[]
cursor?: string
}