add translate
This commit is contained in:
@@ -25,8 +25,18 @@ function groupCollectionsByService(collections: string[]): Map<string, string[]>
|
||||
return groups
|
||||
}
|
||||
|
||||
// Local favicon mappings
|
||||
const localFavicons: Record<string, string> = {
|
||||
'syui.ai': '/favicon/syui.ai.png',
|
||||
'bsky.app': '/favicon/bsky.app.png',
|
||||
'atproto.com': '/favicon/atproto.com.png',
|
||||
}
|
||||
|
||||
// Get favicon URL for service
|
||||
function getFaviconUrl(service: string): string {
|
||||
if (localFavicons[service]) {
|
||||
return localFavicons[service]
|
||||
}
|
||||
return `https://www.google.com/s2/favicons?domain=${service}&sz=32`
|
||||
}
|
||||
|
||||
@@ -167,14 +177,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 ? `
|
||||
<button type="button" class="record-delete-btn" id="record-delete-btn" data-collection="${collection}" data-rkey="${rkey}">Delete</button>
|
||||
` : ''
|
||||
|
||||
return `
|
||||
<article class="record-detail">
|
||||
<header class="record-header">
|
||||
<h3>${collection}</h3>
|
||||
<p class="record-uri">URI: ${record.uri}</p>
|
||||
<p class="record-cid">CID: ${record.cid}</p>
|
||||
${deleteBtn}
|
||||
</header>
|
||||
<div class="json-view">
|
||||
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>
|
||||
|
||||
105
src/web/components/discussion.ts
Normal file
105
src/web/components/discussion.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { searchPostsForUrl, type SearchPost } from '../lib/api'
|
||||
|
||||
const DISCUSSION_POST_LIMIT = 10
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('ja-JP', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function getPostUrl(uri: string, appUrl: string): string {
|
||||
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
|
||||
const parts = uri.replace('at://', '').split('/')
|
||||
if (parts.length >= 3) {
|
||||
return `${appUrl}/profile/${parts[0]}/post/${parts[2]}`
|
||||
}
|
||||
return '#'
|
||||
}
|
||||
|
||||
export function renderDiscussion(postUrl: string, appUrl: string = 'https://bsky.app'): string {
|
||||
// Build search URL (truncate for search limit)
|
||||
let searchQuery = postUrl
|
||||
try {
|
||||
const urlObj = new URL(postUrl)
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean)
|
||||
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
|
||||
const rkey = pathParts[1] || ''
|
||||
const remainingLength = 20 - basePath.length
|
||||
const rkeyPrefix = remainingLength > 0 ? rkey.slice(0, remainingLength) : ''
|
||||
searchQuery = basePath + rkeyPrefix
|
||||
} catch {
|
||||
// Keep original
|
||||
}
|
||||
|
||||
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
||||
|
||||
return `
|
||||
<div class="discussion-section">
|
||||
<a href="${searchUrl}" target="_blank" rel="noopener" class="discussion-link">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2.546 20.2A1.5 1.5 0 0 0 4 22h.5l2.83-.892A9.96 9.96 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2z"/>
|
||||
</svg>
|
||||
Discuss on Bluesky
|
||||
</a>
|
||||
<div id="discussion-posts" class="discussion-posts" data-app-url="${escapeHtml(appUrl)}"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export async function loadDiscussionPosts(container: HTMLElement, postUrl: string, appUrl: string = 'https://bsky.app'): Promise<void> {
|
||||
const postsContainer = container.querySelector('#discussion-posts') as HTMLElement
|
||||
if (!postsContainer) return
|
||||
|
||||
const dataAppUrl = postsContainer.dataset.appUrl || appUrl
|
||||
|
||||
postsContainer.innerHTML = '<div class="loading-small">Loading comments...</div>'
|
||||
|
||||
const posts = await searchPostsForUrl(postUrl)
|
||||
|
||||
if (posts.length === 0) {
|
||||
postsContainer.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map((post: SearchPost) => {
|
||||
const author = post.author
|
||||
const avatar = author.avatar || ''
|
||||
const displayName = author.displayName || author.handle
|
||||
const handle = author.handle
|
||||
const record = post.record as { text?: string; createdAt?: string }
|
||||
const text = record?.text || ''
|
||||
const createdAt = record?.createdAt || ''
|
||||
const postLink = getPostUrl(post.uri, dataAppUrl)
|
||||
|
||||
// Truncate text
|
||||
const truncatedText = text.length > 200 ? text.slice(0, 200) + '...' : text
|
||||
|
||||
return `
|
||||
<a href="${postLink}" target="_blank" rel="noopener" class="discussion-post">
|
||||
<div class="discussion-author">
|
||||
${avatar ? `<img src="${escapeHtml(avatar)}" class="discussion-avatar" alt="">` : '<div class="discussion-avatar-placeholder"></div>'}
|
||||
<div class="discussion-author-info">
|
||||
<span class="discussion-name">${escapeHtml(displayName)}</span>
|
||||
<span class="discussion-handle">@${escapeHtml(handle)}</span>
|
||||
</div>
|
||||
<span class="discussion-date">${formatDate(createdAt)}</span>
|
||||
</div>
|
||||
<div class="discussion-text">${escapeHtml(truncatedText)}</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
postsContainer.innerHTML = postsHtml
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
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
|
||||
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout (${did?.slice(0, 20)}...)">✓</button>`
|
||||
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login">↗</button>`
|
||||
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout">${handle || 'logout'}</button>`
|
||||
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login"><img src="/icon/user.svg" alt="Login" class="login-icon"></button>`
|
||||
|
||||
return `
|
||||
<header id="header">
|
||||
|
||||
22
src/web/components/loading.ts
Normal file
22
src/web/components/loading.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Loading indicator component
|
||||
|
||||
export function showLoading(container: HTMLElement): void {
|
||||
const existing = container.querySelector('.loading-overlay')
|
||||
if (existing) return
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'loading-overlay'
|
||||
overlay.innerHTML = '<div class="loading-spinner"></div>'
|
||||
container.appendChild(overlay)
|
||||
}
|
||||
|
||||
export function hideLoading(container: HTMLElement): void {
|
||||
const overlay = container.querySelector('.loading-overlay')
|
||||
if (overlay) {
|
||||
overlay.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export function renderLoadingSmall(): string {
|
||||
return '<div class="loading-small">Loading...</div>'
|
||||
}
|
||||
@@ -1,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 = `
|
||||
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">Blog</a>
|
||||
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">Browser</a>
|
||||
<a href="/" class="tab">/</a>
|
||||
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
|
||||
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</a>
|
||||
`
|
||||
|
||||
if (isLoggedIn()) {
|
||||
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
|
||||
}
|
||||
|
||||
tabs += `
|
||||
<div class="pds-selector" id="pds-selector">
|
||||
<button type="button" class="tab" id="pds-tab">PDS</button>
|
||||
<button type="button" class="tab" id="pds-tab">pds</button>
|
||||
<div class="pds-dropdown" id="pds-dropdown"></div>
|
||||
</div>
|
||||
`
|
||||
@@ -26,55 +42,114 @@ export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' = '
|
||||
return `<div class="mode-tabs">${tabs}</div>`
|
||||
}
|
||||
|
||||
export async function setupModeTabs(onNetworkChange: (network: string) => void): Promise<void> {
|
||||
// Render language selector (above content)
|
||||
export function renderLangSelector(langs: string[]): string {
|
||||
if (langs.length < 2) return ''
|
||||
|
||||
return `
|
||||
<div class="lang-selector" id="lang-selector">
|
||||
<button type="button" class="lang-btn" id="lang-tab">
|
||||
<img src="/icon/language.svg" alt="Lang" class="lang-icon">
|
||||
</button>
|
||||
<div class="lang-dropdown" id="lang-dropdown"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export async function setupModeTabs(onNetworkChange: (network: string) => void, availableLangs?: string[], onLangChange?: (lang: string) => void): Promise<void> {
|
||||
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 `
|
||||
<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${key}">
|
||||
<span class="pds-name">${key}</span>
|
||||
<span class="pds-check">✓</span>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
// Build options
|
||||
const optionsHtml = Object.keys(networks).map(key => {
|
||||
const isSelected = key === currentNetwork
|
||||
return `
|
||||
<div class="pds-option ${isSelected ? 'selected' : ''}" data-network="${key}">
|
||||
<span class="pds-name">${key}</span>
|
||||
<span class="pds-check">✓</span>
|
||||
</div>
|
||||
`
|
||||
}).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 `
|
||||
<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang}">
|
||||
<span class="lang-name">${lang.toUpperCase()}</span>
|
||||
<span class="lang-check">✓</span>
|
||||
</div>
|
||||
`
|
||||
}).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')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 '<p class="no-posts">No posts yet.</p>'
|
||||
}
|
||||
|
||||
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 `
|
||||
<article class="post-item">
|
||||
<a href="/@${handle}/${rkey}" class="post-link">
|
||||
<h2 class="post-title">${escapeHtml(post.value.title)}</h2>
|
||||
<h2 class="post-title">${escapeHtml(displayTitle)}</h2>
|
||||
<time class="post-date">${date}</time>
|
||||
</a>
|
||||
</article>
|
||||
@@ -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 ? `<button type="button" class="post-edit-btn" id="post-edit-btn">Edit</button>` : ''
|
||||
|
||||
// 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 ? `
|
||||
<div class="post-edit-form" id="post-edit-form" style="display: none;">
|
||||
<input type="text" class="post-edit-title" id="post-edit-title" value="${escapeHtml(post.value.title)}" placeholder="Title">
|
||||
<textarea class="post-edit-content" id="post-edit-content" rows="15">${escapeHtml(post.value.content)}</textarea>
|
||||
<div class="post-edit-actions">
|
||||
<button type="button" class="post-edit-cancel" id="post-edit-cancel">Cancel</button>
|
||||
<button type="button" class="post-edit-save" id="post-edit-save" data-collection="${collection}" data-rkey="${rkey}">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''
|
||||
|
||||
return `
|
||||
<article class="post-detail">
|
||||
<article class="post-detail" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">${escapeHtml(post.value.title)}</h1>
|
||||
<div class="post-meta">
|
||||
<time class="post-date">${date}</time>
|
||||
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||
${editBtn}
|
||||
</div>
|
||||
</header>
|
||||
<div class="post-content">${content}</div>
|
||||
${editForm}
|
||||
<div id="post-display">
|
||||
<h1 class="post-title">${escapeHtml(displayTitle)}</h1>
|
||||
<div class="post-content">${content}</div>
|
||||
</div>
|
||||
</article>
|
||||
${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
|
||||
}
|
||||
|
||||
@@ -19,24 +19,17 @@ export async function renderProfile(
|
||||
: `<span>@${escapeHtml(handle)}</span>`
|
||||
|
||||
const avatarHtml = avatarUrl
|
||||
? `<a href="/"><img class="profile-avatar" src="${avatarUrl}" alt="${displayName}"></a>`
|
||||
: `<a href="/"><div class="profile-avatar-placeholder"></div></a>`
|
||||
? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
|
||||
: `<div class="profile-avatar-placeholder"></div>`
|
||||
|
||||
return `
|
||||
<div class="profile">
|
||||
<div class="profile-row">
|
||||
${avatarHtml}
|
||||
<div class="profile-meta">
|
||||
<span class="profile-name">${escapeHtml(displayName)}</span>
|
||||
<span class="profile-handle">${handleHtml}</span>
|
||||
</div>
|
||||
${avatarHtml}
|
||||
<div class="profile-info">
|
||||
<h1 class="profile-name">${escapeHtml(displayName)}</h1>
|
||||
<p class="profile-handle">${handleHtml}</p>
|
||||
${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
|
||||
</div>
|
||||
${description ? `
|
||||
<div class="profile-row">
|
||||
<div class="profile-avatar-spacer"></div>
|
||||
<p class="profile-description">${escapeHtml(description)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -242,3 +242,96 @@ export async function getRecord(did: string, collection: string, rkey: string):
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Constants for search
|
||||
const SEARCH_TIMEOUT_MS = 5000
|
||||
const MAX_SEARCH_LENGTH = 20
|
||||
|
||||
// Search posts that link to a URL
|
||||
export async function searchPostsForUrl(url: string): Promise<SearchPost[]> {
|
||||
// Use public.api.bsky.app for search
|
||||
const endpoint = 'https://public.api.bsky.app'
|
||||
|
||||
// Extract search-friendly patterns from URL
|
||||
const searchQueries: string[] = []
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathWithDomain = urlObj.host + urlObj.pathname.replace(/\/$/, '')
|
||||
|
||||
// Limit length for search
|
||||
if (pathWithDomain.length <= MAX_SEARCH_LENGTH) {
|
||||
searchQueries.push(pathWithDomain)
|
||||
} else {
|
||||
// Truncate to max length
|
||||
searchQueries.push(pathWithDomain.slice(0, MAX_SEARCH_LENGTH))
|
||||
}
|
||||
|
||||
// Also try shorter path
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean)
|
||||
if (pathParts.length >= 1) {
|
||||
const shortPath = urlObj.host + '/' + pathParts[0]
|
||||
if (shortPath.length <= MAX_SEARCH_LENGTH) {
|
||||
searchQueries.push(shortPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
searchQueries.push(url.slice(0, MAX_SEARCH_LENGTH))
|
||||
}
|
||||
|
||||
const allPosts: SearchPost[] = []
|
||||
const seenUris = new Set<string>()
|
||||
|
||||
for (const query of searchQueries) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
|
||||
|
||||
const res = await fetch(
|
||||
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!res.ok) continue
|
||||
|
||||
const data = await res.json()
|
||||
const posts = (data.posts || []).filter((post: SearchPost) => {
|
||||
const embedUri = (post.record as { embed?: { external?: { uri?: string } } })?.embed?.external?.uri
|
||||
const text = (post.record as { text?: string })?.text || ''
|
||||
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
for (const post of posts) {
|
||||
if (!seenUris.has(post.uri)) {
|
||||
seenUris.add(post.uri)
|
||||
allPosts.push(post)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Timeout or network error
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
allPosts.sort((a, b) => {
|
||||
const aDate = (a.record as { createdAt?: string })?.createdAt || ''
|
||||
const bDate = (b.record as { createdAt?: string })?.createdAt || ''
|
||||
return new Date(bDate).getTime() - new Date(aDate).getTime()
|
||||
})
|
||||
|
||||
return allPosts
|
||||
}
|
||||
|
||||
// Search post type
|
||||
export interface SearchPost {
|
||||
uri: string
|
||||
cid: string
|
||||
author: {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
record: unknown
|
||||
}
|
||||
|
||||
@@ -242,3 +242,52 @@ export async function createPost(
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Update post
|
||||
export async function updatePost(
|
||||
collection: string,
|
||||
rkey: string,
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<{ uri: string; cid: string } | null> {
|
||||
if (!agent) return null
|
||||
|
||||
try {
|
||||
const result = await agent.com.atproto.repo.putRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
record: {
|
||||
$type: collection,
|
||||
title,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
return { uri: result.data.uri, cid: result.data.cid }
|
||||
} catch (err) {
|
||||
console.error('Update post error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete record
|
||||
export async function deleteRecord(
|
||||
collection: string,
|
||||
rkey: string
|
||||
): Promise<boolean> {
|
||||
if (!agent) return false
|
||||
|
||||
try {
|
||||
await agent.com.atproto.repo.deleteRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Delete record error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface Route {
|
||||
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) {
|
||||
|
||||
186
src/web/main.ts
186
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<string | undefined> {
|
||||
}
|
||||
|
||||
async function render(route: Route): Promise<void> {
|
||||
showLoading(app)
|
||||
|
||||
try {
|
||||
const config = await getConfig()
|
||||
|
||||
@@ -112,11 +115,30 @@ async function render(route: Route): Promise<void> {
|
||||
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<string>()
|
||||
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<void> {
|
||||
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 += `<div id="content">${renderRecordDetail(record, route.collection)}</div>`
|
||||
html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>`
|
||||
} else {
|
||||
html += `<div id="content" class="error">Record not found</div>`
|
||||
}
|
||||
@@ -164,49 +190,72 @@ async function render(route: Route): Promise<void> {
|
||||
} 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 += `<div id="content">${renderPostDetail(post, handle, config.collection)}</div>`
|
||||
html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>`
|
||||
} else {
|
||||
html += `<div id="content" class="error">Post not found</div>`
|
||||
}
|
||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||
|
||||
} else if (route.type === 'postpage') {
|
||||
// Post form page
|
||||
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
|
||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||
|
||||
} else {
|
||||
// User page: compact collection buttons + posts
|
||||
const collections = await describeRepo(did)
|
||||
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
|
||||
|
||||
// Show post form if logged-in user is viewing their own page
|
||||
const loggedInHandle = getLoggedInHandle()
|
||||
const isOwnPage = isLoggedIn() && loggedInHandle === handle
|
||||
if (isOwnPage) {
|
||||
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
|
||||
}
|
||||
// Language selector above content
|
||||
html += renderLangSelector(langList)
|
||||
|
||||
const posts = await getPosts(did, config.collection, localFirst)
|
||||
// Use pre-loaded posts
|
||||
html += `<div id="content">${renderPostList(posts, handle)}</div>`
|
||||
}
|
||||
|
||||
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<void> {
|
||||
<div class="error">Error: ${error}</div>
|
||||
${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())
|
||||
|
||||
|
||||
@@ -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 */
|
||||
@@ -146,10 +181,24 @@ body {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-btn.login-btn:hover .login-icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.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 +371,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 +621,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 +754,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 +1350,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 +1362,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 +1394,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 +1458,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;
|
||||
|
||||
Reference in New Issue
Block a user