add translate
This commit is contained in:
BIN
public/icon/atproto.com.png
Normal file
BIN
public/icon/atproto.com.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icon/bsky.app.png
Normal file
BIN
public/icon/bsky.app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
1
public/icon/language.svg
Normal file
1
public/icon/language.svg
Normal 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 |
BIN
public/icon/syui.ai.png
Normal file
BIN
public/icon/syui.ai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
1
public/icon/user.svg
Normal file
1
public/icon/user.svg
Normal 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
67
public/syui.svg
Normal 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 |
@@ -25,8 +25,18 @@ function groupCollectionsByService(collections: string[]): Map<string, string[]>
|
||||
return groups
|
||||
}
|
||||
|
||||
// Local favicon mappings
|
||||
const localFavicons: Record<string, string> = {
|
||||
'syui.ai': '/icon/syui.ai.png',
|
||||
'bsky.app': '/icon/bsky.app.png',
|
||||
'atproto.com': '/icon/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