add comment
This commit is contained in:
@@ -1,33 +1,38 @@
|
||||
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo } from '../lib/api.js'
|
||||
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js'
|
||||
import { deleteRecord } from '../lib/auth.js'
|
||||
import { escapeHtml } from '../lib/utils.js'
|
||||
|
||||
function extractRkey(uri: string): string {
|
||||
const parts = uri.split('/')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('ja-JP', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
async function renderServices(did: string, handle: string): Promise<string> {
|
||||
const collections = await describeRepo(did)
|
||||
const [collections, pds] = await Promise.all([
|
||||
describeRepo(did),
|
||||
resolvePds(did)
|
||||
])
|
||||
|
||||
// Server info section
|
||||
const plcUrl = `${getPlc()}/${did}/log`
|
||||
const serverHtml = `
|
||||
<div class="server-info">
|
||||
<h3>Server</h3>
|
||||
<dl class="server-details">
|
||||
<div class="server-row">
|
||||
<dt>DID</dt>
|
||||
<dd class="did-value"><a href="${plcUrl}" target="_blank" rel="noopener">${escapeHtml(did)}</a></dd>
|
||||
</div>
|
||||
<div class="server-row">
|
||||
<dt>PDS</dt>
|
||||
<dd class="pds-value"><a href="${escapeHtml(pds)}" target="_blank" rel="noopener">${escapeHtml(pds)}</a></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
`
|
||||
|
||||
if (collections.length === 0) {
|
||||
return '<p class="no-data">No collections found</p>'
|
||||
return serverHtml + '<p class="no-data">No collections found</p>'
|
||||
}
|
||||
|
||||
// Group by service domain
|
||||
@@ -57,6 +62,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
${serverHtml}
|
||||
<div class="services-list">
|
||||
<h3>Services</h3>
|
||||
<ul class="service-list">${items}</ul>
|
||||
@@ -99,7 +105,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str
|
||||
`
|
||||
}
|
||||
|
||||
async function renderRecordList(did: string, handle: string, collection: string): Promise<string> {
|
||||
async function renderRecordList(did: string, handle: string, collection: string, canDelete: boolean): Promise<string> {
|
||||
const records = await listRecordsRaw(did, collection)
|
||||
|
||||
if (records.length === 0) {
|
||||
@@ -109,12 +115,16 @@ async function renderRecordList(did: string, handle: string, collection: string)
|
||||
const items = records.map(rec => {
|
||||
const rkey = extractRkey(rec.uri)
|
||||
const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey
|
||||
const deleteBtn = canDelete
|
||||
? `<button class="delete-btn-small" data-collection="${collection}" data-rkey="${rkey}" title="Delete">×</button>`
|
||||
: ''
|
||||
return `
|
||||
<li class="record-item">
|
||||
<li class="record-item" data-rkey="${rkey}">
|
||||
<a href="/at/${handle}/${collection}/${rkey}" class="record-link">
|
||||
<span class="record-rkey">${rkey}</span>
|
||||
<span class="record-preview">${preview}</span>
|
||||
<span class="record-preview">${escapeHtml(preview)}</span>
|
||||
</a>
|
||||
${deleteBtn}
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
@@ -185,7 +195,7 @@ export async function mountAtBrowser(
|
||||
const info = getServiceInfo(collection)
|
||||
const backService = info ? info.domain : ''
|
||||
nav = `<a href="/at/${handle}/${backService}" class="back-link">← ${info?.name || 'Back'}</a>`
|
||||
content = await renderRecordList(did, handle, collection)
|
||||
content = await renderRecordList(did, handle, collection, canDelete)
|
||||
} else if (service) {
|
||||
nav = `<a href="/at/${handle}" class="back-link">← Services</a>`
|
||||
content = await renderCollections(did, handle, service)
|
||||
@@ -195,7 +205,7 @@ export async function mountAtBrowser(
|
||||
|
||||
container.innerHTML = nav + content
|
||||
|
||||
// Add delete button handler
|
||||
// Add delete button handler (record detail page)
|
||||
const deleteBtn = container.querySelector('.delete-btn')
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
@@ -206,14 +216,12 @@ export async function mountAtBrowser(
|
||||
|
||||
if (!col || !rk) return
|
||||
|
||||
if (!confirm('Delete this record?')) return
|
||||
|
||||
try {
|
||||
btn.disabled = true
|
||||
btn.textContent = 'Deleting...'
|
||||
await deleteRecord(col, rk)
|
||||
// Go back to collection
|
||||
window.location.href = `/at/${handle}/${col}`
|
||||
// Re-render collection list (stay in browser mode)
|
||||
await mountAtBrowser(container, handle, col, null, service, loginDid)
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err)
|
||||
btn.disabled = false
|
||||
@@ -221,6 +229,41 @@ export async function mountAtBrowser(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add delete button handlers for list items
|
||||
const deleteSmallBtns = container.querySelectorAll('.delete-btn-small')
|
||||
deleteSmallBtns.forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const button = e.target as HTMLButtonElement
|
||||
const col = button.dataset.collection
|
||||
const rk = button.dataset.rkey
|
||||
|
||||
if (!col || !rk) return
|
||||
|
||||
try {
|
||||
button.disabled = true
|
||||
button.textContent = '...'
|
||||
await deleteRecord(col, rk)
|
||||
// Remove the item from the list
|
||||
const listItem = button.closest('.record-item')
|
||||
if (listItem) {
|
||||
listItem.remove()
|
||||
// Update record count
|
||||
const countEl = container.querySelector('.record-count')
|
||||
if (countEl) {
|
||||
const remaining = container.querySelectorAll('.record-item').length
|
||||
countEl.textContent = `${remaining} records`
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err)
|
||||
button.disabled = false
|
||||
button.textContent = '×'
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||
}
|
||||
|
||||
102
src/components/discussion.ts
Normal file
102
src/components/discussion.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { searchPostsForUrl } from '../lib/api.js'
|
||||
import { escapeHtml } from '../lib/utils.js'
|
||||
|
||||
// Map network to app URL
|
||||
export function getAppUrl(network: string): string {
|
||||
if (network === 'syu.is') {
|
||||
return 'https://syu.is'
|
||||
}
|
||||
return 'https://bsky.app'
|
||||
}
|
||||
|
||||
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 renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string {
|
||||
// Convert full URL to search-friendly format (domain/post/rkey_prefix without https://)
|
||||
// Keep total length around 20 chars to avoid URL truncation in posts
|
||||
const MAX_SEARCH_LENGTH = 20
|
||||
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 = MAX_SEARCH_LENGTH - basePath.length
|
||||
const rkeyPrefix = remainingLength > 0 ? rkey.slice(0, remainingLength) : ''
|
||||
searchQuery = basePath + rkeyPrefix
|
||||
} catch {
|
||||
// Keep original if parsing fails
|
||||
}
|
||||
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
||||
return `
|
||||
<div class="discussion-section">
|
||||
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
||||
<svg width="20" height="20" 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
|
||||
|
||||
// Get appUrl from data attribute if available
|
||||
const dataAppUrl = postsContainer.dataset.appUrl
|
||||
const effectiveAppUrl = dataAppUrl || appUrl
|
||||
|
||||
postsContainer.innerHTML = '<div class="loading-small">Loading...</div>'
|
||||
|
||||
const posts = await searchPostsForUrl(postUrl)
|
||||
|
||||
if (posts.length === 0) {
|
||||
postsContainer.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
const postsHtml = posts.slice(0, 10).map(post => {
|
||||
const author = post.author
|
||||
const avatar = author.avatar || ''
|
||||
const displayName = author.displayName || author.handle
|
||||
const handle = author.handle
|
||||
const text = post.record?.text || ''
|
||||
const createdAt = post.record?.createdAt || ''
|
||||
const postLink = getPostUrl(post.uri, effectiveAppUrl)
|
||||
|
||||
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-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(text)}</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
postsContainer.innerHTML = postsHtml
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { BlogPost } from '../types.js'
|
||||
import { putRecord } from '../lib/auth.js'
|
||||
import { renderMarkdown } from '../lib/markdown.js'
|
||||
import { escapeHtml } from '../lib/utils.js'
|
||||
import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js'
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
@@ -11,14 +13,6 @@ function formatDate(dateStr: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = '<p class="no-posts">No posts yet</p>'
|
||||
@@ -40,9 +34,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
||||
container.innerHTML = `<ul class="post-list">${html}</ul>`
|
||||
}
|
||||
|
||||
export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false): void {
|
||||
export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false, siteUrl?: string, network: string = 'bsky.social'): void {
|
||||
const rkey = post.uri.split('/').pop() || ''
|
||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}`
|
||||
const postUrl = siteUrl ? `${siteUrl}/post/${rkey}` : `${window.location.origin}/post/${rkey}`
|
||||
const appUrl = getAppUrl(network)
|
||||
|
||||
const editBtn = canEdit ? `<button class="edit-btn" id="edit-btn">edit</button>` : ''
|
||||
|
||||
@@ -59,6 +55,8 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
|
||||
<div class="post-content" id="post-content">${renderMarkdown(post.content)}</div>
|
||||
</article>
|
||||
|
||||
${renderDiscussionLink(postUrl, appUrl)}
|
||||
|
||||
<div class="edit-form-container" id="edit-form-container" style="display: none;">
|
||||
<h3>Edit Post</h3>
|
||||
<form class="edit-form" id="edit-form">
|
||||
@@ -72,6 +70,9 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
|
||||
</div>
|
||||
`
|
||||
|
||||
// Load discussion posts
|
||||
loadDiscussionPosts(container, postUrl)
|
||||
|
||||
if (canEdit) {
|
||||
const editBtnEl = document.getElementById('edit-btn')
|
||||
const editFormContainer = document.getElementById('edit-form-container')
|
||||
|
||||
Reference in New Issue
Block a user