This commit is contained in:
2026-01-18 18:08:53 +09:00
commit 5fe9e0a3f9
56 changed files with 6679 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;')
}