add translate
This commit is contained in:
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
1
public/icon/language.svg
Normal file
1
public/icon/language.svg
Normal 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 |
@@ -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>
|
||||||
|
|||||||
105
src/web/components/discussion.ts
Normal file
105
src/web/components/discussion.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 `
|
||||||
|
|||||||
22
src/web/components/loading.ts
Normal file
22
src/web/components/loading.ts
Normal 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>'
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,25 +19,18 @@ 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-meta">
|
<div class="profile-info">
|
||||||
<span class="profile-name">${escapeHtml(displayName)}</span>
|
<h1 class="profile-name">${escapeHtml(displayName)}</h1>
|
||||||
<span class="profile-handle">${handleHtml}</span>
|
<p class="profile-handle">${handleHtml}</p>
|
||||||
|
${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${description ? `
|
|
||||||
<div class="profile-row">
|
|
||||||
<div class="profile-avatar-spacer"></div>
|
|
||||||
<p class="profile-description">${escapeHtml(description)}</p>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
146
src/web/main.ts
146
src/web/main.ts
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user