add translate

This commit is contained in:
2026-01-18 15:47:24 +09:00
parent 6ef8780ac6
commit 8f8b2b7d28
18 changed files with 1049 additions and 153 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/favicon/bsky.app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/favicon/syui.ai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

1
public/icon/language.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/icon/user.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M320 312C386.3 312 440 258.3 440 192C440 125.7 386.3 72 320 72C253.7 72 200 125.7 200 192C200 258.3 253.7 312 320 312zM290.3 368C191.8 368 112 447.8 112 546.3C112 562.7 125.3 576 141.7 576L498.3 576C514.7 576 528 562.7 528 546.3C528 447.8 448.2 368 349.7 368L290.3 368z"/></svg>

After

Width:  |  Height:  |  Size: 500 B

67
public/syui.svg Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
syui
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92
-98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22
-33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5
-13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247
-1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31
-14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83
-143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37
-28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121
-17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51
-112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4
-9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82
-123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34
-18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95
-62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17
-4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3
45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7
-7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10
23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15
72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52
32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12
24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106
27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534
10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13
200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60
-40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25
83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25
18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49
-3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0
-53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7
75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24
-46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96
-53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0
-7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85
-38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77
-25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91
-20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18
15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92
-113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115
301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46
89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23
15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z
m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7
-187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0
84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25
-32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17
-13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11
-14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49
-146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29
-104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48
22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0
10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40
16 57 18 38 52 41 99 11z" fill="#EF454A"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -25,8 +25,18 @@ function groupCollectionsByService(collections: string[]): Map<string, string[]>
return groups 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 // Get favicon URL for service
function getFaviconUrl(service: string): string { function getFaviconUrl(service: string): string {
if (localFavicons[service]) {
return localFavicons[service]
}
return `https://www.google.com/s2/favicons?domain=${service}&sz=32` return `https://www.google.com/s2/favicons?domain=${service}&sz=32`
} }
@@ -167,14 +177,21 @@ export function renderRecordList(
// Render single record detail // Render single record detail
export function renderRecordDetail( export function renderRecordDetail(
record: { uri: string; cid: string; value: unknown }, record: { uri: string; cid: string; value: unknown },
collection: string collection: string,
isOwner: boolean = false
): string { ): 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 ` return `
<article class="record-detail"> <article class="record-detail">
<header class="record-header"> <header class="record-header">
<h3>${collection}</h3> <h3>${collection}</h3>
<p class="record-uri">URI: ${record.uri}</p> <p class="record-uri">URI: ${record.uri}</p>
<p class="record-cid">CID: ${record.cid}</p> <p class="record-cid">CID: ${record.cid}</p>
${deleteBtn}
</header> </header>
<div class="json-view"> <div class="json-view">
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre> <pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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
}

View File

@@ -1,12 +1,12 @@
import { isLoggedIn, getLoggedInDid } from '../lib/auth' import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
export function renderHeader(currentHandle: string): string { export function renderHeader(currentHandle: string): string {
const loggedIn = isLoggedIn() const loggedIn = isLoggedIn()
const did = getLoggedInDid() const handle = getLoggedInHandle()
const loginBtn = loggedIn 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 user-btn" id="logout-btn" title="Logout">${handle || 'logout'}</button>`
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login"></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 ` return `
<header id="header"> <header id="header">

View 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>'
}

View File

@@ -1,6 +1,8 @@
import { getNetworks } from '../lib/api' import { getNetworks } from '../lib/api'
import { isLoggedIn } from '../lib/auth'
let currentNetwork = 'bsky.social' let currentNetwork = 'bsky.social'
let currentLang = localStorage.getItem('preferred-lang') || 'en'
export function getCurrentNetwork(): string { export function getCurrentNetwork(): string {
return currentNetwork return currentNetwork
@@ -10,15 +12,29 @@ export function setCurrentNetwork(network: string): void {
currentNetwork = network 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 = ` let tabs = `
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">Blog</a> <a href="/" class="tab">/</a>
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">Browser</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 += ` tabs += `
<div class="pds-selector" id="pds-selector"> <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 class="pds-dropdown" id="pds-dropdown"></div>
</div> </div>
` `
@@ -26,12 +42,25 @@ export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' = '
return `<div class="mode-tabs">${tabs}</div>` 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 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 // Load networks
const networks = await getNetworks() const networks = await getNetworks()
@@ -46,21 +75,16 @@ export async function setupModeTabs(onNetworkChange: (network: string) => void):
` `
}).join('') }).join('')
dropdown.innerHTML = optionsHtml pdsDropdown.innerHTML = optionsHtml
// Toggle dropdown // Toggle dropdown
pdsTab.addEventListener('click', (e) => { pdsTab.addEventListener('click', (e) => {
e.stopPropagation() e.stopPropagation()
dropdown.classList.toggle('show') pdsDropdown.classList.toggle('show')
})
// Close on outside click
document.addEventListener('click', () => {
dropdown.classList.remove('show')
}) })
// Handle option selection // Handle option selection
dropdown.querySelectorAll('.pds-option').forEach(opt => { pdsDropdown.querySelectorAll('.pds-option').forEach(opt => {
opt.addEventListener('click', (e) => { opt.addEventListener('click', (e) => {
e.stopPropagation() e.stopPropagation()
const network = (opt as HTMLElement).dataset.network || '' const network = (opt as HTMLElement).dataset.network || ''
@@ -68,13 +92,64 @@ export async function setupModeTabs(onNetworkChange: (network: string) => void):
currentNetwork = network currentNetwork = network
// Update UI // Update UI
dropdown.querySelectorAll('.pds-option').forEach(o => { pdsDropdown.querySelectorAll('.pds-option').forEach(o => {
o.classList.remove('selected') o.classList.remove('selected')
}) })
opt.classList.add('selected') opt.classList.add('selected')
dropdown.classList.remove('show') pdsDropdown.classList.remove('show')
onNetworkChange(network) 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')
})
} }

View File

@@ -1,5 +1,7 @@
import type { Post } from '../types' import type { Post } from '../types'
import { renderMarkdown } from '../lib/markdown' import { renderMarkdown } from '../lib/markdown'
import { renderDiscussion, loadDiscussionPosts } from './discussion'
import { getCurrentLang } from './mode-tabs'
// Render post list // Render post list
export function renderPostList(posts: Post[], handle: string): string { 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>' return '<p class="no-posts">No posts yet.</p>'
} }
const currentLang = getCurrentLang()
const items = posts.map(post => { const items = posts.map(post => {
const rkey = post.uri.split('/').pop() || '' 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 ` return `
<article class="post-item"> <article class="post-item">
<a href="/@${handle}/${rkey}" class="post-link"> <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> <time class="post-date">${date}</time>
</a> </a>
</article> </article>
@@ -25,26 +37,83 @@ export function renderPostList(posts: Post[], handle: string): string {
} }
// Render single post detail // 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 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 content = renderMarkdown(post.value.content)
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}` 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 ` return `
<article class="post-detail"> <article class="post-detail" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}">
<header class="post-header"> <header class="post-header">
<h1 class="post-title">${escapeHtml(post.value.title)}</h1>
<div class="post-meta"> <div class="post-meta">
<time class="post-date">${date}</time> <time class="post-date">${date}</time>
<a href="${jsonUrl}" class="json-btn">json</a> <a href="${jsonUrl}" class="json-btn">json</a>
${editBtn}
</div> </div>
</header> </header>
${editForm}
<div id="post-display">
<h1 class="post-title">${escapeHtml(displayTitle)}</h1>
<div class="post-content">${content}</div> <div class="post-content">${content}</div>
</div>
</article> </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 { export function mountPostList(container: HTMLElement, html: string): void {
container.innerHTML = html container.innerHTML = html
} }

View File

@@ -19,25 +19,18 @@ export async function renderProfile(
: `<span>@${escapeHtml(handle)}</span>` : `<span>@${escapeHtml(handle)}</span>`
const avatarHtml = avatarUrl const avatarHtml = avatarUrl
? `<a href="/"><img class="profile-avatar" src="${avatarUrl}" alt="${displayName}"></a>` ? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
: `<a href="/"><div class="profile-avatar-placeholder"></div></a>` : `<div class="profile-avatar-placeholder"></div>`
return ` return `
<div class="profile"> <div class="profile">
<div class="profile-row">
${avatarHtml} ${avatarHtml}
<div class="profile-meta"> <div class="profile-info">
<span class="profile-name">${escapeHtml(displayName)}</span> <h1 class="profile-name">${escapeHtml(displayName)}</h1>
<span class="profile-handle">${handleHtml}</span> <p class="profile-handle">${handleHtml}</p>
${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
</div> </div>
</div> </div>
${description ? `
<div class="profile-row">
<div class="profile-avatar-spacer"></div>
<p class="profile-description">${escapeHtml(description)}</p>
</div>
` : ''}
</div>
` `
} }

View File

@@ -242,3 +242,96 @@ export async function getRecord(did: string, collection: string, rkey: string):
} }
return null 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
}

View File

@@ -242,3 +242,52 @@ export async function createPost(
throw err 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
}
}

View File

@@ -1,5 +1,5 @@
export interface Route { export interface Route {
type: 'home' | 'user' | 'post' | 'atbrowser' | 'service' | 'collection' | 'record' type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
handle?: string handle?: string
rkey?: string rkey?: string
service?: string service?: string
@@ -45,7 +45,13 @@ export function parseRoute(): Route {
return { type: 'user', handle: userMatch[1] } 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(/^\/@([^/]+)\/([^/]+)$/) const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
if (postMatch) { if (postMatch) {
return { type: 'post', handle: postMatch[1], rkey: postMatch[2] } 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) { if (route.type === 'user' && route.handle) {
path = `/@${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) { } else if (route.type === 'post' && route.handle && route.rkey) {
path = `/@${route.handle}/${route.rkey}` path = `/@${route.handle}/${route.rkey}`
} else if (route.type === 'atbrowser' && route.handle) { } else if (route.type === 'atbrowser' && route.handle) {

View File

@@ -1,14 +1,15 @@
import './styles/main.css' import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api' import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' 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 { renderHeader } from './components/header'
import { renderProfile } from './components/profile' 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 { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser' 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 { renderFooter } from './components/footer'
import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')! const app = document.getElementById('app')!
@@ -51,6 +52,8 @@ async function getWebUrl(handle: string): Promise<string | undefined> {
} }
async function render(route: Route): Promise<void> { async function render(route: Route): Promise<void> {
showLoading(app)
try { try {
const config = await getConfig() const config = await getConfig()
@@ -112,11 +115,30 @@ async function render(route: Route): Promise<void> {
const profile = await getProfile(did, localFirst) const profile = await getProfile(did, localFirst)
const webUrl = await getWebUrl(handle) 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 // Build page
let html = renderHeader(handle) let html = renderHeader(handle)
// Mode tabs (Blog/Browser/PDS) // Mode tabs (Blog/Browser/Post/PDS)
const activeTab = route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog' const activeTab = route.type === 'postpage' ? 'post' :
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab) html += renderModeTabs(handle, activeTab)
// Profile section // Profile section
@@ -124,12 +146,16 @@ async function render(route: Route): Promise<void> {
html += await renderProfile(did, profile, handle, webUrl) 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 // Content section based on route type
if (route.type === 'record' && route.collection && route.rkey) { if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view // AT-Browser: Single record view
const record = await getRecord(did, route.collection, route.rkey) const record = await getRecord(did, route.collection, route.rkey)
if (record) { if (record) {
html += `<div id="content">${renderRecordDetail(record, route.collection)}</div>` html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>`
} else { } else {
html += `<div id="content" class="error">Record not found</div>` 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) { } else if (route.type === 'post' && route.rkey) {
// Post detail (config.collection with markdown) // Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst) const post = await getPost(did, config.collection, route.rkey, localFirst)
html += renderLangSelector(langList)
if (post) { 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 { } else {
html += `<div id="content" class="error">Post not found</div>` html += `<div id="content" class="error">Post not found</div>`
} }
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>` 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 { } else {
// User page: compact collection buttons + posts // User page: compact collection buttons + posts
const collections = await describeRepo(did) const collections = await describeRepo(did)
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>` html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
// Show post form if logged-in user is viewing their own page // Language selector above content
const loggedInHandle = getLoggedInHandle() html += renderLangSelector(langList)
const isOwnPage = isLoggedIn() && loggedInHandle === handle
if (isOwnPage) {
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
}
const posts = await getPosts(did, config.collection, localFirst) // Use pre-loaded posts
html += `<div id="content">${renderPostList(posts, handle)}</div>` html += `<div id="content">${renderPostList(posts, handle)}</div>`
} }
html += renderFooter(handle) html += renderFooter(handle)
app.innerHTML = html app.innerHTML = html
hideLoading(app)
setupEventHandlers() setupEventHandlers()
// Setup mode tabs (PDS selector) // Setup mode tabs (PDS selector + Lang selector)
await setupModeTabs((_network) => { await setupModeTabs(
(_network) => {
// Refresh when network is changed // Refresh when network is changed
render(parseRoute()) render(parseRoute())
}) },
langList,
// Setup post form if it exists (_lang) => {
const loggedInHandle = getLoggedInHandle() // Refresh when language is changed
if (isLoggedIn() && loggedInHandle === handle) {
setupPostForm(config.collection, () => {
// Refresh on success
render(parseRoute()) 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) { } catch (error) {
console.error('Render error:', error) console.error('Render error:', error)
app.innerHTML = ` app.innerHTML = `
@@ -214,6 +263,7 @@ async function render(route: Route): Promise<void> {
<div class="error">Error: ${error}</div> <div class="error">Error: ${error}</div>
${renderFooter(currentHandle)} ${renderFooter(currentHandle)}
` `
hideLoading(app)
setupEventHandlers() 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 // Initial render
render(parseRoute()) render(parseRoute())

View File

@@ -19,6 +19,41 @@ body {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 20px; 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 */ /* Dark mode */
@@ -146,10 +181,24 @@ body {
color: #666; 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 { .header-btn.user-btn {
width: auto;
padding: 8px 12px;
background: var(--btn-color); background: var(--btn-color);
color: #fff; color: #fff;
border-color: var(--btn-color); border-color: var(--btn-color);
font-size: 13px;
font-weight: 500;
} }
/* Post Form */ /* Post Form */
@@ -322,79 +371,59 @@ body {
color: #fff; color: #fff;
} }
/* Profile - SNS style */ /* Profile */
.profile { .profile {
padding: 16px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
margin: 32px 0;
}
.profile-row {
display: flex; display: flex;
align-items: flex-start; gap: 16px;
gap: 12px; padding: 20px;
} background: #f5f5f5;
border-radius: 12px;
.profile-row + .profile-row { margin-bottom: 24px;
margin-top: 8px;
} }
.profile-avatar { .profile-avatar {
width: 48px; width: 80px;
height: 48px; height: 80px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0;
} }
.profile-avatar-placeholder { .profile-avatar-placeholder {
width: 48px; width: 80px;
height: 48px; height: 80px;
border-radius: 50%; border-radius: 50%;
background: #e0e0e0; background: #e0e0e0;
flex-shrink: 0;
} }
.profile-avatar-spacer { .profile-info {
width: 48px; flex: 1;
flex-shrink: 0;
}
.profile-meta {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
} }
.profile-name { .profile-name {
font-size: 15px; font-size: 20px;
font-weight: 700; font-weight: 600;
color: #0f1419; margin-bottom: 4px;
} }
.profile-handle { .profile-handle {
font-size: 14px; font-size: 14px;
color: #536471; color: #666;
margin-bottom: 8px;
} }
.profile-handle-link { .profile-handle-link {
color: #536471; color: #666;
text-decoration: none; text-decoration: none;
} }
.profile-handle-link:hover { .profile-handle-link:hover {
color: var(--btn-color);
text-decoration: underline; text-decoration: underline;
} }
.profile-description { .profile-desc {
font-size: 14px; font-size: 14px;
color: #0f1419; color: #444;
line-height: 1.5;
margin: 0;
} }
/* Services */ /* Services */
@@ -592,6 +621,120 @@ body {
color: #333; 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 { .edit-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -611,6 +754,110 @@ body {
background: #218838; 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 */
.edit-form-container { .edit-form-container {
padding: 20px 0; padding: 20px 0;
@@ -1103,8 +1350,11 @@ body {
margin: 4px 0; margin: 4px 0;
} }
/* Language Selector */ /* Language Selector (above content) */
.lang-selector { .lang-selector {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
position: relative; position: relative;
} }
@@ -1112,18 +1362,26 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; background: transparent;
height: 36px; border: none;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
color: #666; color: #666;
font-size: 18px; padding: 8px 16px;
} }
.lang-btn:hover { .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 { .lang-dropdown {
@@ -1136,7 +1394,7 @@ body {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px; min-width: 100px;
z-index: 100; z-index: 100;
overflow: hidden; overflow: hidden;
} }
@@ -1200,12 +1458,14 @@ body {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.lang-btn { .lang-btn {
background: #2a2a2a; background: transparent;
border-color: #333;
color: #888; color: #888;
} }
.lang-btn:hover { .lang-btn:hover {
background: #333; background: #2a2a2a;
}
.lang-icon {
filter: invert(0.7);
} }
.lang-dropdown { .lang-dropdown {
background: #1a1a1a; background: #1a1a1a;