add translate

This commit is contained in:
2026-01-18 15:47:24 +09:00
parent 6ef8780ac6
commit 4da313aa68
14 changed files with 970 additions and 84 deletions

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

1
public/icon/language.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -167,14 +167,21 @@ export function renderRecordList(
// Render single record detail // Render single record detail
export function renderRecordDetail( export function renderRecordDetail(
record: { uri: string; cid: string; value: unknown }, record: { uri: string; cid: string; value: unknown },
collection: string collection: string,
isOwner: boolean = false
): string { ): 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 ` return `
<article class="record-detail"> <article class="record-detail">
<header class="record-header"> <header class="record-header">
<h3>${collection}</h3> <h3>${collection}</h3>
<p class="record-uri">URI: ${record.uri}</p> <p class="record-uri">URI: ${record.uri}</p>
<p class="record-cid">CID: ${record.cid}</p> <p class="record-cid">CID: ${record.cid}</p>
${deleteBtn}
</header> </header>
<div class="json-view"> <div class="json-view">
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre> <pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>

View File

@@ -0,0 +1,105 @@
import { searchPostsForUrl, 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 (truncate for search limit)
let searchQuery = postUrl
try {
const urlObj = new URL(postUrl)
const pathParts = urlObj.pathname.split('/').filter(Boolean)
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
const rkey = pathParts[1] || ''
const remainingLength = 20 - basePath.length
const rkeyPrefix = remainingLength > 0 ? rkey.slice(0, remainingLength) : ''
searchQuery = basePath + rkeyPrefix
} 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
const dataAppUrl = 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

@@ -1,11 +1,11 @@
import { isLoggedIn, getLoggedInDid } from '../lib/auth' import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
export function renderHeader(currentHandle: string): string { export function renderHeader(currentHandle: string): string {
const loggedIn = isLoggedIn() const loggedIn = isLoggedIn()
const did = getLoggedInDid() const handle = getLoggedInHandle()
const loginBtn = loggedIn const loginBtn = loggedIn
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout (${did?.slice(0, 20)}...)">✓</button>` ? `<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">↗</button>` : `<button type="button" class="header-btn login-btn" id="login-btn" title="Login">↗</button>`
return ` return `

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

@@ -1,4 +1,5 @@
import { getNetworks } from '../lib/api' import { getNetworks } from '../lib/api'
import { isLoggedIn } from '../lib/auth'
let currentNetwork = 'bsky.social' let currentNetwork = 'bsky.social'
@@ -10,12 +11,16 @@ export function setCurrentNetwork(network: string): void {
currentNetwork = network currentNetwork = network
} }
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' = 'blog'): string { export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string {
let tabs = ` let tabs = `
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">Blog</a> <a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">Blog</a>
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">Browser</a> <a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">Browser</a>
` `
if (isLoggedIn()) {
tabs += `<a href="/@${handle}/post" class="tab ${activeTab === 'post' ? 'active' : ''}">Post</a>`
}
tabs += ` tabs += `
<div class="pds-selector" id="pds-selector"> <div class="pds-selector" id="pds-selector">
<button type="button" class="tab" id="pds-tab">PDS</button> <button type="button" class="tab" id="pds-tab">PDS</button>

View File

@@ -1,5 +1,6 @@
import type { Post } from '../types' import type { Post } from '../types'
import { renderMarkdown } from '../lib/markdown' import { renderMarkdown } from '../lib/markdown'
import { renderDiscussion, loadDiscussionPosts } from './discussion'
// Render post list // Render post list
export function renderPostList(posts: Post[], handle: string): string { export function renderPostList(posts: Post[], handle: string): string {
@@ -25,26 +26,187 @@ export function renderPostList(posts: Post[], handle: string): string {
} }
// Render single post detail // Render single post detail
export function renderPostDetail(post: Post, handle: string, collection: string): string { 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 rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP') const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP')
const content = renderMarkdown(post.value.content) const content = renderMarkdown(post.value.content)
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}` const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}`
// Build post URL for discussion search
const postUrl = siteUrl ? `${siteUrl}/${rkey}` : `${window.location.origin}/@${handle}/${rkey}`
const editBtn = isOwner ? `<button type="button" class="post-edit-btn" id="post-edit-btn">Edit</button>` : ''
// Translation selector (dropdown like PDS)
const translations = post.value.translations
const hasTranslation = translations && Object.keys(translations).length > 0
let langSelector = ''
let translationContents = ''
if (hasTranslation) {
const langs = Object.keys(translations)
const originalLang = post.value.lang || 'ja'
// Build dropdown options
const options = [`
<div class="lang-option selected" data-lang="original">
<span class="lang-name">${originalLang.toUpperCase()}</span>
<span class="lang-check">✓</span>
</div>
`]
for (const lang of langs) {
const trans = translations[lang]
const transContent = renderMarkdown(trans.content)
const transTitle = trans.title || post.value.title
options.push(`
<div class="lang-option" data-lang="${lang}">
<span class="lang-name">${lang.toUpperCase()}</span>
<span class="lang-check">✓</span>
</div>
`)
translationContents += `
<div class="post-translation" id="post-translation-${lang}" style="display: none;">
<h1 class="post-title">${escapeHtml(transTitle)}</h1>
<div class="post-content">${transContent}</div>
</div>
`
}
langSelector = `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn">
<img src="/icon/language.svg" alt="Language" class="lang-icon">
</button>
<div class="lang-dropdown" id="lang-dropdown">
${options.join('')}
</div>
</div>
`
}
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)}">
<textarea class="post-edit-content" id="post-edit-content" rows="10">${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 ` return `
<article class="post-detail"> <article class="post-detail" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}">
<header class="post-header"> <header class="post-header">
<h1 class="post-title">${escapeHtml(post.value.title)}</h1> <h1 class="post-title" id="post-title-display">${escapeHtml(post.value.title)}</h1>
<div class="post-meta"> <div class="post-meta">
<time class="post-date">${date}</time> <time class="post-date">${date}</time>
<a href="${jsonUrl}" class="json-btn">json</a> <a href="${jsonUrl}" class="json-btn">json</a>
${editBtn}
</div> </div>
</header> </header>
<div class="post-content">${content}</div> ${langSelector ? `<div class="post-lang-bar">${langSelector}</div>` : ''}
<div class="post-content" id="post-content-display">${content}</div>
${translationContents}
${editForm}
</article> </article>
${renderDiscussion(postUrl, appUrl)}
` `
} }
// Setup post detail interactions (language selector, discussion loading)
export function setupPostDetail(container: HTMLElement): void {
const article = container.querySelector('.post-detail') as HTMLElement
if (!article) return
// Language selector dropdown
const langBtn = container.querySelector('#lang-btn') as HTMLButtonElement
const langDropdown = container.querySelector('#lang-dropdown') as HTMLElement
const originalContent = container.querySelector('#post-content-display') as HTMLElement
const originalTitle = container.querySelector('#post-title-display') as HTMLElement
if (langBtn && langDropdown) {
// Function to apply language selection
const applyLanguage = (lang: string) => {
// Update selection UI
langDropdown.querySelectorAll('.lang-option').forEach(o => {
o.classList.remove('selected')
if ((o as HTMLElement).dataset.lang === lang) {
o.classList.add('selected')
}
})
// Hide all translations
container.querySelectorAll('.post-translation').forEach(el => {
(el as HTMLElement).style.display = 'none'
})
if (lang === 'original') {
// Show original
if (originalContent) originalContent.style.display = ''
if (originalTitle) originalTitle.style.display = ''
} else {
// Hide original, show translation
if (originalContent) originalContent.style.display = 'none'
if (originalTitle) originalTitle.style.display = 'none'
const transEl = container.querySelector(`#post-translation-${lang}`) as HTMLElement
if (transEl) transEl.style.display = 'block'
}
}
// Restore saved language preference
const savedLang = localStorage.getItem('preferred-lang')
if (savedLang) {
const hasLang = langDropdown.querySelector(`[data-lang="${savedLang}"]`)
if (hasLang) {
applyLanguage(savedLang)
}
}
// Toggle dropdown
langBtn.addEventListener('click', (e) => {
e.stopPropagation()
langDropdown.classList.toggle('show')
})
// Close on outside click
document.addEventListener('click', () => {
langDropdown.classList.remove('show')
})
// Handle option selection
langDropdown.querySelectorAll('.lang-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation()
const lang = (opt as HTMLElement).dataset.lang || 'original'
// Save preference
localStorage.setItem('preferred-lang', lang)
applyLanguage(lang)
langDropdown.classList.remove('show')
})
})
}
// 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 { export function mountPostList(container: HTMLElement, html: string): void {
container.innerHTML = html container.innerHTML = html
} }

View File

@@ -19,24 +19,17 @@ export async function renderProfile(
: `<span>@${escapeHtml(handle)}</span>` : `<span>@${escapeHtml(handle)}</span>`
const avatarHtml = avatarUrl const avatarHtml = avatarUrl
? `<a href="/"><img class="profile-avatar" src="${avatarUrl}" alt="${displayName}"></a>` ? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
: `<a href="/"><div class="profile-avatar-placeholder"></div></a>` : `<div class="profile-avatar-placeholder"></div>`
return ` return `
<div class="profile"> <div class="profile">
<div class="profile-row"> ${avatarHtml}
${avatarHtml} <div class="profile-info">
<div class="profile-meta"> <h1 class="profile-name">${escapeHtml(displayName)}</h1>
<span class="profile-name">${escapeHtml(displayName)}</span> <p class="profile-handle">${handleHtml}</p>
<span class="profile-handle">${handleHtml}</span> ${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
</div>
</div> </div>
${description ? `
<div class="profile-row">
<div class="profile-avatar-spacer"></div>
<p class="profile-description">${escapeHtml(description)}</p>
</div>
` : ''}
</div> </div>
` `
} }

View File

@@ -242,3 +242,96 @@ export async function getRecord(did: string, collection: string, rkey: string):
} }
return null return null
} }
// Constants for search
const SEARCH_TIMEOUT_MS = 5000
const MAX_SEARCH_LENGTH = 20
// Search posts that link to a URL
export async function searchPostsForUrl(url: string): Promise<SearchPost[]> {
// Use public.api.bsky.app for search
const endpoint = 'https://public.api.bsky.app'
// Extract search-friendly patterns from URL
const searchQueries: string[] = []
try {
const urlObj = new URL(url)
const pathWithDomain = urlObj.host + urlObj.pathname.replace(/\/$/, '')
// Limit length for search
if (pathWithDomain.length <= MAX_SEARCH_LENGTH) {
searchQueries.push(pathWithDomain)
} else {
// Truncate to max length
searchQueries.push(pathWithDomain.slice(0, MAX_SEARCH_LENGTH))
}
// Also try shorter path
const pathParts = urlObj.pathname.split('/').filter(Boolean)
if (pathParts.length >= 1) {
const shortPath = urlObj.host + '/' + pathParts[0]
if (shortPath.length <= MAX_SEARCH_LENGTH) {
searchQueries.push(shortPath)
}
}
} catch {
searchQueries.push(url.slice(0, MAX_SEARCH_LENGTH))
}
const allPosts: SearchPost[] = []
const seenUris = new Set<string>()
for (const query of searchQueries) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
const res = await fetch(
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
{ signal: controller.signal }
)
clearTimeout(timeoutId)
if (!res.ok) continue
const data = await res.json()
const posts = (data.posts || []).filter((post: SearchPost) => {
const embedUri = (post.record as { embed?: { external?: { uri?: string } } })?.embed?.external?.uri
const text = (post.record as { text?: string })?.text || ''
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
})
for (const post of posts) {
if (!seenUris.has(post.uri)) {
seenUris.add(post.uri)
allPosts.push(post)
}
}
} catch {
// Timeout or network error
}
}
// Sort by date (newest first)
allPosts.sort((a, b) => {
const aDate = (a.record as { createdAt?: string })?.createdAt || ''
const bDate = (b.record as { createdAt?: string })?.createdAt || ''
return new Date(bDate).getTime() - new Date(aDate).getTime()
})
return allPosts
}
// Search post type
export interface SearchPost {
uri: string
cid: string
author: {
did: string
handle: string
displayName?: string
avatar?: string
}
record: unknown
}

View File

@@ -242,3 +242,52 @@ export async function createPost(
throw err throw err
} }
} }
// Update post
export async function updatePost(
collection: string,
rkey: string,
title: string,
content: string
): 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,
title,
content,
createdAt: new Date().toISOString(),
},
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Update post error:', err)
throw err
}
}
// Delete record
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
}
}

View File

@@ -1,5 +1,5 @@
export interface Route { export interface Route {
type: 'home' | 'user' | 'post' | 'atbrowser' | 'service' | 'collection' | 'record' type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
handle?: string handle?: string
rkey?: string rkey?: string
service?: string service?: string
@@ -45,7 +45,13 @@ export function parseRoute(): Route {
return { type: 'user', handle: userMatch[1] } return { type: 'user', handle: userMatch[1] }
} }
// Post page: /@handle/rkey (for config.collection) // Post form page: /@handle/post
const postPageMatch = path.match(/^\/@([^/]+)\/post\/?$/)
if (postPageMatch) {
return { type: 'postpage', handle: postPageMatch[1] }
}
// Post detail page: /@handle/rkey (for config.collection)
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/) const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
if (postMatch) { if (postMatch) {
return { type: 'post', handle: postMatch[1], rkey: postMatch[2] } return { type: 'post', handle: postMatch[1], rkey: postMatch[2] }
@@ -61,6 +67,8 @@ export function navigate(route: Route): void {
if (route.type === 'user' && route.handle) { if (route.type === 'user' && route.handle) {
path = `/@${route.handle}` path = `/@${route.handle}`
} else if (route.type === 'postpage' && route.handle) {
path = `/@${route.handle}/post`
} else if (route.type === 'post' && route.handle && route.rkey) { } else if (route.type === 'post' && route.handle && route.rkey) {
path = `/@${route.handle}/${route.rkey}` path = `/@${route.handle}/${route.rkey}`
} else if (route.type === 'atbrowser' && route.handle) { } else if (route.type === 'atbrowser' && route.handle) {

View File

@@ -1,14 +1,15 @@
import './styles/main.css' import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api' import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle } from './lib/auth' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
import { renderHeader } from './components/header' import { renderHeader } from './components/header'
import { renderProfile } from './components/profile' import { renderProfile } from './components/profile'
import { renderPostList, renderPostDetail } from './components/posts' import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
import { renderPostForm, setupPostForm } from './components/postform' import { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser' import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
import { renderModeTabs, setupModeTabs } from './components/mode-tabs' import { renderModeTabs, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer' import { renderFooter } from './components/footer'
import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')! const app = document.getElementById('app')!
@@ -51,6 +52,8 @@ async function getWebUrl(handle: string): Promise<string | undefined> {
} }
async function render(route: Route): Promise<void> { async function render(route: Route): Promise<void> {
showLoading(app)
try { try {
const config = await getConfig() const config = await getConfig()
@@ -115,8 +118,9 @@ async function render(route: Route): Promise<void> {
// Build page // Build page
let html = renderHeader(handle) let html = renderHeader(handle)
// Mode tabs (Blog/Browser/PDS) // Mode tabs (Blog/Browser/Post/PDS)
const activeTab = route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog' const activeTab = route.type === 'postpage' ? 'post' :
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab) html += renderModeTabs(handle, activeTab)
// Profile section // Profile section
@@ -124,12 +128,16 @@ async function render(route: Route): Promise<void> {
html += await renderProfile(did, profile, handle, webUrl) html += await renderProfile(did, profile, handle, webUrl)
} }
// Check if logged-in user owns this content
const loggedInDid = getLoggedInDid()
const isOwner = isLoggedIn() && loggedInDid === did
// Content section based on route type // Content section based on route type
if (route.type === 'record' && route.collection && route.rkey) { if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view // AT-Browser: Single record view
const record = await getRecord(did, route.collection, route.rkey) const record = await getRecord(did, route.collection, route.rkey)
if (record) { if (record) {
html += `<div id="content">${renderRecordDetail(record, route.collection)}</div>` html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>`
} else { } else {
html += `<div id="content" class="error">Record not found</div>` html += `<div id="content" class="error">Record not found</div>`
} }
@@ -165,24 +173,22 @@ async function render(route: Route): Promise<void> {
// Post detail (config.collection with markdown) // Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst) const post = await getPost(did, config.collection, route.rkey, localFirst)
if (post) { if (post) {
html += `<div id="content">${renderPostDetail(post, handle, config.collection)}</div>` html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>`
} else { } else {
html += `<div id="content" class="error">Post not found</div>` html += `<div id="content" class="error">Post not found</div>`
} }
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'postpage') {
// Post form page
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else { } else {
// User page: compact collection buttons + posts // User page: compact collection buttons + posts
const collections = await describeRepo(did) const collections = await describeRepo(did)
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>` html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
// Show post form if logged-in user is viewing their own page
const loggedInHandle = getLoggedInHandle()
const isOwnPage = isLoggedIn() && loggedInHandle === handle
if (isOwnPage) {
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
}
const posts = await getPosts(did, config.collection, localFirst) const posts = await getPosts(did, config.collection, localFirst)
html += `<div id="content">${renderPostList(posts, handle)}</div>` html += `<div id="content">${renderPostList(posts, handle)}</div>`
} }
@@ -190,6 +196,7 @@ async function render(route: Route): Promise<void> {
html += renderFooter(handle) html += renderFooter(handle)
app.innerHTML = html app.innerHTML = html
hideLoading(app)
setupEventHandlers() setupEventHandlers()
// Setup mode tabs (PDS selector) // Setup mode tabs (PDS selector)
@@ -198,15 +205,28 @@ async function render(route: Route): Promise<void> {
render(parseRoute()) render(parseRoute())
}) })
// Setup post form if it exists // Setup post form on postpage
const loggedInHandle = getLoggedInHandle() if (route.type === 'postpage' && isLoggedIn()) {
if (isLoggedIn() && loggedInHandle === handle) {
setupPostForm(config.collection, () => { setupPostForm(config.collection, () => {
// Refresh on success // Navigate to user page on success
render(parseRoute()) navigate({ type: 'user', handle })
}) })
} }
// Setup record delete button
if (isOwner) {
setupRecordDelete(handle, route)
setupPostEdit(config.collection)
}
// Setup post detail (translation toggle, discussion)
if (route.type === 'post') {
const contentEl = document.getElementById('content')
if (contentEl) {
setupPostDetail(contentEl)
}
}
} catch (error) { } catch (error) {
console.error('Render error:', error) console.error('Render error:', error)
app.innerHTML = ` app.innerHTML = `
@@ -214,6 +234,7 @@ async function render(route: Route): Promise<void> {
<div class="error">Error: ${error}</div> <div class="error">Error: ${error}</div>
${renderFooter(currentHandle)} ${renderFooter(currentHandle)}
` `
hideLoading(app)
setupEventHandlers() setupEventHandlers()
} }
} }
@@ -254,6 +275,95 @@ function setupEventHandlers(): void {
}) })
} }
// Setup record delete button
function setupRecordDelete(handle: string, _route: Route): void {
const deleteBtn = document.getElementById('record-delete-btn')
if (!deleteBtn) return
deleteBtn.addEventListener('click', async () => {
const collection = deleteBtn.getAttribute('data-collection')
const rkey = deleteBtn.getAttribute('data-rkey')
if (!collection || !rkey) return
if (!confirm('Are you sure you want to delete this record?')) return
try {
deleteBtn.textContent = 'Deleting...'
;(deleteBtn as HTMLButtonElement).disabled = true
await deleteRecord(collection, rkey)
// Navigate back to collection list
navigate({ type: 'collection', handle, collection })
} catch (err) {
console.error('Delete failed:', err)
alert('Delete failed: ' + err)
deleteBtn.textContent = 'Delete'
;(deleteBtn as HTMLButtonElement).disabled = false
}
})
}
// Setup post edit form
function setupPostEdit(collection: string): void {
const editBtn = document.getElementById('post-edit-btn')
const editForm = document.getElementById('post-edit-form')
const titleDisplay = document.getElementById('post-title-display')
const contentDisplay = document.getElementById('post-content-display')
const cancelBtn = document.getElementById('post-edit-cancel')
const saveBtn = document.getElementById('post-edit-save')
const titleInput = document.getElementById('post-edit-title') as HTMLInputElement
const contentInput = document.getElementById('post-edit-content') as HTMLTextAreaElement
if (!editBtn || !editForm) return
// Show edit form
editBtn.addEventListener('click', () => {
if (titleDisplay) titleDisplay.style.display = 'none'
if (contentDisplay) contentDisplay.style.display = 'none'
editForm.style.display = 'block'
editBtn.style.display = 'none'
})
// Cancel edit
cancelBtn?.addEventListener('click', () => {
editForm.style.display = 'none'
if (titleDisplay) titleDisplay.style.display = ''
if (contentDisplay) contentDisplay.style.display = ''
editBtn.style.display = ''
})
// Save edit
saveBtn?.addEventListener('click', async () => {
const rkey = saveBtn.getAttribute('data-rkey')
if (!rkey || !titleInput || !contentInput) return
const title = titleInput.value.trim()
const content = contentInput.value.trim()
if (!title || !content) {
alert('Title and content are required')
return
}
try {
saveBtn.textContent = 'Saving...'
;(saveBtn as HTMLButtonElement).disabled = true
await updatePost(collection, rkey, title, content)
// Refresh the page
render(parseRoute())
} catch (err) {
console.error('Update failed:', err)
alert('Update failed: ' + err)
saveBtn.textContent = 'Save'
;(saveBtn as HTMLButtonElement).disabled = false
}
})
}
// Initial render // Initial render
render(parseRoute()) render(parseRoute())

View File

@@ -19,6 +19,41 @@ body {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
position: relative;
}
/* Loading */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f0f0f0;
border-top-color: var(--btn-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-small {
padding: 12px;
text-align: center;
color: #666;
font-size: 14px;
} }
/* Dark mode */ /* Dark mode */
@@ -147,9 +182,13 @@ body {
} }
.header-btn.user-btn { .header-btn.user-btn {
width: auto;
padding: 8px 12px;
background: var(--btn-color); background: var(--btn-color);
color: #fff; color: #fff;
border-color: var(--btn-color); border-color: var(--btn-color);
font-size: 13px;
font-weight: 500;
} }
/* Post Form */ /* Post Form */
@@ -322,79 +361,59 @@ body {
color: #fff; color: #fff;
} }
/* Profile - SNS style */ /* Profile */
.profile { .profile {
padding: 16px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
margin: 32px 0;
}
.profile-row {
display: flex; display: flex;
align-items: flex-start; gap: 16px;
gap: 12px; padding: 20px;
} background: #f5f5f5;
border-radius: 12px;
.profile-row + .profile-row { margin-bottom: 24px;
margin-top: 8px;
} }
.profile-avatar { .profile-avatar {
width: 48px; width: 80px;
height: 48px; height: 80px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0;
} }
.profile-avatar-placeholder { .profile-avatar-placeholder {
width: 48px; width: 80px;
height: 48px; height: 80px;
border-radius: 50%; border-radius: 50%;
background: #e0e0e0; background: #e0e0e0;
flex-shrink: 0;
} }
.profile-avatar-spacer { .profile-info {
width: 48px; flex: 1;
flex-shrink: 0;
}
.profile-meta {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
} }
.profile-name { .profile-name {
font-size: 15px; font-size: 20px;
font-weight: 700; font-weight: 600;
color: #0f1419; margin-bottom: 4px;
} }
.profile-handle { .profile-handle {
font-size: 14px; font-size: 14px;
color: #536471; color: #666;
margin-bottom: 8px;
} }
.profile-handle-link { .profile-handle-link {
color: #536471; color: #666;
text-decoration: none; text-decoration: none;
} }
.profile-handle-link:hover { .profile-handle-link:hover {
color: var(--btn-color);
text-decoration: underline; text-decoration: underline;
} }
.profile-description { .profile-desc {
font-size: 14px; font-size: 14px;
color: #0f1419; color: #444;
line-height: 1.5;
margin: 0;
} }
/* Services */ /* Services */
@@ -592,6 +611,120 @@ body {
color: #333; color: #333;
} }
/* Record Delete Button */
.record-delete-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
margin-top: 12px;
background: #dc3545;
color: #fff;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.record-delete-btn:hover {
background: #c82333;
}
.record-delete-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
/* Post Edit Button */
.post-edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
margin-left: 8px;
background: var(--btn-color);
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: opacity 0.2s;
}
.post-edit-btn:hover {
opacity: 0.85;
}
/* Post Edit Form */
.post-edit-form {
margin-top: 16px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}
.post-edit-title {
width: 100%;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
}
.post-edit-content {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 200px;
}
.post-edit-actions {
display: flex;
gap: 8px;
margin-top: 12px;
justify-content: flex-end;
}
.post-edit-cancel {
padding: 8px 16px;
background: #6c757d;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.post-edit-cancel:hover {
background: #5a6268;
}
.post-edit-save {
padding: 8px 16px;
background: var(--btn-color);
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.post-edit-save:hover {
opacity: 0.9;
}
.post-edit-save:disabled {
background: #6c757d;
cursor: not-allowed;
}
.edit-btn { .edit-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -611,6 +744,204 @@ body {
background: #218838; background: #218838;
} }
/* Discussion */
.discussion-section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.discussion-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #f5f5f5;
color: #333;
text-decoration: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.discussion-link:hover {
background: #e8e8e8;
}
.discussion-link svg {
color: var(--btn-color);
}
.discussion-posts {
margin-top: 16px;
}
.discussion-post {
display: block;
padding: 12px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: background 0.2s;
}
.discussion-post:hover {
background: #f0f0f0;
}
.discussion-author {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.discussion-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.discussion-avatar-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background: #ddd;
}
.discussion-author-info {
flex: 1;
min-width: 0;
}
.discussion-name {
display: block;
font-size: 14px;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.discussion-handle {
display: block;
font-size: 12px;
color: #666;
}
.discussion-date {
font-size: 12px;
color: #888;
white-space: nowrap;
}
.discussion-text {
font-size: 14px;
color: #444;
line-height: 1.5;
word-break: break-word;
}
/* Language Selector */
.post-lang-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.lang-selector {
position: relative;
}
.lang-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
background: #e8e8e8;
}
.lang-icon {
width: 20px;
height: 20px;
opacity: 0.7;
}
.lang-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 100px;
z-index: 100;
overflow: hidden;
}
.lang-dropdown.show {
display: block;
}
.lang-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.lang-option:hover {
background: #f5f5f5;
}
.lang-option.selected {
background: #f0f7ff;
}
.lang-name {
color: #333;
font-weight: 500;
}
.lang-check {
color: var(--btn-color);
font-size: 12px;
opacity: 0;
}
.lang-option.selected .lang-check {
opacity: 1;
}
.post-translation {
margin-top: 0;
}
.post-translation .post-title {
font-size: 24px;
margin-bottom: 16px;
}
/* Edit Form */ /* Edit Form */
.edit-form-container { .edit-form-container {
padding: 20px 0; padding: 20px 0;