Files
log/src/components/atbrowser.ts
2026-01-15 23:56:20 +09:00

234 lines
7.3 KiB
TypeScript

import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js'
import { deleteRecord } from '../lib/auth.js'
import { escapeHtml } from '../lib/utils.js'
function extractRkey(uri: string): string {
const parts = uri.split('/')
return parts[parts.length - 1]
}
async function renderServices(did: string, handle: string): Promise<string> {
const [collections, pds] = await Promise.all([
describeRepo(did),
resolvePds(did)
])
// Server info section
const plcUrl = `${getPlc()}/${did}/log`
const serverHtml = `
<div class="server-info">
<h3>Server</h3>
<dl class="server-details">
<div class="server-row">
<dt>DID</dt>
<dd class="did-value"><a href="${plcUrl}" target="_blank" rel="noopener">${escapeHtml(did)}</a></dd>
</div>
<div class="server-row">
<dt>PDS</dt>
<dd class="pds-value"><a href="${escapeHtml(pds)}" target="_blank" rel="noopener">${escapeHtml(pds)}</a></dd>
</div>
</dl>
</div>
`
if (collections.length === 0) {
return serverHtml + '<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="/at/${handle}/${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 `
${serverHtml}
<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="/at/${handle}/${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="/at/${handle}/${collection}/${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 = '<div class="loading"><div class="loading-spinner"></div></div>'
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="/at/${handle}/${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="/at/${handle}/${backService}" class="back-link">← ${info?.name || 'Back'}</a>`
content = await renderRecordList(did, handle, collection)
} else if (service) {
nav = `<a href="/at/${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 = `/at/${handle}/${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>`
}
}