Loading comments...
'
+
+ 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 `
+
+
+ ${avatar ? `
` : '
'}
+
+ ${escapeHtml(displayName)}
+ @${escapeHtml(handle)}
+
+
${formatDate(createdAt)}
+
+ ${escapeHtml(truncatedText)}
+
+ `
+ }).join('')
+
+ postsContainer.innerHTML = postsHtml
+}
diff --git a/src/web/components/header.ts b/src/web/components/header.ts
index daf327e..3a90dc0 100644
--- a/src/web/components/header.ts
+++ b/src/web/components/header.ts
@@ -1,11 +1,11 @@
-import { isLoggedIn, getLoggedInDid } from '../lib/auth'
+import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
export function renderHeader(currentHandle: string): string {
const loggedIn = isLoggedIn()
- const did = getLoggedInDid()
+ const handle = getLoggedInHandle()
const loginBtn = loggedIn
- ? ``
+ ? ``
: ``
return `
diff --git a/src/web/components/loading.ts b/src/web/components/loading.ts
new file mode 100644
index 0000000..fbdb40f
--- /dev/null
+++ b/src/web/components/loading.ts
@@ -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 = 'Loading...
'
+}
diff --git a/src/web/components/mode-tabs.ts b/src/web/components/mode-tabs.ts
index 897846c..2fca206 100644
--- a/src/web/components/mode-tabs.ts
+++ b/src/web/components/mode-tabs.ts
@@ -1,4 +1,5 @@
import { getNetworks } from '../lib/api'
+import { isLoggedIn } from '../lib/auth'
let currentNetwork = 'bsky.social'
@@ -10,12 +11,16 @@ export function setCurrentNetwork(network: string): void {
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 = `
PDS
diff --git a/src/web/components/posts.ts b/src/web/components/posts.ts
index 1134928..6591ee5 100644
--- a/src/web/components/posts.ts
+++ b/src/web/components/posts.ts
@@ -1,5 +1,6 @@
import type { Post } from '../types'
import { renderMarkdown } from '../lib/markdown'
+import { renderDiscussion, loadDiscussionPosts } from './discussion'
// Render post list
export function renderPostList(posts: Post[], handle: string): string {
@@ -25,26 +26,187 @@ export function renderPostList(posts: Post[], handle: string): string {
}
// 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 date = new Date(post.value.createdAt).toLocaleDateString('ja-JP')
const content = renderMarkdown(post.value.content)
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 ? `
Edit ` : ''
+
+ // 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 = [`
+
+ ${originalLang.toUpperCase()}
+ ✓
+
+ `]
+
+ for (const lang of langs) {
+ const trans = translations[lang]
+ const transContent = renderMarkdown(trans.content)
+ const transTitle = trans.title || post.value.title
+
+ options.push(`
+
+ ${lang.toUpperCase()}
+ ✓
+
+ `)
+
+ translationContents += `
+
+
${escapeHtml(transTitle)}
+
${transContent}
+
+ `
+ }
+
+ langSelector = `
+
+
+
+
+
+ ${options.join('')}
+
+
+ `
+ }
+
+ const editForm = isOwner ? `
+
+
+
+
+ Cancel
+ Save
+
+
+ ` : ''
+
return `
-
+
- ${content}
+ ${langSelector ? `${langSelector}
` : ''}
+ ${content}
+ ${translationContents}
+ ${editForm}
+ ${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 {
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..61da138 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/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(/^\/@([^/]+)\/([^/]+)$/)
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}/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..c7eddc2 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 { 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()
@@ -115,8 +118,9 @@ async function render(route: Route): Promise {
// 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 +128,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
`
}
@@ -165,24 +173,22 @@ async function render(route: Route): Promise {
// Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst)
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)}
`
- }
-
const posts = await getPosts(did, config.collection, localFirst)
html += `${renderPostList(posts, handle)}
`
}
@@ -190,6 +196,7 @@ async function render(route: Route): Promise {
html += renderFooter(handle)
app.innerHTML = html
+ hideLoading(app)
setupEventHandlers()
// Setup mode tabs (PDS selector)
@@ -198,15 +205,28 @@ async function render(route: Route): Promise {
render(parseRoute())
})
- // Setup post form if it exists
- const loggedInHandle = getLoggedInHandle()
- if (isLoggedIn() && loggedInHandle === handle) {
+ // Setup post form on postpage
+ if (route.type === 'postpage' && isLoggedIn()) {
setupPostForm(config.collection, () => {
- // Refresh on success
- render(parseRoute())
+ // 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 +234,7 @@ async function render(route: Route): Promise {
Error: ${error}
${renderFooter(currentHandle)}
`
+ hideLoading(app)
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
render(parseRoute())
diff --git a/src/web/styles/main.css b/src/web/styles/main.css
index cf209aa..248b2a3 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,204 @@ 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;
+}
+
+/* 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-container {
padding: 20px 0;