This commit is contained in:
2026-01-15 18:36:41 +09:00
commit 162072d980
24 changed files with 2153 additions and 0 deletions

227
src/components/atbrowser.ts Normal file
View File

@@ -0,0 +1,227 @@
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo } from '../lib/api.js'
import { deleteRecord } from '../lib/auth.js'
function extractRkey(uri: string): string {
const parts = uri.split('/')
return parts[parts.length - 1]
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
async function renderServices(did: string, handle: string): Promise<string> {
const collections = await describeRepo(did)
if (collections.length === 0) {
return '<p class="no-data">No collections found</p>'
}
// Group by service domain
const serviceMap = new Map<string, { name: string; favicon: string; count: number }>()
for (const col of collections) {
const info = getServiceInfo(col)
if (info) {
const key = info.domain
if (!serviceMap.has(key)) {
serviceMap.set(key, { name: info.name, favicon: info.favicon, count: 0 })
}
serviceMap.get(key)!.count++
}
}
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
return `
<li class="service-list-item">
<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}" class="service-list-link">
<img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.style.display='none'">
<span class="service-list-name">${info.name}</span>
<span class="service-list-count">${info.count}</span>
</a>
</li>
`
}).join('')
return `
<div class="services-list">
<h3>Services</h3>
<ul class="service-list">${items}</ul>
</div>
`
}
async function renderCollections(did: string, handle: string, serviceDomain: string): Promise<string> {
const collections = await describeRepo(did)
// Filter by service domain
const filtered = collections.filter(col => {
const info = getServiceInfo(col)
return info && info.domain === serviceDomain
})
if (filtered.length === 0) {
return '<p class="no-data">No collections found</p>'
}
// Get favicon from first collection
const firstInfo = getServiceInfo(filtered[0])
const favicon = firstInfo ? `<img src="${firstInfo.favicon}" class="collection-header-favicon" alt="" onerror="this.style.display='none'">` : ''
const items = filtered.map(col => {
return `
<li class="collection-item">
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link">
<span class="collection-nsid">${col}</span>
</a>
</li>
`
}).join('')
return `
<div class="collections">
<h3 class="collection-header">${favicon}<span>${serviceDomain}</span></h3>
<ul class="collection-list">${items}</ul>
</div>
`
}
async function renderRecordList(did: string, handle: string, collection: string): Promise<string> {
const records = await listRecordsRaw(did, collection)
if (records.length === 0) {
return '<p class="no-data">No records found</p>'
}
const items = records.map(rec => {
const rkey = extractRkey(rec.uri)
const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey
return `
<li class="record-item">
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}&rkey=${rkey}" class="record-link">
<span class="record-rkey">${rkey}</span>
<span class="record-preview">${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>
`
}
async function renderRecordDetail(did: string, handle: string, collection: string, rkey: string, canDelete: boolean): Promise<string> {
const record = await getRecordRaw(did, collection, rkey)
if (!record) {
return '<p class="error">Record not found</p>'
}
const lexicon = await fetchLexicon(collection)
const schemaStatus = lexicon ? 'verified' : 'none'
const schemaLabel = lexicon ? '✓ Schema' : '○ No schema'
const json = JSON.stringify(record, null, 2)
const deleteBtn = canDelete
? `<button class="delete-btn" data-collection="${collection}" data-rkey="${rkey}">Delete</button>`
: ''
return `
<div class="record-detail">
<div class="record-header">
<h3>${collection}</h3>
<p class="record-uri">${record.uri}</p>
<p class="record-cid">CID: ${record.cid}</p>
<span class="schema-status schema-${schemaStatus}">${schemaLabel}</span>
${deleteBtn}
</div>
<div class="json-view">
<pre><code>${escapeHtml(json)}</code></pre>
</div>
</div>
`
}
export async function mountAtBrowser(
container: HTMLElement,
handle: string,
collection: string | null,
rkey: string | null,
service: string | null = null,
loginDid: string | null = null
): Promise<void> {
container.innerHTML = '<p class="loading">Loading...</p>'
try {
const did = handle.startsWith('did:') ? handle : await resolveHandle(handle)
const canDelete = loginDid !== null && loginDid === did
let content: string
let nav = ''
if (collection && rkey) {
nav = `<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}" class="back-link">← Back</a>`
content = await renderRecordDetail(did, handle, collection, rkey, canDelete)
} else if (collection) {
// Get service from collection for back link
const info = getServiceInfo(collection)
const backService = info ? info.domain : ''
nav = `<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(backService)}" class="back-link">← ${info?.name || 'Back'}</a>`
content = await renderRecordList(did, handle, collection)
} else if (service) {
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Services</a>`
content = await renderCollections(did, handle, service)
} else {
content = await renderServices(did, handle)
}
container.innerHTML = nav + content
// Add delete button handler
const deleteBtn = container.querySelector('.delete-btn')
if (deleteBtn) {
deleteBtn.addEventListener('click', async (e) => {
e.preventDefault()
const btn = e.target as HTMLButtonElement
const col = btn.dataset.collection
const rk = btn.dataset.rkey
if (!col || !rk) return
if (!confirm('Delete this record?')) return
try {
btn.disabled = true
btn.textContent = 'Deleting...'
await deleteRecord(col, rk)
// Go back to collection
window.location.href = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}`
} catch (err) {
alert('Delete failed: ' + err)
btn.disabled = false
btn.textContent = 'Delete'
}
})
}
} catch (err) {
container.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
}

89
src/components/browser.ts Normal file
View File

@@ -0,0 +1,89 @@
export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHandle?: string): string {
const loginBtn = isLoggedIn
? `<button type="button" class="header-btn user-btn" id="user-btn" title="${userHandle}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/>
</svg>
</button>`
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
</button>`
return `
<div class="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>
</div>
`
}
export interface HeaderCallbacks {
onBrowse: (handle: string) => void
onLogin: () => void
onLogout: () => void
}
export function mountHeader(
container: HTMLElement,
currentHandle: string,
isLoggedIn: boolean,
userHandle: string | undefined,
callbacks: HeaderCallbacks
): void {
container.innerHTML = renderHeader(currentHandle, isLoggedIn, userHandle)
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) {
callbacks.onBrowse(handle)
}
})
if (isLoggedIn) {
const userBtn = document.getElementById('user-btn')
userBtn?.addEventListener('click', async (e) => {
e.preventDefault()
e.stopPropagation()
if (confirm('Logout?')) {
await callbacks.onLogout()
}
})
} else {
const loginBtn = document.getElementById('login-btn')
loginBtn?.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
callbacks.onLogin()
})
}
}
// Keep old function for compatibility
export function mountBrowser(
container: HTMLElement,
currentHandle: string,
onSubmit: (handle: string) => void
): void {
mountHeader(container, currentHandle, false, undefined, {
onBrowse: onSubmit,
onLogin: () => {},
onLogout: () => {}
})
}

View File

@@ -0,0 +1,74 @@
import { createPost } from '../lib/auth.js'
export function renderPostForm(collection: string): string {
return `
<div class="post-form-container">
<h3>New Post</h3>
<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"
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 mountPostForm(
container: HTMLElement,
collection: string,
onSuccess: () => void
): void {
container.innerHTML = renderPostForm(collection)
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
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 successfully!</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'
}
})
}

115
src/components/posts.ts Normal file
View File

@@ -0,0 +1,115 @@
import type { BlogPost } from '../types.js'
import { putRecord } from '../lib/auth.js'
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
if (posts.length === 0) {
container.innerHTML = '<p class="no-posts">No posts yet</p>'
return
}
const html = posts.map(post => {
const rkey = post.uri.split('/').pop()
return `
<li class="post-item">
<a href="?rkey=${rkey}" class="post-link">
<span class="post-title">${escapeHtml(post.title)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span>
</a>
</li>
`
}).join('')
container.innerHTML = `<ul class="post-list">${html}</ul>`
}
export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false): void {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}&rkey=${rkey}`
const editBtn = canEdit ? `<button class="edit-btn" id="edit-btn">edit</button>` : ''
container.innerHTML = `
<article class="post-detail">
<header class="post-header">
<h1 class="post-title" id="post-title">${escapeHtml(post.title)}</h1>
<div class="post-meta">
<time class="post-date">${formatDate(post.createdAt)}</time>
<a href="${jsonUrl}" class="json-btn">json</a>
${editBtn}
</div>
</header>
<div class="post-content" id="post-content">${escapeHtml(post.content)}</div>
</article>
<div class="edit-form-container" id="edit-form-container" style="display: none;">
<h3>Edit Post</h3>
<form class="edit-form" id="edit-form">
<input type="text" id="edit-title" class="edit-form-title" value="${escapeHtml(post.title)}" placeholder="Title" required>
<textarea id="edit-content" class="edit-form-body" placeholder="Content" required>${escapeHtml(post.content)}</textarea>
<div class="edit-form-footer">
<button type="button" id="edit-cancel" class="edit-cancel-btn">Cancel</button>
<button type="submit" id="edit-submit" class="edit-submit-btn">Save</button>
</div>
</form>
</div>
`
if (canEdit) {
const editBtnEl = document.getElementById('edit-btn')
const editFormContainer = document.getElementById('edit-form-container')
const editForm = document.getElementById('edit-form') as HTMLFormElement
const editCancel = document.getElementById('edit-cancel')
const postArticle = container.querySelector('.post-detail') as HTMLElement
editBtnEl?.addEventListener('click', () => {
postArticle.style.display = 'none'
editFormContainer!.style.display = 'block'
})
editCancel?.addEventListener('click', () => {
postArticle.style.display = 'block'
editFormContainer!.style.display = 'none'
})
editForm?.addEventListener('submit', async (e) => {
e.preventDefault()
const title = (document.getElementById('edit-title') as HTMLInputElement).value
const content = (document.getElementById('edit-content') as HTMLTextAreaElement).value
const submitBtn = document.getElementById('edit-submit') as HTMLButtonElement
try {
submitBtn.disabled = true
submitBtn.textContent = 'Saving...'
await putRecord(collection, rkey, {
title,
content,
createdAt: post.createdAt,
})
window.location.reload()
} catch (err) {
alert('Save failed: ' + err)
submitBtn.disabled = false
submitBtn.textContent = 'Save'
}
})
}
}

18
src/components/profile.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Profile } from '../types.js'
export function renderProfile(profile: Profile): string {
return `
<div class="profile">
${profile.avatar ? `<img src="${profile.avatar}" alt="avatar" class="profile-avatar">` : ''}
<div class="profile-info">
<h1 class="profile-name">${profile.displayName || profile.handle}</h1>
<p class="profile-handle">@${profile.handle}</p>
${profile.description ? `<p class="profile-desc">${profile.description}</p>` : ''}
</div>
</div>
`
}
export function mountProfile(container: HTMLElement, profile: Profile): void {
container.innerHTML = renderProfile(profile)
}

View File

@@ -0,0 +1,42 @@
import { describeRepo, getServiceInfo, resolveHandle } from '../lib/api.js'
export async function renderServices(handle: string): Promise<string> {
const did = handle.startsWith('did:') ? handle : await resolveHandle(handle)
const collections = await describeRepo(did)
if (collections.length === 0) {
return ''
}
// Group by service
const serviceMap = new Map<string, { name: string; favicon: string; collections: string[] }>()
for (const col of collections) {
const info = getServiceInfo(col)
if (info) {
const key = info.domain
if (!serviceMap.has(key)) {
serviceMap.set(key, { name: info.name, favicon: info.favicon, collections: [] })
}
serviceMap.get(key)!.collections.push(col)
}
}
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
const url = `?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}`
return `
<a href="${url}" class="service-item" title="${info.collections.join(', ')}">
<img src="${info.favicon}" class="service-favicon" alt="" onerror="this.style.display='none'">
<span class="service-name">${info.name}</span>
</a>
`
}).join('')
return `<div class="services">${items}</div>`
}
export async function mountServices(container: HTMLElement, handle: string): Promise<void> {
const html = await renderServices(handle)
container.innerHTML = html
}

217
src/lib/api.ts Normal file
View File

@@ -0,0 +1,217 @@
import { AtpAgent } from '@atproto/api'
import type { Profile, BlogPost, NetworkConfig } from '../types.js'
const agents: Map<string, AtpAgent> = new Map()
let networkConfig: NetworkConfig | null = null
export function setNetworkConfig(config: NetworkConfig): void {
networkConfig = config
}
function getPlc(): string {
return networkConfig?.plc || 'https://plc.directory'
}
function getBsky(): string {
return networkConfig?.bsky || 'https://public.api.bsky.app'
}
export function getAgent(service: string): AtpAgent {
if (!agents.has(service)) {
agents.set(service, new AtpAgent({ service }))
}
return agents.get(service)!
}
export async function resolvePds(did: string): Promise<string> {
const res = await fetch(`${getPlc()}/${did}`)
const doc = await res.json()
const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
return service?.serviceEndpoint || getBsky()
}
export async function resolveHandle(handle: string): Promise<string> {
const agent = getAgent(getBsky())
const res = await agent.resolveHandle({ handle })
return res.data.did
}
export async function getProfile(actor: string): Promise<Profile> {
const agent = getAgent(getBsky())
const res = await agent.getProfile({ actor })
return {
did: res.data.did,
handle: res.data.handle,
displayName: res.data.displayName,
description: res.data.description,
avatar: res.data.avatar,
banner: res.data.banner,
}
}
export async function listRecords(
did: string,
collection: string,
limit = 50
): Promise<BlogPost[]> {
const pds = await resolvePds(did)
const agent = getAgent(pds)
const res = await agent.com.atproto.repo.listRecords({
repo: did,
collection,
limit,
})
return res.data.records.map((record: any) => ({
uri: record.uri,
cid: record.cid,
title: record.value.title || '',
content: record.value.content || '',
createdAt: record.value.createdAt || '',
}))
}
export async function getRecord(
did: string,
collection: string,
rkey: string
): Promise<BlogPost | null> {
const pds = await resolvePds(did)
const agent = getAgent(pds)
try {
const res = await agent.com.atproto.repo.getRecord({
repo: did,
collection,
rkey,
})
return {
uri: res.data.uri,
cid: res.data.cid || '',
title: (res.data.value as any).title || '',
content: (res.data.value as any).content || '',
createdAt: (res.data.value as any).createdAt || '',
}
} catch {
return null
}
}
export async function describeRepo(did: string): Promise<string[]> {
const pds = await resolvePds(did)
const agent = getAgent(pds)
const res = await agent.com.atproto.repo.describeRepo({ repo: did })
return res.data.collections || []
}
export async function listRecordsRaw(
did: string,
collection: string,
limit = 100
): Promise<any[]> {
const pds = await resolvePds(did)
const agent = getAgent(pds)
const res = await agent.com.atproto.repo.listRecords({
repo: did,
collection,
limit,
})
return res.data.records
}
export async function getRecordRaw(
did: string,
collection: string,
rkey: string
): Promise<any | null> {
const pds = await resolvePds(did)
const agent = getAgent(pds)
try {
const res = await agent.com.atproto.repo.getRecord({
repo: did,
collection,
rkey,
})
return res.data
} catch {
return null
}
}
// Known lexicon prefixes that have schemas
const KNOWN_LEXICON_PREFIXES = [
'app.bsky.',
'chat.bsky.',
'com.atproto.',
'sh.tangled.',
'pub.leaflet.',
'blue.linkat.',
'fyi.unravel.frontpage.',
'com.whtwnd.',
'com.shinolabs.pinksea.',
]
export function hasKnownSchema(nsid: string): boolean {
return KNOWN_LEXICON_PREFIXES.some(prefix => nsid.startsWith(prefix))
}
export async function fetchLexicon(nsid: string): Promise<any | null> {
// Check if it's a known lexicon first
if (hasKnownSchema(nsid)) {
return { id: nsid, known: true }
}
// Extract authority from NSID (e.g., "ai.syui.log.post" -> "syui.ai")
const parts = nsid.split('.')
if (parts.length < 3) return null
const authority = parts.slice(0, 2).reverse().join('.')
const url = `https://${authority}/.well-known/lexicon/${nsid}.json`
try {
const res = await fetch(url)
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
// Known service mappings for collections
const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
'app.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
'chat.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
'ai.syui': { domain: 'syui.ai' },
'com.whtwnd': { domain: 'whtwnd.com' },
'fyi.unravel.frontpage': { domain: 'frontpage.fyi' },
'com.shinolabs.pinksea': { domain: 'pinksea.art' },
'blue.linkat': { domain: 'linkat.blue' },
'sh.tangled': { domain: 'tangled.sh' },
'pub.leaflet': { domain: 'leaflet.pub' },
}
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
// Try to find matching service prefix
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
if (collection.startsWith(prefix)) {
return {
name: info.domain,
domain: info.domain,
favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
}
}
}
// Fallback: extract domain from first 2 parts of NSID
const parts = collection.split('.')
if (parts.length >= 2) {
const domain = parts.slice(0, 2).reverse().join('.')
return {
name: domain,
domain: domain,
favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
}
}
return null
}

187
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,187 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
import type { NetworkConfig } from '../types.js'
let oauthClient: BrowserOAuthClient | null = null
let agent: Agent | null = null
let currentNetworkConfig: NetworkConfig | null = null
export interface AuthSession {
did: string
handle: string
agent: Agent
}
export function setAuthNetworkConfig(config: NetworkConfig): void {
currentNetworkConfig = config
// Reset client when network changes
oauthClient = null
}
export 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
}
function getClientId(): string {
const host = window.location.host
// For localhost development
if (host.includes('localhost') || host.includes('127.0.0.1')) {
// client_id must start with http://localhost, redirect_uri must use 127.0.0.1
const port = window.location.port || '3000'
const redirectUri = `http://127.0.0.1:${port}/`
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
}
// For production, use the client-metadata.json
return `${window.location.origin}/client-metadata.json`
}
export async function login(handle: string): Promise<void> {
const client = await initOAuthClient()
await client.signIn(handle, {
scope: 'atproto transition:generic',
})
}
export async function handleOAuthCallback(): Promise<AuthSession | null> {
const params = new URLSearchParams(window.location.search)
if (!params.has('code') && !params.has('state')) {
return null
}
try {
const client = await initOAuthClient()
const result = await client.callback(params)
agent = new Agent(result.session)
// Get profile to get handle
const profile = await agent.getProfile({ actor: result.session.did })
// Clear URL params
window.history.replaceState({}, '', window.location.pathname)
return {
did: result.session.did,
handle: profile.data.handle,
agent,
}
} catch (err) {
console.error('OAuth callback error:', err)
return null
}
}
export async function restoreSession(): Promise<AuthSession | null> {
try {
const client = await initOAuthClient()
const result = await client.init()
if (result?.session) {
agent = new Agent(result.session)
const profile = await agent.getProfile({ actor: result.session.did })
return {
did: result.session.did,
handle: profile.data.handle,
agent,
}
}
} catch (err) {
console.error('Session restore error:', err)
}
return null
}
export async function logout(): Promise<void> {
// Clear all storage
sessionStorage.clear()
localStorage.clear()
// Clear IndexedDB (used by OAuth client)
const databases = await indexedDB.databases()
for (const db of databases) {
if (db.name) {
indexedDB.deleteDatabase(db.name)
}
}
agent = null
oauthClient = null
}
export function getAgent(): Agent | null {
return agent
}
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
}
}
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
}
}
export async function putRecord(
collection: string,
rkey: string,
record: Record<string, unknown>
): 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,
...record,
},
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Put record error:', err)
throw err
}
}

171
src/main.ts Normal file
View File

@@ -0,0 +1,171 @@
import { getProfile, listRecords, getRecord, setNetworkConfig } from './lib/api.js'
import { renderServices } from './components/services.js'
import { login, logout, restoreSession, handleOAuthCallback, setAuthNetworkConfig, type AuthSession } from './lib/auth.js'
import { mountProfile } from './components/profile.js'
import { mountPostList, mountPostDetail } from './components/posts.js'
import { mountHeader } from './components/browser.js'
import { mountAtBrowser } from './components/atbrowser.js'
import { mountPostForm } from './components/postform.js'
import type { AppConfig, Networks } from './types.js'
let authSession: AuthSession | null = null
async function loadConfig(): Promise<AppConfig> {
const res = await fetch('/config.json')
return res.json()
}
async function loadNetworks(): Promise<Networks> {
const res = await fetch('/networks.json')
return res.json()
}
function renderFooter(handle: string): string {
const parts = handle.split('.')
const username = parts[0] || handle
return `
<footer class="site-footer">
<p>&copy; ${username}</p>
</footer>
`
}
function renderTabs(handle: string, mode: string | null, isLoggedIn: boolean): string {
const blogActive = !mode || mode === 'blog' ? 'active' : ''
const browserActive = mode === 'browser' ? 'active' : ''
const postActive = mode === 'post' ? 'active' : ''
let tabs = `
<a href="?handle=${handle}" class="tab ${blogActive}">Blog</a>
<a href="?mode=browser&handle=${handle}" class="tab ${browserActive}">Browser</a>
`
if (isLoggedIn) {
tabs += `<a href="?mode=post&handle=${handle}" class="tab ${postActive}">Post</a>`
}
return `<div class="mode-tabs">${tabs}</div>`
}
async function init(): Promise<void> {
const [config, networks] = await Promise.all([loadConfig(), loadNetworks()])
// Set page title
document.title = config.title || 'ailog'
// Set theme color
if (config.color) {
document.documentElement.style.setProperty('--btn-color', config.color)
}
// Set network config
const networkConfig = networks[config.network]
if (networkConfig) {
setNetworkConfig(networkConfig)
setAuthNetworkConfig(networkConfig)
}
// Handle OAuth callback
const callbackSession = await handleOAuthCallback()
if (callbackSession) {
authSession = callbackSession
} else {
// Try to restore existing session
authSession = await restoreSession()
}
const params = new URLSearchParams(window.location.search)
const mode = params.get('mode')
const rkey = params.get('rkey')
const collection = params.get('collection')
const service = params.get('service')
const handle = params.get('handle') || config.handle
const profileEl = document.getElementById('profile')
const contentEl = document.getElementById('content')
const headerEl = document.getElementById('header')
const footerEl = document.getElementById('footer')
if (!profileEl || !contentEl || !headerEl) return
// Footer
if (footerEl) {
footerEl.innerHTML = renderFooter(config.handle)
}
const isLoggedIn = !!authSession
// Header with login
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => {
const currentMode = params.get('mode')
if (currentMode === 'browser') {
window.location.href = `?mode=browser&handle=${newHandle}`
} else {
window.location.href = `?handle=${newHandle}`
}
},
onLogin: async () => {
const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle
try {
await login(inputHandle)
} catch (err) {
console.error('Login error:', err)
alert('Login failed: ' + err)
}
},
onLogout: async () => {
await logout()
window.location.reload()
}
})
// Post mode (requires login)
if (mode === 'post' && isLoggedIn) {
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
mountPostForm(contentEl, config.collection, () => {
window.location.href = `?handle=${handle}`
})
return
}
// AT Browser mode
if (mode === 'browser') {
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
const loginDid = authSession?.did || null
await mountAtBrowser(contentEl, handle, collection, rkey, service, loginDid)
return
}
// Blog mode (default)
try {
const profile = await getProfile(handle)
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile)
// Add services
const servicesHtml = await renderServices(handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
if (rkey) {
const post = await getRecord(profile.did, config.collection, rkey)
if (post) {
const canEdit = isLoggedIn && authSession?.did === profile.did
mountPostDetail(contentEl, post, handle, config.collection, canEdit)
} else {
contentEl.innerHTML = '<p>Post not found</p>'
}
} else {
const posts = await listRecords(profile.did, config.collection)
mountPostList(contentEl, posts)
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
}
init()

779
src/styles/main.css Normal file
View File

@@ -0,0 +1,779 @@
:root {
--btn-color: #0066cc;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: #fff;
}
#app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
body {
background: #0a0a0a;
color: #e0e0e0;
}
.profile {
background: #1a1a1a;
}
.service-item {
background: #2a2a2a;
color: #e0e0e0;
}
.service-item:hover {
background: #333;
}
.post-item {
border-color: #333;
}
.post-link:hover {
background: #1a1a1a;
}
.browser-input {
background: #1a1a1a;
border-color: #333;
color: #e0e0e0;
}
}
/* Header */
#header {
margin-bottom: 24px;
}
.header-form {
display: flex;
gap: 8px;
align-items: center;
}
.header-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.header-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
color: #333;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
}
.header-btn:hover {
background: #e0e0e0;
}
.header-btn.at-btn {
background: var(--btn-color);
color: #fff;
border-color: var(--btn-color);
}
.header-btn.at-btn:hover {
background: #0052a3;
}
.header-btn.login-btn {
color: #666;
}
.header-btn.user-btn {
background: var(--btn-color);
color: #fff;
border-color: var(--btn-color);
}
/* Post Form */
.post-form-container {
padding: 20px 0;
}
.post-form-container h3 {
font-size: 18px;
margin-bottom: 16px;
}
.post-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.post-form-title {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
.post-form-body {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
resize: vertical;
min-height: 120px;
font-family: inherit;
}
.post-form-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.post-form-collection {
font-size: 12px;
color: #888;
font-family: monospace;
}
.post-form-btn {
padding: 10px 24px;
background: var(--btn-color);
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.post-form-btn:hover {
background: #0052a3;
}
.post-form-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.post-status {
margin-top: 12px;
}
.post-success {
color: #155724;
}
.post-error {
color: #dc3545;
}
/* Profile */
.profile {
display: flex;
gap: 16px;
padding: 20px;
background: #f5f5f5;
border-radius: 12px;
margin-bottom: 24px;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.profile-info {
flex: 1;
}
.profile-name {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.profile-handle {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.profile-desc {
font-size: 14px;
color: #444;
}
/* Services */
.services {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.service-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #f5f5f5;
border-radius: 20px;
text-decoration: none;
color: #333;
font-size: 13px;
transition: background 0.2s;
}
.service-item:hover {
background: #e8e8e8;
}
.service-favicon {
width: 16px;
height: 16px;
}
.service-name {
font-weight: 500;
}
/* Post List */
.post-list {
list-style: none;
margin-top: 24px;
margin-bottom: 24px;
}
.post-item {
border-bottom: 1px solid #eee;
}
.post-link {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 8px;
text-decoration: none;
color: inherit;
}
.post-link:hover {
background: #f9f9f9;
}
.post-title {
font-weight: 500;
}
.post-date {
font-size: 13px;
color: #888;
}
/* Post Detail */
.post-detail {
padding: 20px 0;
}
.post-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #eee;
}
.post-header .post-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.post-meta {
display: flex;
align-items: center;
gap: 12px;
}
.post-header .post-date {
font-size: 14px;
color: #888;
}
.json-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: #f0f0f0;
color: #666;
border-radius: 4px;
text-decoration: none;
font-family: monospace;
font-size: 12px;
}
.json-btn:hover {
background: #e0e0e0;
color: #333;
}
.edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: #28a745;
color: #fff;
border: none;
border-radius: 4px;
text-decoration: none;
font-family: monospace;
font-size: 12px;
cursor: pointer;
}
.edit-btn:hover {
background: #218838;
}
/* Edit Form */
.edit-form-container {
padding: 20px 0;
}
.edit-form-container h3 {
font-size: 18px;
margin-bottom: 16px;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-form-title {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
.edit-form-body {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
resize: vertical;
min-height: 200px;
font-family: inherit;
}
.edit-form-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.edit-cancel-btn {
padding: 10px 24px;
background: #6c757d;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.edit-cancel-btn:hover {
background: #5a6268;
}
.edit-submit-btn {
padding: 10px 24px;
background: #28a745;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.edit-submit-btn:hover {
background: #218838;
}
.edit-submit-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.post-content {
font-size: 16px;
line-height: 1.8;
white-space: pre-wrap;
}
.post-footer {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.back-link {
color: var(--btn-color);
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
/* Utility */
.no-posts,
.no-data,
.error {
padding: 40px;
text-align: center;
color: #888;
}
.loading {
padding: 40px;
text-align: center;
color: #666;
}
/* Footer */
.site-footer {
margin-top: 60px;
padding: 20px 0;
text-align: center;
font-size: 13px;
color: #888;
}
.site-footer p {
margin: 4px 0;
}
/* Mode Tabs */
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
}
.tab {
padding: 8px 16px;
text-decoration: none;
color: #666;
border-radius: 6px;
font-size: 14px;
}
.tab:hover {
background: #f0f0f0;
}
.tab.active {
background: var(--btn-color);
color: #fff;
}
/* AT Browser */
.services-list,
.collections,
.records,
.record-detail {
padding: 16px 0;
}
.services-list h3,
.collections h3,
.records h3,
.record-detail h3 {
font-size: 18px;
margin-bottom: 12px;
}
.service-list {
list-style: none;
}
.service-list-item {
border-bottom: 1px solid #eee;
}
.service-list-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
text-decoration: none;
color: inherit;
}
.service-list-link:hover {
background: #f9f9f9;
}
.service-list-favicon {
width: 24px;
height: 24px;
}
.service-list-name {
flex: 1;
font-weight: 500;
}
.service-list-count {
font-size: 13px;
color: #888;
}
.collection-header {
display: flex;
align-items: center;
gap: 8px;
}
.collection-header-favicon {
width: 24px;
height: 24px;
}
.collection-list,
.record-list {
list-style: none;
}
.collection-item,
.record-item {
border-bottom: 1px solid #eee;
}
.collection-link,
.record-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
text-decoration: none;
color: inherit;
font-family: monospace;
font-size: 14px;
}
.collection-link:hover,
.record-link:hover {
background: #f9f9f9;
}
.collection-favicon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.collection-nsid {
flex: 1;
}
.collection-service {
font-size: 12px;
color: #888;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
.record-link {
display: flex;
gap: 16px;
}
.record-rkey {
color: var(--btn-color);
min-width: 120px;
}
.record-preview {
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-count {
font-size: 13px;
color: #888;
margin-bottom: 12px;
}
/* Record Detail */
.record-header {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #eee;
}
.record-uri,
.record-cid {
font-family: monospace;
font-size: 12px;
color: #666;
margin: 4px 0;
word-break: break-all;
}
.schema-status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
margin-top: 8px;
}
.schema-verified {
background: #d4edda;
color: #155724;
}
.schema-none {
background: #f0f0f0;
color: #666;
}
.delete-btn {
display: inline-block;
padding: 6px 12px;
background: #dc3545;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-left: 8px;
}
.delete-btn:hover {
background: #c82333;
}
.delete-btn:disabled {
background: #999;
cursor: not-allowed;
}
/* JSON View */
.json-view {
background: #f5f5f5;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
}
.json-view pre {
margin: 0;
}
.json-view code {
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 13px;
line-height: 1.5;
}
/* Dark mode additions */
@media (prefers-color-scheme: dark) {
.header-input {
background: #1a1a1a;
border-color: #333;
color: #e0e0e0;
}
.header-btn {
background: #2a2a2a;
border-color: #333;
color: #e0e0e0;
}
.header-btn:hover {
background: #333;
}
.header-btn.at-btn,
.header-btn.user-btn {
background: var(--btn-color);
border-color: var(--btn-color);
color: #fff;
}
.post-form-title,
.post-form-body {
background: #1a1a1a;
border-color: #333;
color: #e0e0e0;
}
.json-btn {
background: #2a2a2a;
color: #888;
}
.json-btn:hover {
background: #333;
color: #e0e0e0;
}
.edit-form-title,
.edit-form-body {
background: #1a1a1a;
border-color: #333;
color: #e0e0e0;
}
.tab:hover {
background: #333;
}
.tab.active {
background: var(--btn-color);
}
.service-list-link:hover,
.collection-link:hover,
.record-link:hover {
background: #1a1a1a;
}
.service-list-item,
.collection-item,
.record-item,
.record-header {
border-color: #333;
}
.json-view {
background: #1a1a1a;
}
.schema-verified {
background: #1e3a29;
color: #75b798;
}
.schema-none {
background: #2a2a2a;
color: #888;
}
.delete-btn {
background: #dc3545;
}
.delete-btn:hover {
background: #c82333;
}
}

31
src/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export interface Profile {
did: string
handle: string
displayName?: string
description?: string
avatar?: string
banner?: string
}
export interface BlogPost {
uri: string
cid: string
title: string
content: string
createdAt: string
}
export interface NetworkConfig {
plc: string
bsky: string
}
export interface AppConfig {
title: string
handle: string
collection: string
network: string
color?: string
}
export type Networks = Record<string, NetworkConfig>