init
This commit is contained in:
227
src/components/atbrowser.ts
Normal file
227
src/components/atbrowser.ts
Normal 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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
89
src/components/browser.ts
Normal 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: () => {}
|
||||
})
|
||||
}
|
||||
74
src/components/postform.ts
Normal file
74
src/components/postform.ts
Normal 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
115
src/components/posts.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = '<p class="no-posts">No posts yet</p>'
|
||||
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
18
src/components/profile.ts
Normal 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)
|
||||
}
|
||||
42
src/components/services.ts
Normal file
42
src/components/services.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user