simple new blog

This commit is contained in:
2026-01-15 17:19:43 +09:00
parent b344a0a760
commit cf46369cd0
52 changed files with 1590 additions and 3174 deletions

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

@@ -0,0 +1,140 @@
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo } from '../lib/api.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 renderCollections(did: string, handle: string): Promise<string> {
const collections = await describeRepo(did)
if (collections.length === 0) {
return '<p class="no-data">No collections found</p>'
}
const items = collections.map(col => {
const service = getServiceInfo(col)
const favicon = service ? `<img src="${service.favicon}" class="collection-favicon" alt="" onerror="this.style.display='none'">` : ''
const serviceName = service ? `<span class="collection-service">${service.name}</span>` : ''
return `
<li class="collection-item">
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link">
${favicon}
<span class="collection-nsid">${col}</span>
${serviceName}
</a>
</li>
`
}).join('')
return `
<div class="collections">
<h3>Collections</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): 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)
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>
</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
): Promise<void> {
container.innerHTML = '<p class="loading">Loading...</p>'
try {
const did = handle.startsWith('did:') ? handle : await resolveHandle(handle)
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)
} else if (collection) {
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Collections</a>`
content = await renderRecordList(did, handle, collection)
} else {
content = await renderCollections(did, handle)
}
container.innerHTML = nav + content
} 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'
}
})
}

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

@@ -0,0 +1,54 @@
import type { BlogPost } from '../types.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): void {
container.innerHTML = `
<article class="post-detail">
<header class="post-header">
<h1 class="post-title">${escapeHtml(post.title)}</h1>
<time class="post-date">${formatDate(post.createdAt)}</time>
</header>
<div class="post-content">${escapeHtml(post.content)}</div>
<footer class="post-footer">
<a href="?handle=${handle}" class="back-link">← Back to posts</a>
</footer>
</article>
`
}

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)
}