From 4da313aa68982eaacf1b9f52d39bec155968d60f Mon Sep 17 00:00:00 2001 From: syui Date: Sun, 18 Jan 2026 15:47:24 +0900 Subject: [PATCH] add translate --- public/{ => icon}/ai.svg | 0 public/icon/language.svg | 1 + src/web/components/browser.ts | 9 +- src/web/components/discussion.ts | 105 ++++++++ src/web/components/header.ts | 6 +- src/web/components/loading.ts | 22 ++ src/web/components/mode-tabs.ts | 7 +- src/web/components/posts.ts | 170 ++++++++++++- src/web/components/profile.ts | 21 +- src/web/lib/api.ts | 93 +++++++ src/web/lib/auth.ts | 49 ++++ src/web/lib/router.ts | 12 +- src/web/main.ts | 146 +++++++++-- src/web/styles/main.css | 413 ++++++++++++++++++++++++++++--- 14 files changed, 970 insertions(+), 84 deletions(-) rename public/{ => icon}/ai.svg (100%) create mode 100644 public/icon/language.svg create mode 100644 src/web/components/discussion.ts create mode 100644 src/web/components/loading.ts diff --git a/public/ai.svg b/public/icon/ai.svg similarity index 100% rename from public/ai.svg rename to public/icon/ai.svg diff --git a/public/icon/language.svg b/public/icon/language.svg new file mode 100644 index 0000000..89c6b9d --- /dev/null +++ b/public/icon/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/components/browser.ts b/src/web/components/browser.ts index b86a9f1..13de138 100644 --- a/src/web/components/browser.ts +++ b/src/web/components/browser.ts @@ -167,14 +167,21 @@ export function renderRecordList( // Render single record detail export function renderRecordDetail( record: { uri: string; cid: string; value: unknown }, - collection: string + collection: string, + isOwner: boolean = false ): string { + const rkey = record.uri.split('/').pop() || '' + const deleteBtn = isOwner ? ` + + ` : '' + return `

${collection}

URI: ${record.uri}

CID: ${record.cid}

+ ${deleteBtn}
${escapeHtml(JSON.stringify(record.value, null, 2))}
diff --git a/src/web/components/discussion.ts b/src/web/components/discussion.ts new file mode 100644 index 0000000..9b553f5 --- /dev/null +++ b/src/web/components/discussion.ts @@ -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, '"') +} + +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 ` + + ` +} + +export async function loadDiscussionPosts(container: HTMLElement, postUrl: string, appUrl: string = 'https://bsky.app'): Promise { + const postsContainer = container.querySelector('#discussion-posts') as HTMLElement + if (!postsContainer) return + + const dataAppUrl = postsContainer.dataset.appUrl || appUrl + + postsContainer.innerHTML = '
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 = '
' + container.appendChild(overlay) +} + +export function hideLoading(container: HTMLElement): void { + const overlay = container.querySelector('.loading-overlay') + if (overlay) { + overlay.remove() + } +} + +export function renderLoadingSmall(): string { + return '
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 = ` Blog Browser ` + if (isLoggedIn()) { + tabs += `Post` + } + tabs += `
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 ? `` : '' + + // 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 += ` + + ` + } + + langSelector = ` +
+ +
+ ${options.join('')} +
+
+ ` + } + + const editForm = isOwner ? ` + + ` : '' + return ` -
+
-

${escapeHtml(post.value.title)}

+

${escapeHtml(post.value.title)}

-
${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 - ? `${displayName}` - : `
` + ? `${escapeHtml(displayName)}` + : `
` 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 += `` + } else if (route.type === 'postpage') { + // Post form page + html += `
${renderPostForm(config.collection)}
` + html += `` + } 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;