No posts yet.
'
}
+ const currentLang = getCurrentLang()
+
const items = posts.map(post => {
const rkey = post.uri.split('/').pop() || ''
- const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP')
+ const date = new Date(post.value.createdAt).toLocaleDateString('en-US')
+ const originalLang = post.value.lang || 'ja'
+ const translations = post.value.translations
+
+ // Use translation if available
+ let displayTitle = post.value.title
+ if (translations && currentLang !== originalLang && translations[currentLang]) {
+ displayTitle = translations[currentLang].title || post.value.title
+ }
return `
+
- ${content}
+ ${editForm}
+
+
${escapeHtml(displayTitle)}
+
${content}
+
+ ${renderDiscussion(postUrl, appUrl)}
`
}
+// Setup post detail interactions (discussion loading)
+export function setupPostDetail(container: HTMLElement): void {
+ const article = container.querySelector('.post-detail') as HTMLElement
+ if (!article) return
+
+ // 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 {
container.innerHTML = html
}
diff --git a/src/web/components/profile.ts b/src/web/components/profile.ts
index 54c1ef1..ca6c577 100644
--- a/src/web/components/profile.ts
+++ b/src/web/components/profile.ts
@@ -19,24 +19,17 @@ export async function renderProfile(
: `@${escapeHtml(handle)} `
const avatarHtml = avatarUrl
- ? ` `
- : `
`
+ ? ` `
+ : `
`
return `
-
- ${avatarHtml}
-
- ${escapeHtml(displayName)}
- ${handleHtml}
-
+ ${avatarHtml}
+
+
${escapeHtml(displayName)}
+
${handleHtml}
+ ${description ? `
${escapeHtml(description)}
` : ''}
- ${description ? `
-
-
-
${escapeHtml(description)}
-
- ` : ''}
`
}
diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts
index 8181cbb..0eec51e 100644
--- a/src/web/lib/api.ts
+++ b/src/web/lib/api.ts
@@ -242,3 +242,96 @@ export async function getRecord(did: string, collection: string, rkey: string):
}
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
{
+ // 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()
+
+ 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
+}
diff --git a/src/web/lib/auth.ts b/src/web/lib/auth.ts
index 44bff95..27aec8b 100644
--- a/src/web/lib/auth.ts
+++ b/src/web/lib/auth.ts
@@ -242,3 +242,52 @@ export async function createPost(
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 {
+ 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
+ }
+}
diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts
index e61b8b3..61dd689 100644
--- a/src/web/lib/router.ts
+++ b/src/web/lib/router.ts
@@ -1,5 +1,5 @@
export interface Route {
- type: 'home' | 'user' | 'post' | 'atbrowser' | 'service' | 'collection' | 'record'
+ type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
handle?: string
rkey?: string
service?: string
@@ -45,7 +45,13 @@ export function parseRoute(): Route {
return { type: 'user', handle: userMatch[1] }
}
- // Post page: /@handle/rkey (for config.collection)
+ // Post form page: /@handle/at/post
+ const postPageMatch = path.match(/^\/@([^/]+)\/at\/post\/?$/)
+ if (postPageMatch) {
+ return { type: 'postpage', handle: postPageMatch[1] }
+ }
+
+ // Post detail page: /@handle/rkey (for config.collection)
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
if (postMatch) {
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) {
path = `/@${route.handle}`
+ } else if (route.type === 'postpage' && route.handle) {
+ path = `/@${route.handle}/at/post`
} else if (route.type === 'post' && route.handle && route.rkey) {
path = `/@${route.handle}/${route.rkey}`
} else if (route.type === 'atbrowser' && route.handle) {
diff --git a/src/web/main.ts b/src/web/main.ts
index 05a55c9..127268d 100644
--- a/src/web/main.ts
+++ b/src/web/main.ts
@@ -1,14 +1,15 @@
import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
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 { renderProfile } from './components/profile'
-import { renderPostList, renderPostDetail } from './components/posts'
+import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
import { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
-import { renderModeTabs, setupModeTabs } from './components/mode-tabs'
+import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer'
+import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')!
@@ -51,6 +52,8 @@ async function getWebUrl(handle: string): Promise {
}
async function render(route: Route): Promise {
+ showLoading(app)
+
try {
const config = await getConfig()
@@ -112,11 +115,30 @@ async function render(route: Route): Promise {
const profile = await getProfile(did, localFirst)
const webUrl = await getWebUrl(handle)
+ // Load posts to check for translations
+ const posts = await getPosts(did, config.collection, localFirst)
+
+ // Collect available languages from posts
+ const availableLangs = new Set()
+ for (const post of posts) {
+ // Add original language (default: ja for Japanese posts)
+ const postLang = post.value.lang || 'ja'
+ availableLangs.add(postLang)
+ // Add translation languages
+ if (post.value.translations) {
+ for (const lang of Object.keys(post.value.translations)) {
+ availableLangs.add(lang)
+ }
+ }
+ }
+ const langList = Array.from(availableLangs)
+
// Build page
let html = renderHeader(handle)
- // Mode tabs (Blog/Browser/PDS)
- const activeTab = route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog'
+ // Mode tabs (Blog/Browser/Post/PDS)
+ const activeTab = route.type === 'postpage' ? 'post' :
+ (route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab)
// Profile section
@@ -124,12 +146,16 @@ async function render(route: Route): Promise {
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
if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view
const record = await getRecord(did, route.collection, route.rkey)
if (record) {
- html += `${renderRecordDetail(record, route.collection)}
`
+ html += `${renderRecordDetail(record, route.collection, isOwner)}
`
} else {
html += `Record not found
`
}
@@ -164,49 +190,72 @@ async function render(route: Route): Promise {
} else if (route.type === 'post' && route.rkey) {
// Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst)
+ html += renderLangSelector(langList)
if (post) {
- html += `${renderPostDetail(post, handle, config.collection)}
`
+ html += `${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}
`
} else {
html += `Post not found
`
}
html += `${handle} `
+ } else if (route.type === 'postpage') {
+ // Post form page
+ html += `${renderPostForm(config.collection)}
`
+ html += `${handle} `
+
} else {
// User page: compact collection buttons + posts
const collections = await describeRepo(did)
html += `${renderCollectionButtons(collections, handle)}
`
- // Show post form if logged-in user is viewing their own page
- const loggedInHandle = getLoggedInHandle()
- const isOwnPage = isLoggedIn() && loggedInHandle === handle
- if (isOwnPage) {
- html += `${renderPostForm(config.collection)}
`
- }
+ // Language selector above content
+ html += renderLangSelector(langList)
- const posts = await getPosts(did, config.collection, localFirst)
+ // Use pre-loaded posts
html += `${renderPostList(posts, handle)}
`
}
html += renderFooter(handle)
app.innerHTML = html
+ hideLoading(app)
setupEventHandlers()
- // Setup mode tabs (PDS selector)
- await setupModeTabs((_network) => {
- // Refresh when network is changed
- render(parseRoute())
- })
-
- // Setup post form if it exists
- const loggedInHandle = getLoggedInHandle()
- if (isLoggedIn() && loggedInHandle === handle) {
- setupPostForm(config.collection, () => {
- // Refresh on success
+ // Setup mode tabs (PDS selector + Lang selector)
+ await setupModeTabs(
+ (_network) => {
+ // Refresh when network is changed
render(parseRoute())
+ },
+ langList,
+ (_lang) => {
+ // Refresh when language is changed
+ render(parseRoute())
+ }
+ )
+
+ // Setup post form on postpage
+ if (route.type === 'postpage' && isLoggedIn()) {
+ setupPostForm(config.collection, () => {
+ // Navigate to user page on success
+ 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) {
console.error('Render error:', error)
app.innerHTML = `
@@ -214,6 +263,7 @@ async function render(route: Route): Promise {
Error: ${error}
${renderFooter(currentHandle)}
`
+ hideLoading(app)
setupEventHandlers()
}
}
@@ -254,6 +304,92 @@ 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 postDisplay = document.getElementById('post-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 (postDisplay) postDisplay.style.display = 'none'
+ editForm.style.display = 'block'
+ editBtn.style.display = 'none'
+ })
+
+ // Cancel edit
+ cancelBtn?.addEventListener('click', () => {
+ editForm.style.display = 'none'
+ if (postDisplay) postDisplay.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
render(parseRoute())
diff --git a/src/web/styles/main.css b/src/web/styles/main.css
index cf209aa..2c9f19e 100644
--- a/src/web/styles/main.css
+++ b/src/web/styles/main.css
@@ -19,6 +19,41 @@ body {
max-width: 800px;
margin: 0 auto;
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 */
@@ -147,9 +182,13 @@ body {
}
.header-btn.user-btn {
+ width: auto;
+ padding: 8px 12px;
background: var(--btn-color);
color: #fff;
border-color: var(--btn-color);
+ font-size: 13px;
+ font-weight: 500;
}
/* Post Form */
@@ -322,79 +361,59 @@ body {
color: #fff;
}
-/* Profile - SNS style */
+/* Profile */
.profile {
- padding: 16px;
- background: #fff;
- border: 1px solid #e0e0e0;
- border-radius: 12px;
- margin: 32px 0;
-}
-
-.profile-row {
display: flex;
- align-items: flex-start;
- gap: 12px;
-}
-
-.profile-row + .profile-row {
- margin-top: 8px;
+ gap: 16px;
+ padding: 20px;
+ background: #f5f5f5;
+ border-radius: 12px;
+ margin-bottom: 24px;
}
.profile-avatar {
- width: 48px;
- height: 48px;
+ width: 80px;
+ height: 80px;
border-radius: 50%;
object-fit: cover;
- flex-shrink: 0;
}
.profile-avatar-placeholder {
- width: 48px;
- height: 48px;
+ width: 80px;
+ height: 80px;
border-radius: 50%;
background: #e0e0e0;
- flex-shrink: 0;
}
-.profile-avatar-spacer {
- width: 48px;
- flex-shrink: 0;
-}
-
-.profile-meta {
- display: flex;
- align-items: baseline;
- gap: 8px;
- flex-wrap: wrap;
- padding-top: 4px;
+.profile-info {
+ flex: 1;
}
.profile-name {
- font-size: 15px;
- font-weight: 700;
- color: #0f1419;
+ font-size: 20px;
+ font-weight: 600;
+ margin-bottom: 4px;
}
.profile-handle {
font-size: 14px;
- color: #536471;
+ color: #666;
+ margin-bottom: 8px;
}
.profile-handle-link {
- color: #536471;
+ color: #666;
text-decoration: none;
}
.profile-handle-link:hover {
+ color: var(--btn-color);
text-decoration: underline;
}
-.profile-description {
+.profile-desc {
font-size: 14px;
- color: #0f1419;
- line-height: 1.5;
- margin: 0;
+ color: #444;
}
/* Services */
@@ -592,6 +611,120 @@ body {
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 {
display: inline-flex;
align-items: center;
@@ -611,6 +744,110 @@ body {
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;
+}
+
+
/* Edit Form */
.edit-form-container {
padding: 20px 0;
@@ -1103,8 +1340,11 @@ body {
margin: 4px 0;
}
-/* Language Selector */
+/* Language Selector (above content) */
.lang-selector {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 8px;
position: relative;
}
@@ -1112,18 +1352,26 @@ body {
display: flex;
align-items: center;
justify-content: center;
- width: 36px;
- height: 36px;
- background: #f0f0f0;
- border: 1px solid #ddd;
+ background: transparent;
+ border: none;
border-radius: 6px;
cursor: pointer;
color: #666;
- font-size: 18px;
+ padding: 8px 16px;
}
.lang-btn:hover {
- background: #e0e0e0;
+ background: #f0f0f0;
+}
+
+.lang-icon {
+ width: 20px;
+ height: 20px;
+ opacity: 0.6;
+}
+
+.lang-btn:hover .lang-icon {
+ opacity: 0.9;
}
.lang-dropdown {
@@ -1136,7 +1384,7 @@ body {
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- min-width: 140px;
+ min-width: 100px;
z-index: 100;
overflow: hidden;
}
@@ -1200,12 +1448,14 @@ body {
@media (prefers-color-scheme: dark) {
.lang-btn {
- background: #2a2a2a;
- border-color: #333;
+ background: transparent;
color: #888;
}
.lang-btn:hover {
- background: #333;
+ background: #2a2a2a;
+ }
+ .lang-icon {
+ filter: invert(0.7);
}
.lang-dropdown {
background: #1a1a1a;