diff --git a/public/icon/atproto.com.png b/public/icon/atproto.com.png new file mode 100644 index 0000000..4c04c29 Binary files /dev/null and b/public/icon/atproto.com.png differ diff --git a/public/icon/bsky.app.png b/public/icon/bsky.app.png new file mode 100644 index 0000000..a5ca7ee Binary files /dev/null and b/public/icon/bsky.app.png differ diff --git a/public/icon/language.svg b/public/icon/language.svg new file mode 100644 index 0000000..89c6b9d --- /dev/null +++ b/public/icon/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icon/syui.ai.png b/public/icon/syui.ai.png new file mode 100644 index 0000000..fae8d5a Binary files /dev/null and b/public/icon/syui.ai.png differ diff --git a/public/icon/user.svg b/public/icon/user.svg new file mode 100644 index 0000000..84cac42 --- /dev/null +++ b/public/icon/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/syui.svg b/public/syui.svg new file mode 100644 index 0000000..5813502 --- /dev/null +++ b/public/syui.svg @@ -0,0 +1,67 @@ + + + + +syui + + + + + + + diff --git a/src/web/components/browser.ts b/src/web/components/browser.ts index b86a9f1..4de442f 100644 --- a/src/web/components/browser.ts +++ b/src/web/components/browser.ts @@ -25,8 +25,18 @@ function groupCollectionsByService(collections: string[]): Map return groups } +// Local favicon mappings +const localFavicons: Record = { + '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 ? ` + + ` : '' + return `

${collection}

URI: ${record.uri}

CID: ${record.cid}

+ ${deleteBtn}
${escapeHtml(JSON.stringify(record.value, null, 2))}
diff --git a/src/web/components/discussion.ts b/src/web/components/discussion.ts new file mode 100644 index 0000000..9b553f5 --- /dev/null +++ b/src/web/components/discussion.ts @@ -0,0 +1,105 @@ +import { searchPostsForUrl, type SearchPost } from '../lib/api' + +const DISCUSSION_POST_LIMIT = 10 + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr) + return date.toLocaleDateString('ja-JP', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) +} + +function getPostUrl(uri: string, appUrl: string): string { + // at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey + const parts = uri.replace('at://', '').split('/') + if (parts.length >= 3) { + return `${appUrl}/profile/${parts[0]}/post/${parts[2]}` + } + return '#' +} + +export function renderDiscussion(postUrl: string, appUrl: string = 'https://bsky.app'): string { + // Build search URL (truncate for search limit) + let searchQuery = postUrl + try { + const urlObj = new URL(postUrl) + const pathParts = urlObj.pathname.split('/').filter(Boolean) + const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/' + const rkey = pathParts[1] || '' + const remainingLength = 20 - basePath.length + const rkeyPrefix = remainingLength > 0 ? rkey.slice(0, remainingLength) : '' + searchQuery = basePath + rkeyPrefix + } catch { + // Keep original + } + + const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}` + + return ` + + ` +} + +export async function loadDiscussionPosts(container: HTMLElement, postUrl: string, appUrl: string = 'https://bsky.app'): Promise { + const postsContainer = container.querySelector('#discussion-posts') as HTMLElement + if (!postsContainer) return + + const dataAppUrl = postsContainer.dataset.appUrl || appUrl + + postsContainer.innerHTML = '
Loading comments...
' + + const posts = await searchPostsForUrl(postUrl) + + if (posts.length === 0) { + postsContainer.innerHTML = '' + return + } + + const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map((post: SearchPost) => { + const author = post.author + const avatar = author.avatar || '' + const displayName = author.displayName || author.handle + const handle = author.handle + const record = post.record as { text?: string; createdAt?: string } + const text = record?.text || '' + const createdAt = record?.createdAt || '' + const postLink = getPostUrl(post.uri, dataAppUrl) + + // Truncate text + const truncatedText = text.length > 200 ? text.slice(0, 200) + '...' : text + + return ` + +
+ ${avatar ? `` : '
'} +
+ ${escapeHtml(displayName)} + @${escapeHtml(handle)} +
+ ${formatDate(createdAt)} +
+
${escapeHtml(truncatedText)}
+
+ ` + }).join('') + + postsContainer.innerHTML = postsHtml +} diff --git a/src/web/components/header.ts b/src/web/components/header.ts index daf327e..f85c2c9 100644 --- a/src/web/components/header.ts +++ b/src/web/components/header.ts @@ -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 - ? `` - : `` + ? `` + : `` return `