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/public/syui.svg b/public/syui.svg new file mode 100644 index 0000000..5813502 --- /dev/null +++ b/public/syui.svg @@ -0,0 +1,67 @@ + + + + +syui + + + + + + + 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..94c3d63 100644 --- a/src/web/components/mode-tabs.ts +++ b/src/web/components/mode-tabs.ts @@ -1,6 +1,8 @@ import { getNetworks } from '../lib/api' +import { isLoggedIn } from '../lib/auth' let currentNetwork = 'bsky.social' +let currentLang = localStorage.getItem('preferred-lang') || 'en' export function getCurrentNetwork(): string { return currentNetwork @@ -10,15 +12,29 @@ export function setCurrentNetwork(network: string): void { currentNetwork = network } -export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' = 'blog'): string { +export function getCurrentLang(): string { + return currentLang +} + +export function setCurrentLang(lang: string): void { + currentLang = lang + localStorage.setItem('preferred-lang', lang) +} + +export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string { let tabs = ` - Blog - Browser + / + ${handle} + at ` + if (isLoggedIn()) { + tabs += `post` + } + tabs += `
- +
` @@ -26,55 +42,114 @@ export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' = ' return `
${tabs}
` } -export async function setupModeTabs(onNetworkChange: (network: string) => void): Promise { +// Render language selector (above content) +export function renderLangSelector(langs: string[]): string { + if (langs.length < 2) return '' + + return ` +
+ +
+
+ ` +} + +export async function setupModeTabs(onNetworkChange: (network: string) => void, availableLangs?: string[], onLangChange?: (lang: string) => void): Promise { const pdsTab = document.getElementById('pds-tab') - const dropdown = document.getElementById('pds-dropdown') + const pdsDropdown = document.getElementById('pds-dropdown') - if (!pdsTab || !dropdown) return + if (pdsTab && pdsDropdown) { + // Load networks + const networks = await getNetworks() - // Load networks - const networks = await getNetworks() + // Build options + const optionsHtml = Object.keys(networks).map(key => { + const isSelected = key === currentNetwork + return ` +
+ ${key} + +
+ ` + }).join('') - // Build options - const optionsHtml = Object.keys(networks).map(key => { - const isSelected = key === currentNetwork - return ` -
- ${key} - -
- ` - }).join('') + pdsDropdown.innerHTML = optionsHtml - dropdown.innerHTML = optionsHtml - - // Toggle dropdown - pdsTab.addEventListener('click', (e) => { - e.stopPropagation() - dropdown.classList.toggle('show') - }) - - // Close on outside click - document.addEventListener('click', () => { - dropdown.classList.remove('show') - }) - - // Handle option selection - dropdown.querySelectorAll('.pds-option').forEach(opt => { - opt.addEventListener('click', (e) => { + // Toggle dropdown + pdsTab.addEventListener('click', (e) => { e.stopPropagation() - const network = (opt as HTMLElement).dataset.network || '' - - currentNetwork = network - - // Update UI - dropdown.querySelectorAll('.pds-option').forEach(o => { - o.classList.remove('selected') - }) - opt.classList.add('selected') - dropdown.classList.remove('show') - - onNetworkChange(network) + pdsDropdown.classList.toggle('show') }) + + // Handle option selection + pdsDropdown.querySelectorAll('.pds-option').forEach(opt => { + opt.addEventListener('click', (e) => { + e.stopPropagation() + const network = (opt as HTMLElement).dataset.network || '' + + currentNetwork = network + + // Update UI + pdsDropdown.querySelectorAll('.pds-option').forEach(o => { + o.classList.remove('selected') + }) + opt.classList.add('selected') + pdsDropdown.classList.remove('show') + + onNetworkChange(network) + }) + }) + } + + // Setup language selector + const langTab = document.getElementById('lang-tab') + const langDropdown = document.getElementById('lang-dropdown') + + if (langTab && langDropdown && availableLangs && availableLangs.length > 0) { + // Build language options + const langOptionsHtml = availableLangs.map(lang => { + const isSelected = lang === currentLang + return ` +
+ ${lang.toUpperCase()} + +
+ ` + }).join('') + + langDropdown.innerHTML = langOptionsHtml + + // Toggle dropdown + langTab.addEventListener('click', (e) => { + e.stopPropagation() + langDropdown.classList.toggle('show') + }) + + // Handle option selection + langDropdown.querySelectorAll('.lang-option').forEach(opt => { + opt.addEventListener('click', (e) => { + e.stopPropagation() + const lang = (opt as HTMLElement).dataset.lang || '' + + setCurrentLang(lang) + + // Update UI + langDropdown.querySelectorAll('.lang-option').forEach(o => { + o.classList.remove('selected') + }) + opt.classList.add('selected') + langDropdown.classList.remove('show') + + if (onLangChange) onLangChange(lang) + }) + }) + } + + // Close dropdowns on outside click + document.addEventListener('click', () => { + pdsDropdown?.classList.remove('show') + langDropdown?.classList.remove('show') }) } diff --git a/src/web/components/posts.ts b/src/web/components/posts.ts index 1134928..3135143 100644 --- a/src/web/components/posts.ts +++ b/src/web/components/posts.ts @@ -1,5 +1,7 @@ import type { Post } from '../types' import { renderMarkdown } from '../lib/markdown' +import { renderDiscussion, loadDiscussionPosts } from './discussion' +import { getCurrentLang } from './mode-tabs' // Render post list export function renderPostList(posts: Post[], handle: string): string { @@ -7,14 +9,24 @@ export function renderPostList(posts: Post[], handle: string): string { return '

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 ` @@ -25,26 +37,83 @@ 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 date = new Date(post.value.createdAt).toLocaleDateString('en-US') 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 ? `` : '' + + // Get current language and show appropriate content + const currentLang = getCurrentLang() + const translations = post.value.translations + const originalLang = post.value.lang || 'ja' + + let displayTitle = post.value.title + let displayContent = post.value.content + + // Use translation if available and not original language + if (translations && currentLang !== originalLang && translations[currentLang]) { + const trans = translations[currentLang] + displayTitle = trans.title || post.value.title + displayContent = trans.content + } + + const content = renderMarkdown(displayContent) + + const editForm = isOwner ? ` + + ` : '' + return ` -
+
-

${escapeHtml(post.value.title)}

-
${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 - ? `${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..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 += `` + } 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)}
` - } + // 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;