refact web
This commit is contained in:
@@ -125,6 +125,19 @@ pub fn run(content_dir: &Path) -> Result<()> {
|
||||
total_updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate list.json (all records in a single array)
|
||||
let list_path = col_path.join("list.json");
|
||||
let mut records: Vec<serde_json::Value> = Vec::new();
|
||||
for rkey in &rkeys {
|
||||
let file_path = col_path.join(format!("{}.json", rkey));
|
||||
if let Ok(content) = fs::read_to_string(&file_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
records.push(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(&list_path, serde_json::to_string(&records)?)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// AT-Browser: Server info and collection hierarchy
|
||||
import { escapeHtml } from '../lib/util'
|
||||
|
||||
// Group collections by service domain
|
||||
function groupCollectionsByService(collections: string[]): Map<string, string[]> {
|
||||
@@ -241,11 +242,3 @@ function getRecordPreview(value: Record<string, unknown>): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Card display component for ai.syui.card.user collection
|
||||
import { getLocalizedText } from '../lib/util'
|
||||
|
||||
export interface UserCard {
|
||||
id: number
|
||||
@@ -29,18 +30,6 @@ export interface CardAdminData {
|
||||
card: CardAdminEntry[]
|
||||
}
|
||||
|
||||
// Get current language
|
||||
function getLang(): string {
|
||||
return localStorage.getItem('preferredLang') || 'ja'
|
||||
}
|
||||
|
||||
// Get localized text
|
||||
function getLocalizedText(obj: { ja: string; en: string } | undefined): string {
|
||||
if (!obj) return ''
|
||||
const lang = getLang()
|
||||
return obj[lang as 'ja' | 'en'] || obj.ja || obj.en || ''
|
||||
}
|
||||
|
||||
// Get rarity class name
|
||||
function getRarityClass(card: UserCard): string {
|
||||
if (card.unique) return 'unique'
|
||||
|
||||
187
src/web/components/chat-list.ts
Normal file
187
src/web/components/chat-list.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { ChatMessage, Profile } from '../types'
|
||||
import { escapeHtml, formatDateTime, getRkeyFromUri, getAuthorityFromUri } from '../lib/util'
|
||||
import { buildAuthorMap, resolveAuthorDid, chatTypeSlug, getTranslatedContent } from './chat'
|
||||
|
||||
export type { ChatCollectionEntry } from './chat'
|
||||
|
||||
const formatChatTime = formatDateTime
|
||||
|
||||
// Render chat threads list (conversations this user started)
|
||||
export function renderChatThreadList(
|
||||
messages: ChatMessage[],
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string,
|
||||
chatCollection: string = 'ai.syui.log.chat'
|
||||
): string {
|
||||
// Build set of all message URIs
|
||||
const allUris = new Set(messages.map(m => m.uri))
|
||||
|
||||
// Find root messages by this user:
|
||||
// 1. No root field (explicit start of conversation)
|
||||
// 2. Or root points to non-existent message (orphaned, treat as root)
|
||||
// For orphaned roots, only keep the oldest message per orphaned root URI
|
||||
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
|
||||
const rootMessages: ChatMessage[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||
|
||||
if (!msg.value.root) {
|
||||
// No root = explicit conversation start
|
||||
rootMessages.push(msg)
|
||||
} else if (!allUris.has(msg.value.root)) {
|
||||
// Orphaned root - keep only the oldest message per orphaned root
|
||||
const existing = orphanedRootFirstMsg.get(msg.value.root)
|
||||
if (!existing || new Date(msg.value.publishedAt) < new Date(existing.value.publishedAt)) {
|
||||
orphanedRootFirstMsg.set(msg.value.root, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orphaned root representatives
|
||||
for (const msg of orphanedRootFirstMsg.values()) {
|
||||
rootMessages.push(msg)
|
||||
}
|
||||
|
||||
if (rootMessages.length === 0) {
|
||||
return '<p class="no-posts">No chat threads yet.</p>'
|
||||
}
|
||||
|
||||
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
|
||||
// Sort by publishedAt (newest first)
|
||||
const sorted = [...rootMessages].sort((a, b) =>
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
|
||||
const avatarHtml = author.avatarUrl
|
||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||
: `<div class="chat-avatar-placeholder"></div>`
|
||||
|
||||
// Truncate content for preview (use translated content, show first 3 lines)
|
||||
const displayContent = getTranslatedContent(msg)
|
||||
const lines = displayContent.split('\n').slice(0, 3)
|
||||
const preview = lines.join('\n')
|
||||
|
||||
return `
|
||||
<a href="/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}" class="chat-thread-item">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-thread-content">
|
||||
<div class="chat-thread-header">
|
||||
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||
<span class="chat-type-badge" data-type="${chatTypeSlug(chatCollection)}">${chatTypeSlug(chatCollection)}</span>
|
||||
<span class="chat-time">${time}</span>
|
||||
</div>
|
||||
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="chat-thread-list">${items}</div>`
|
||||
}
|
||||
|
||||
// Render unified chat list page with filter buttons
|
||||
export function renderChatListPage(
|
||||
collections: import('./chat').ChatCollectionEntry[],
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string
|
||||
): string {
|
||||
// Merge all collections into a single list
|
||||
const allMessages: ChatMessage[] = []
|
||||
const messageCollectionMap = new Map<string, string>()
|
||||
|
||||
for (const c of collections) {
|
||||
for (const msg of c.messages) {
|
||||
allMessages.push(msg)
|
||||
messageCollectionMap.set(msg.uri, c.collection)
|
||||
}
|
||||
}
|
||||
|
||||
// Build author map with first collection's params (all share same users)
|
||||
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
|
||||
// Find root messages (same logic as renderChatThreadList)
|
||||
const allUris = new Set(allMessages.map(m => m.uri))
|
||||
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
|
||||
const rootMessages: ChatMessage[] = []
|
||||
|
||||
for (const msg of allMessages) {
|
||||
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||
|
||||
if (!msg.value.root) {
|
||||
rootMessages.push(msg)
|
||||
} else if (!allUris.has(msg.value.root)) {
|
||||
const existing = orphanedRootFirstMsg.get(msg.value.root)
|
||||
if (!existing || new Date(msg.value.publishedAt) < new Date(existing.value.publishedAt)) {
|
||||
orphanedRootFirstMsg.set(msg.value.root, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of orphanedRootFirstMsg.values()) {
|
||||
rootMessages.push(msg)
|
||||
}
|
||||
|
||||
if (rootMessages.length === 0) {
|
||||
return '<div class="chat-container"><p class="no-posts">No chat threads yet.</p></div>'
|
||||
}
|
||||
|
||||
// Sort by publishedAt (newest first)
|
||||
const sorted = [...rootMessages].sort((a, b) =>
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
const collection = messageCollectionMap.get(msg.uri) || 'ai.syui.log.chat'
|
||||
const slug = chatTypeSlug(collection)
|
||||
|
||||
const avatarHtml = author.avatarUrl
|
||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||
: `<div class="chat-avatar-placeholder"></div>`
|
||||
|
||||
const displayContent = getTranslatedContent(msg)
|
||||
const lines = displayContent.split('\n').slice(0, 3)
|
||||
const preview = lines.join('\n')
|
||||
|
||||
return `
|
||||
<a href="/@${userHandle}/at/chat/${slug}/${rkey}" class="chat-thread-item">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-thread-content">
|
||||
<div class="chat-thread-header">
|
||||
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||
<span class="chat-type-badge" data-type="${slug}">${slug}</span>
|
||||
<span class="chat-time">${time}</span>
|
||||
</div>
|
||||
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="chat-container"><div class="chat-thread-list">${items}</div></div>`
|
||||
}
|
||||
143
src/web/components/chat-thread.ts
Normal file
143
src/web/components/chat-thread.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { ChatMessage, Profile } from '../types'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
import { escapeHtml, formatDateTime, getRkeyFromUri, getAuthorityFromUri } from '../lib/util'
|
||||
import { buildAuthorMap, resolveAuthorDid, chatTypeSlug, getTranslatedContent } from './chat'
|
||||
|
||||
const formatChatTime = formatDateTime
|
||||
|
||||
// Render single chat thread (full conversation)
|
||||
export function renderChatThread(
|
||||
messages: ChatMessage[],
|
||||
rootRkey: string,
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string,
|
||||
chatCollection: string = 'ai.syui.log.chat',
|
||||
loggedInDid?: string | null
|
||||
): string {
|
||||
// Find root message
|
||||
const rootUri = `at://${userDid}/${chatCollection}/${rootRkey}`
|
||||
const rootMsg = messages.find(m => m.uri === rootUri)
|
||||
|
||||
if (!rootMsg) {
|
||||
return '<p class="error">Chat thread not found.</p>'
|
||||
}
|
||||
|
||||
// Find all messages in this thread
|
||||
// 1. The root message itself
|
||||
// 2. Messages with root === rootUri (direct children)
|
||||
// 3. If this is an orphaned root (root points to non-existent), find siblings with same original root
|
||||
const originalRoot = rootMsg.value.root
|
||||
const allUris = new Set(messages.map(m => m.uri))
|
||||
const isOrphanedRoot = originalRoot && !allUris.has(originalRoot)
|
||||
|
||||
const threadMessages = messages.filter(msg => {
|
||||
// Include the root message itself
|
||||
if (msg.uri === rootUri) return true
|
||||
// Include messages that point to this as root
|
||||
if (msg.value.root === rootUri) return true
|
||||
// If orphaned, include messages with the same original root
|
||||
if (isOrphanedRoot && msg.value.root === originalRoot) return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (threadMessages.length === 0) {
|
||||
return '<p class="error">No messages in this thread.</p>'
|
||||
}
|
||||
|
||||
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
|
||||
// Sort by publishedAt
|
||||
const sorted = [...threadMessages].sort((a, b) =>
|
||||
new Date(a.value.publishedAt).getTime() - new Date(b.value.publishedAt).getTime()
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
|
||||
const avatarHtml = author.avatarUrl
|
||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||
: `<div class="chat-avatar-placeholder"></div>`
|
||||
|
||||
const displayContent = getTranslatedContent(msg)
|
||||
const content = renderMarkdown(displayContent)
|
||||
const recordLink = `/@${author.handle}/at/collection/${chatCollection}/${rkey}`
|
||||
const canEdit = loggedInDid && authorDid === loggedInDid
|
||||
const editLink = `/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}/edit`
|
||||
|
||||
return `
|
||||
<article class="chat-message">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-content-col">
|
||||
<div class="chat-message-header">
|
||||
<a href="/@${author.handle}" class="chat-author">@${escapeHtml(author.handle)}</a>
|
||||
<a href="${recordLink}" class="chat-time">${time}</a>
|
||||
${canEdit ? `<a href="${editLink}" class="chat-edit-btn">edit</a>` : ''}
|
||||
</div>
|
||||
<div class="chat-content">${content}</div>
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="chat-list">${items}</div>`
|
||||
}
|
||||
|
||||
// Render chat thread page
|
||||
export function renderChatThreadPage(
|
||||
messages: ChatMessage[],
|
||||
rootRkey: string,
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string,
|
||||
chatCollection: string = 'ai.syui.log.chat',
|
||||
loggedInDid?: string | null
|
||||
): string {
|
||||
const thread = renderChatThread(messages, rootRkey, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds, chatCollection, loggedInDid)
|
||||
return `<div class="chat-container">${thread}</div>`
|
||||
}
|
||||
|
||||
// Render chat edit form
|
||||
export function renderChatEditForm(
|
||||
message: ChatMessage,
|
||||
collection: string,
|
||||
userHandle: string
|
||||
): string {
|
||||
const rkey = message.uri.split('/').pop() || ''
|
||||
const content = message.value.content.text
|
||||
|
||||
return `
|
||||
<div class="chat-edit-container">
|
||||
<h2>Edit Chat Message</h2>
|
||||
<form class="chat-edit-form" id="chat-edit-form">
|
||||
<textarea
|
||||
class="chat-edit-content"
|
||||
id="chat-edit-content"
|
||||
rows="10"
|
||||
required
|
||||
>${escapeHtml(content)}</textarea>
|
||||
<div class="chat-edit-footer">
|
||||
<span class="chat-edit-collection">${collection}</span>
|
||||
<div class="chat-edit-buttons">
|
||||
<a href="/@${userHandle}/at/chat/${chatTypeSlug(collection)}/${rkey}" class="chat-edit-cancel">Cancel</a>
|
||||
<button type="submit" class="chat-edit-save" id="chat-edit-save" data-rkey="${rkey}">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="chat-edit-status" class="chat-edit-status"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -1,65 +1,28 @@
|
||||
import type { ChatMessage, Profile } from '../types'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
import { getCurrentLang } from './mode-tabs'
|
||||
|
||||
// Get translated content for a chat message
|
||||
function getTranslatedContent(msg: ChatMessage): string {
|
||||
const currentLang = getCurrentLang()
|
||||
const originalLang = msg.value.langs?.[0] || 'ja'
|
||||
const translations = msg.value.translations
|
||||
|
||||
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
||||
const translated = translations[currentLang].content
|
||||
if (typeof translated === 'string') return translated
|
||||
if (translated && typeof translated === 'object' && 'text' in translated) return (translated as { text: string }).text
|
||||
return msg.value.content.text
|
||||
}
|
||||
return msg.value.content.text
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
// Format date/time for chat
|
||||
function formatChatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hour = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${month}/${day} ${hour}:${min}`
|
||||
}
|
||||
|
||||
// Extract rkey from AT URI
|
||||
function getRkeyFromUri(uri: string): string {
|
||||
return uri.split('/').pop() || ''
|
||||
}
|
||||
|
||||
// Extract authority from AT URI (at://did:plc:xxx/collection/rkey → did:plc:xxx)
|
||||
function getAuthorityFromUri(uri: string): string {
|
||||
return uri.replace('at://', '').split('/')[0]
|
||||
}
|
||||
import { getTranslatedContent as getTranslation } from '../lib/util'
|
||||
|
||||
// Profile info for authors
|
||||
interface AuthorInfo {
|
||||
export interface AuthorInfo {
|
||||
did: string
|
||||
handle: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
// Chat collection entry for unified list
|
||||
export interface ChatCollectionEntry {
|
||||
collection: string
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
// Handle/DID resolver: maps both handle and DID to DID
|
||||
let handleToDidMap = new Map<string, string>()
|
||||
|
||||
function resolveAuthorDid(authority: string): string {
|
||||
export function resolveAuthorDid(authority: string): string {
|
||||
return handleToDidMap.get(authority) || authority
|
||||
}
|
||||
|
||||
// Build author info map
|
||||
function buildAuthorMap(
|
||||
export function buildAuthorMap(
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
@@ -97,330 +60,17 @@ function buildAuthorMap(
|
||||
}
|
||||
|
||||
// Map collection name to chat type slug
|
||||
function chatTypeSlug(chatCollection: string): string {
|
||||
export function chatTypeSlug(chatCollection: string): string {
|
||||
if (chatCollection === 'ai.syui.ue.chat') return 'ue'
|
||||
return 'log'
|
||||
}
|
||||
|
||||
// Render chat threads list (conversations this user started)
|
||||
export function renderChatThreadList(
|
||||
messages: ChatMessage[],
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string,
|
||||
chatCollection: string = 'ai.syui.log.chat'
|
||||
): string {
|
||||
// Build set of all message URIs
|
||||
const allUris = new Set(messages.map(m => m.uri))
|
||||
|
||||
// Find root messages by this user:
|
||||
// 1. No root field (explicit start of conversation)
|
||||
// 2. Or root points to non-existent message (orphaned, treat as root)
|
||||
// For orphaned roots, only keep the oldest message per orphaned root URI
|
||||
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
|
||||
const rootMessages: ChatMessage[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||
|
||||
if (!msg.value.root) {
|
||||
// No root = explicit conversation start
|
||||
rootMessages.push(msg)
|
||||
} else if (!allUris.has(msg.value.root)) {
|
||||
// Orphaned root - keep only the oldest message per orphaned root
|
||||
const existing = orphanedRootFirstMsg.get(msg.value.root)
|
||||
if (!existing || new Date(msg.value.publishedAt) < new Date(existing.value.publishedAt)) {
|
||||
orphanedRootFirstMsg.set(msg.value.root, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orphaned root representatives
|
||||
for (const msg of orphanedRootFirstMsg.values()) {
|
||||
rootMessages.push(msg)
|
||||
}
|
||||
|
||||
if (rootMessages.length === 0) {
|
||||
return '<p class="no-posts">No chat threads yet.</p>'
|
||||
}
|
||||
|
||||
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
|
||||
// Sort by publishedAt (newest first)
|
||||
const sorted = [...rootMessages].sort((a, b) =>
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
|
||||
const avatarHtml = author.avatarUrl
|
||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||
: `<div class="chat-avatar-placeholder"></div>`
|
||||
|
||||
// Truncate content for preview (use translated content, show first 3 lines)
|
||||
const displayContent = getTranslatedContent(msg)
|
||||
const lines = displayContent.split('\n').slice(0, 3)
|
||||
const preview = lines.join('\n')
|
||||
|
||||
return `
|
||||
<a href="/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}" class="chat-thread-item">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-thread-content">
|
||||
<div class="chat-thread-header">
|
||||
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||
<span class="chat-type-badge" data-type="${chatTypeSlug(chatCollection)}">${chatTypeSlug(chatCollection)}</span>
|
||||
<span class="chat-time">${time}</span>
|
||||
</div>
|
||||
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="chat-thread-list">${items}</div>`
|
||||
// Get translated content for a chat message
|
||||
export function getTranslatedContent(msg: ChatMessage): string {
|
||||
const originalLang = msg.value.langs?.[0] || 'ja'
|
||||
return getTranslation(msg.value.content.text, msg.value.translations, originalLang)
|
||||
}
|
||||
|
||||
// Render single chat thread (full conversation)
|
||||
export function renderChatThread(
|
||||
messages: ChatMessage[],
|
||||
rootRkey: string,
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string,
|
||||
chatCollection: string = 'ai.syui.log.chat',
|
||||
loggedInDid?: string | null
|
||||
): string {
|
||||
// Find root message
|
||||
const rootUri = `at://${userDid}/${chatCollection}/${rootRkey}`
|
||||
const rootMsg = messages.find(m => m.uri === rootUri)
|
||||
|
||||
if (!rootMsg) {
|
||||
return '<p class="error">Chat thread not found.</p>'
|
||||
}
|
||||
|
||||
// Find all messages in this thread
|
||||
// 1. The root message itself
|
||||
// 2. Messages with root === rootUri (direct children)
|
||||
// 3. If this is an orphaned root (root points to non-existent), find siblings with same original root
|
||||
const originalRoot = rootMsg.value.root
|
||||
const allUris = new Set(messages.map(m => m.uri))
|
||||
const isOrphanedRoot = originalRoot && !allUris.has(originalRoot)
|
||||
|
||||
const threadMessages = messages.filter(msg => {
|
||||
// Include the root message itself
|
||||
if (msg.uri === rootUri) return true
|
||||
// Include messages that point to this as root
|
||||
if (msg.value.root === rootUri) return true
|
||||
// If orphaned, include messages with the same original root
|
||||
if (isOrphanedRoot && msg.value.root === originalRoot) return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (threadMessages.length === 0) {
|
||||
return '<p class="error">No messages in this thread.</p>'
|
||||
}
|
||||
|
||||
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
|
||||
// Sort by publishedAt
|
||||
const sorted = [...threadMessages].sort((a, b) =>
|
||||
new Date(a.value.publishedAt).getTime() - new Date(b.value.publishedAt).getTime()
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
|
||||
const avatarHtml = author.avatarUrl
|
||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||
: `<div class="chat-avatar-placeholder"></div>`
|
||||
|
||||
const displayContent = getTranslatedContent(msg)
|
||||
const content = renderMarkdown(displayContent)
|
||||
const recordLink = `/@${author.handle}/at/collection/${chatCollection}/${rkey}`
|
||||
const canEdit = loggedInDid && authorDid === loggedInDid
|
||||
const editLink = `/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}/edit`
|
||||
|
||||
return `
|
||||
<article class="chat-message">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-content-col">
|
||||
<div class="chat-message-header">
|
||||
<a href="/@${author.handle}" class="chat-author">@${escapeHtml(author.handle)}</a>
|
||||
<a href="${recordLink}" class="chat-time">${time}</a>
|
||||
${canEdit ? `<a href="${editLink}" class="chat-edit-btn">edit</a>` : ''}
|
||||
</div>
|
||||
<div class="chat-content">${content}</div>
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="chat-list">${items}</div>`
|
||||
}
|
||||
|
||||
// Chat collection entry for unified list
|
||||
export interface ChatCollectionEntry {
|
||||
collection: string
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
// Render unified chat list page with filter buttons
|
||||
export function renderChatListPage(
|
||||
collections: ChatCollectionEntry[],
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string
|
||||
): string {
|
||||
// Merge all collections into a single list
|
||||
const allMessages: ChatMessage[] = []
|
||||
const messageCollectionMap = new Map<string, string>()
|
||||
|
||||
for (const c of collections) {
|
||||
for (const msg of c.messages) {
|
||||
allMessages.push(msg)
|
||||
messageCollectionMap.set(msg.uri, c.collection)
|
||||
}
|
||||
}
|
||||
|
||||
// Build author map with first collection's params (all share same users)
|
||||
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
|
||||
// Find root messages (same logic as renderChatThreadList)
|
||||
const allUris = new Set(allMessages.map(m => m.uri))
|
||||
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
|
||||
const rootMessages: ChatMessage[] = []
|
||||
|
||||
for (const msg of allMessages) {
|
||||
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||
|
||||
if (!msg.value.root) {
|
||||
rootMessages.push(msg)
|
||||
} else if (!allUris.has(msg.value.root)) {
|
||||
const existing = orphanedRootFirstMsg.get(msg.value.root)
|
||||
if (!existing || new Date(msg.value.publishedAt) < new Date(existing.value.publishedAt)) {
|
||||
orphanedRootFirstMsg.set(msg.value.root, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of orphanedRootFirstMsg.values()) {
|
||||
rootMessages.push(msg)
|
||||
}
|
||||
|
||||
if (rootMessages.length === 0) {
|
||||
return '<div class="chat-container"><p class="no-posts">No chat threads yet.</p></div>'
|
||||
}
|
||||
|
||||
// Sort by publishedAt (newest first)
|
||||
const sorted = [...rootMessages].sort((a, b) =>
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
const collection = messageCollectionMap.get(msg.uri) || 'ai.syui.log.chat'
|
||||
const slug = chatTypeSlug(collection)
|
||||
|
||||
const avatarHtml = author.avatarUrl
|
||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||
: `<div class="chat-avatar-placeholder"></div>`
|
||||
|
||||
const displayContent = getTranslatedContent(msg)
|
||||
const lines = displayContent.split('\n').slice(0, 3)
|
||||
const preview = lines.join('\n')
|
||||
|
||||
return `
|
||||
<a href="/@${userHandle}/at/chat/${slug}/${rkey}" class="chat-thread-item">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-thread-content">
|
||||
<div class="chat-thread-header">
|
||||
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||
<span class="chat-type-badge" data-type="${slug}">${slug}</span>
|
||||
<span class="chat-time">${time}</span>
|
||||
</div>
|
||||
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="chat-container"><div class="chat-thread-list">${items}</div></div>`
|
||||
}
|
||||
|
||||
// Render chat thread page
|
||||
export function renderChatThreadPage(
|
||||
messages: ChatMessage[],
|
||||
rootRkey: string,
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string,
|
||||
chatCollection: string = 'ai.syui.log.chat',
|
||||
loggedInDid?: string | null
|
||||
): string {
|
||||
const thread = renderChatThread(messages, rootRkey, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds, chatCollection, loggedInDid)
|
||||
return `<div class="chat-container">${thread}</div>`
|
||||
}
|
||||
|
||||
// Render chat edit form
|
||||
export function renderChatEditForm(
|
||||
message: ChatMessage,
|
||||
collection: string,
|
||||
userHandle: string
|
||||
): string {
|
||||
const rkey = message.uri.split('/').pop() || ''
|
||||
const content = message.value.content.text
|
||||
|
||||
return `
|
||||
<div class="chat-edit-container">
|
||||
<h2>Edit Chat Message</h2>
|
||||
<form class="chat-edit-form" id="chat-edit-form">
|
||||
<textarea
|
||||
class="chat-edit-content"
|
||||
id="chat-edit-content"
|
||||
rows="10"
|
||||
required
|
||||
>${escapeHtml(content)}</textarea>
|
||||
<div class="chat-edit-footer">
|
||||
<span class="chat-edit-collection">${collection}</span>
|
||||
<div class="chat-edit-buttons">
|
||||
<a href="/@${userHandle}/at/chat/${chatTypeSlug(collection)}/${rkey}" class="chat-edit-cancel">Cancel</a>
|
||||
<button type="submit" class="chat-edit-save" id="chat-edit-save" data-rkey="${rkey}">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="chat-edit-status" class="chat-edit-status"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
// Re-export from split modules
|
||||
export { renderChatThreadList, renderChatListPage } from './chat-list'
|
||||
export { renderChatThread, renderChatThreadPage, renderChatEditForm } from './chat-thread'
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import { searchPostsForUrl, getCurrentNetwork, type SearchPost } from '../lib/api'
|
||||
import { escapeHtml, formatDateJa } from '../lib/util'
|
||||
|
||||
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('/')
|
||||
@@ -100,7 +84,7 @@ export async function loadDiscussionPosts(container: HTMLElement, postUrl: strin
|
||||
<span class="discussion-name">${escapeHtml(displayName)}</span>
|
||||
<span class="discussion-handle">@${escapeHtml(handle)}</span>
|
||||
</div>
|
||||
<span class="discussion-date">${formatDate(createdAt)}</span>
|
||||
<span class="discussion-date">${formatDateJa(createdAt)}</span>
|
||||
</div>
|
||||
<div class="discussion-text">${escapeHtml(truncatedText)}</div>
|
||||
</a>
|
||||
|
||||
@@ -33,12 +33,11 @@ export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | '
|
||||
tabs += `<a href="/@${handle}/at/chat" class="tab ${activeTab === 'chat' ? 'active' : ''}">chat</a>`
|
||||
}
|
||||
|
||||
// Note tab only for local user (admin) with note posts
|
||||
if (isLoggedIn()) {
|
||||
// Note tab only for logged-in admin with note posts
|
||||
if (isLocalUser && hasNotes) {
|
||||
tabs += `<a href="/@${handle}/at/note" class="tab ${activeTab === 'note' ? 'active' : ''}">note</a>`
|
||||
}
|
||||
|
||||
if (isLoggedIn()) {
|
||||
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
|
||||
tabs += `<a href="/@${handle}/at/link" class="tab ${activeTab === 'link' ? 'active' : ''}">link</a>`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
import type { Post } from '../types'
|
||||
import { escapeHtml, escapeAttr, formatDateJa } from '../lib/util'
|
||||
|
||||
// Note post has extra fields for member content
|
||||
interface NotePost extends Post {
|
||||
@@ -42,12 +43,10 @@ export function renderNoteListPage(posts: NotePost[], handle: string): string {
|
||||
export function renderNoteDetailPage(
|
||||
post: NotePost,
|
||||
_handle: string,
|
||||
localOnly: boolean
|
||||
isOwner: boolean
|
||||
): string {
|
||||
const rkey = post.uri.split('/').pop() || ''
|
||||
const date = new Date(post.value.publishedAt).toLocaleDateString('ja-JP', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
})
|
||||
const date = formatDateJa(post.value.publishedAt)
|
||||
const freeText = post.value.content?.text || ''
|
||||
const memberText = post.value.member?.text || ''
|
||||
const bonusText = post.value.member?.bonus || ''
|
||||
@@ -59,7 +58,7 @@ export function renderNoteDetailPage(
|
||||
let html = ''
|
||||
|
||||
// Action buttons at top
|
||||
if (localOnly) {
|
||||
if (isOwner) {
|
||||
html += `
|
||||
<div class="note-actions">
|
||||
<button type="button" class="note-copy-btn" id="note-copy-title">Copy Title</button>
|
||||
@@ -103,7 +102,7 @@ export function renderNoteDetailPage(
|
||||
html += `</div>`
|
||||
|
||||
// Edit form (below content)
|
||||
if (localOnly) {
|
||||
if (isOwner) {
|
||||
html += `
|
||||
<div class="note-edit" id="note-edit-form" style="display:none">
|
||||
<input type="text" id="note-edit-title" class="note-edit-input" value="${escapeAttr(post.value.title)}" placeholder="Title">
|
||||
@@ -223,10 +222,3 @@ export function setupNoteDetail(
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Post } from '../types'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
import { renderDiscussion, loadDiscussionPosts } from './discussion'
|
||||
import { getCurrentLang } from './mode-tabs'
|
||||
import { escapeHtml, formatDate, getLang } from '../lib/util'
|
||||
|
||||
// Extract text content from post content (handles both string and object formats)
|
||||
function getContentText(content: unknown): string {
|
||||
@@ -14,22 +14,13 @@ function getContentText(content: unknown): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Format date as yyyy/mm/dd
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}/${month}/${day}`
|
||||
}
|
||||
|
||||
// Render post list
|
||||
export function renderPostList(posts: Post[], handle: string): string {
|
||||
if (posts.length === 0) {
|
||||
return '<p class="no-posts">No posts yet.</p>'
|
||||
}
|
||||
|
||||
const currentLang = getCurrentLang()
|
||||
const currentLang = getLang()
|
||||
|
||||
const items = posts.map(post => {
|
||||
const rkey = post.uri.split('/').pop() || ''
|
||||
@@ -77,7 +68,7 @@ export function renderPostDetail(
|
||||
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 currentLang = getLang()
|
||||
const translations = post.value.translations
|
||||
const originalLang = post.value.langs?.[0] || 'ja'
|
||||
|
||||
@@ -144,11 +135,3 @@ export function mountPostDetail(container: HTMLElement, html: string): void {
|
||||
container.innerHTML = html
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Profile } from '../types'
|
||||
import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api'
|
||||
import { escapeHtml } from '../lib/util'
|
||||
|
||||
// Service definitions for profile icons
|
||||
export interface ServiceLink {
|
||||
@@ -122,11 +123,3 @@ export function mountProfile(container: HTMLElement, html: string): void {
|
||||
container.innerHTML = html
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// RSE display component for ai.syui.rse.user collection
|
||||
import { renderCard, type UserCard, type CardAdminEntry, type CardAdminData } from './card'
|
||||
import { getLocalizedText } from '../lib/util'
|
||||
|
||||
export interface RseAdminItem {
|
||||
id: number
|
||||
@@ -25,18 +26,6 @@ export interface RseCollection {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Get current language
|
||||
function getLang(): string {
|
||||
return localStorage.getItem('preferredLang') || 'ja'
|
||||
}
|
||||
|
||||
// Get localized text
|
||||
function getLocalizedText(obj: { ja: string; en: string } | undefined): string {
|
||||
if (!obj) return ''
|
||||
const lang = getLang()
|
||||
return obj[lang as 'ja' | 'en'] || obj.ja || obj.en || ''
|
||||
}
|
||||
|
||||
// Get rarity class from unique flag
|
||||
function getRarityClass(item: RseItem): string {
|
||||
if (item.unique) return 'unique'
|
||||
|
||||
@@ -17,6 +17,7 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeout
|
||||
// Cache
|
||||
let configCache: AppConfig | null = null
|
||||
let networksCache: Networks | null = null
|
||||
const pdsCache = new Map<string, string | null>()
|
||||
|
||||
// Load config.json
|
||||
export async function getConfig(): Promise<AppConfig> {
|
||||
@@ -55,8 +56,10 @@ export async function resolveHandle(handle: string): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get PDS endpoint for DID (try all networks)
|
||||
// Get PDS endpoint for DID (try all networks, cached)
|
||||
export async function getPds(did: string): Promise<string | null> {
|
||||
if (pdsCache.has(did)) return pdsCache.get(did)!
|
||||
|
||||
const networks = await getNetworks()
|
||||
|
||||
for (const network of Object.values(networks)) {
|
||||
@@ -66,6 +69,7 @@ export async function getPds(did: string): Promise<string | null> {
|
||||
const didDoc = await res.json()
|
||||
const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')
|
||||
if (service?.serviceEndpoint) {
|
||||
pdsCache.set(did, service.serviceEndpoint)
|
||||
return service.serviceEndpoint
|
||||
}
|
||||
}
|
||||
@@ -73,6 +77,7 @@ export async function getPds(did: string): Promise<string | null> {
|
||||
// Try next network (timeout or error)
|
||||
}
|
||||
}
|
||||
pdsCache.set(did, null)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -82,39 +87,62 @@ function isJsonResponse(res: Response): boolean {
|
||||
return contentType?.includes('application/json') ?? false
|
||||
}
|
||||
|
||||
// Load local profile
|
||||
async function getLocalProfile(did: string): Promise<Profile | null> {
|
||||
// Generic local-then-remote fetch helper
|
||||
async function fetchLocalThenRemote<T>(
|
||||
did: string,
|
||||
localPath: string,
|
||||
remoteFetcher: (pds: string) => Promise<T | null>,
|
||||
localOnly: boolean,
|
||||
defaultValue: T | null,
|
||||
localTransform?: (data: unknown) => T | null
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/app.bsky.actor.profile/self.json`)
|
||||
if (res.ok && isJsonResponse(res)) return res.json()
|
||||
} catch {
|
||||
// Not found
|
||||
const res = await fetch(localPath)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const data = await res.json()
|
||||
return localTransform ? localTransform(data) : data as T
|
||||
}
|
||||
} catch { /* Not found */ }
|
||||
|
||||
if (localOnly) return defaultValue
|
||||
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return defaultValue
|
||||
|
||||
try {
|
||||
return await remoteFetcher(pds)
|
||||
} catch { /* Failed */ }
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Helper to build a remote getRecord fetcher
|
||||
function remoteGetRecord<T>(
|
||||
did: string,
|
||||
collection: string,
|
||||
rkey: string,
|
||||
transform?: (data: unknown) => T
|
||||
): (pds: string) => Promise<T | null> {
|
||||
return async (pds: string) => {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
|
||||
const res = await fetchWithTimeout(url, {}, 8000)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return transform ? transform(data) : data as T
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Load profile (local only for admin, remote for others)
|
||||
export async function getProfile(did: string, localOnly = false): Promise<Profile | null> {
|
||||
// Try local first
|
||||
const local = await getLocalProfile(did)
|
||||
if (local) return local
|
||||
|
||||
// If local only mode, don't call API
|
||||
if (localOnly) return null
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=app.bsky.actor.profile&rkey=self`
|
||||
const res = await fetchWithTimeout(url, {}, 8000)
|
||||
if (res.ok) return res.json()
|
||||
} catch {
|
||||
// Failed or timeout
|
||||
}
|
||||
return null
|
||||
return fetchLocalThenRemote<Profile>(
|
||||
did,
|
||||
`/at/${did}/app.bsky.actor.profile/self.json`,
|
||||
remoteGetRecord(did, 'app.bsky.actor.profile', 'self'),
|
||||
localOnly,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Get avatar URL (local only for admin, remote for others)
|
||||
@@ -144,6 +172,18 @@ export async function getAvatarUrlRemote(did: string, profile: Profile): Promise
|
||||
|
||||
// Load local posts
|
||||
async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
|
||||
// Try list.json first (single request)
|
||||
try {
|
||||
const listRes = await fetch(`/at/${did}/${collection}/list.json`)
|
||||
if (listRes.ok && isJsonResponse(listRes)) {
|
||||
const posts: Post[] = await listRes.json()
|
||||
return posts.sort((a, b) =>
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
|
||||
// Fallback: index.json + individual files
|
||||
try {
|
||||
const indexRes = await fetch(`/at/${did}/${collection}/index.json`)
|
||||
if (indexRes.ok && isJsonResponse(indexRes)) {
|
||||
@@ -160,87 +200,53 @@ async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
} catch { /* not found */ }
|
||||
return []
|
||||
}
|
||||
|
||||
// Load posts (local only for admin, remote for others)
|
||||
export async function getPosts(did: string, collection: string, localOnly = false): Promise<Post[]> {
|
||||
// Try local first
|
||||
// Try local first (uses special index.json + Promise.all logic)
|
||||
const local = await getLocalPosts(did, collection)
|
||||
if (local.length > 0) return local
|
||||
|
||||
// If local only mode, don't call API
|
||||
if (localOnly) return []
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return []
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
|
||||
const res = await fetch(url)
|
||||
const res = await fetchWithTimeout(url, {}, 8000)
|
||||
if (res.ok) {
|
||||
const data: ListRecordsResponse<Post> = await res.json()
|
||||
return data.records.sort((a, b) =>
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
} catch { /* Failed */ }
|
||||
return []
|
||||
}
|
||||
|
||||
// Get single post (local only for admin, remote for others)
|
||||
export async function getPost(did: string, collection: string, rkey: string, localOnly = false): Promise<Post | null> {
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/${rkey}.json`)
|
||||
if (res.ok && isJsonResponse(res)) return res.json()
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
|
||||
// If local only mode, don't call API
|
||||
if (localOnly) return null
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) return res.json()
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
return fetchLocalThenRemote<Post>(
|
||||
did,
|
||||
`/at/${did}/${collection}/${rkey}.json`,
|
||||
remoteGetRecord(did, collection, rkey),
|
||||
localOnly,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Describe repo - get collections list
|
||||
export async function describeRepo(did: string): Promise<string[]> {
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/describe.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const data = await res.json()
|
||||
return data.collections || []
|
||||
}
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
|
||||
// Remote
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return []
|
||||
|
||||
try {
|
||||
const extractCollections = (data: unknown) => ((data as { collections?: string[] }).collections || []) as unknown as string[]
|
||||
const result = await fetchLocalThenRemote<string[]>(
|
||||
did,
|
||||
`/at/${did}/describe.json`,
|
||||
async (pds: string) => {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
|
||||
const res = await fetchWithTimeout(url, {}, 8000)
|
||||
@@ -248,10 +254,13 @@ export async function describeRepo(did: string): Promise<string[]> {
|
||||
const data = await res.json()
|
||||
return data.collections || []
|
||||
}
|
||||
} catch {
|
||||
// Failed or timeout
|
||||
}
|
||||
return []
|
||||
return null
|
||||
},
|
||||
false,
|
||||
null,
|
||||
extractCollections
|
||||
)
|
||||
return result || []
|
||||
}
|
||||
|
||||
// List records from any collection
|
||||
@@ -393,25 +402,31 @@ export async function getChatMessages(
|
||||
): Promise<ChatMessage[]> {
|
||||
// Load messages for a single DID
|
||||
async function loadForDid(did: string): Promise<ChatMessage[]> {
|
||||
// Try local first
|
||||
// Try local list.json first (single request for all records)
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/list.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
return (await res.json()) as ChatMessage[]
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
|
||||
// Try local index.json + individual files
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/index.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const rkeys: string[] = await res.json()
|
||||
// Load all messages in parallel
|
||||
const msgPromises = rkeys.map(async (rkey) => {
|
||||
const results = await Promise.all(
|
||||
rkeys.map(async (rkey) => {
|
||||
const msgRes = await fetch(`/at/${did}/${collection}/${rkey}.json`)
|
||||
if (msgRes.ok && isJsonResponse(msgRes)) {
|
||||
return msgRes.json() as Promise<ChatMessage>
|
||||
}
|
||||
return null
|
||||
})
|
||||
const results = await Promise.all(msgPromises)
|
||||
)
|
||||
return results.filter((m): m is ChatMessage => m !== null)
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
} catch { /* try remote */ }
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
@@ -420,14 +435,12 @@ export async function getChatMessages(
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
|
||||
const res = await fetch(url)
|
||||
const res = await fetchWithTimeout(url, {}, 8000)
|
||||
if (res.ok) {
|
||||
const data: ListRecordsResponse<ChatMessage> = await res.json()
|
||||
return data.records
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
} catch { /* failed */ }
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -591,33 +604,15 @@ export async function getCards(
|
||||
did: string,
|
||||
collection: string = 'ai.syui.card.user'
|
||||
): Promise<CardCollection | null> {
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/self.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const record = await res.json()
|
||||
return record.value as CardCollection
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const record = await res.json()
|
||||
return record.value as CardCollection
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
const extractValue = (data: unknown) => (data as { value: CardCollection }).value
|
||||
return fetchLocalThenRemote<CardCollection>(
|
||||
did,
|
||||
`/at/${did}/${collection}/self.json`,
|
||||
remoteGetRecord(did, collection, 'self', extractValue),
|
||||
false,
|
||||
null,
|
||||
extractValue
|
||||
)
|
||||
}
|
||||
|
||||
// RSE collection type
|
||||
@@ -639,34 +634,15 @@ export interface RseCollection {
|
||||
// Get user's RSE collection (ai.syui.rse.user)
|
||||
export async function getRse(did: string): Promise<RseCollection | null> {
|
||||
const collection = 'ai.syui.rse.user'
|
||||
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/self.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const record = await res.json()
|
||||
return record.value as RseCollection
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const record = await res.json()
|
||||
return record.value as RseCollection
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
const extractValue = (data: unknown) => (data as { value: RseCollection }).value
|
||||
return fetchLocalThenRemote<RseCollection>(
|
||||
did,
|
||||
`/at/${did}/${collection}/self.json`,
|
||||
remoteGetRecord(did, collection, 'self', extractValue),
|
||||
false,
|
||||
null,
|
||||
extractValue
|
||||
)
|
||||
}
|
||||
|
||||
// VRM collection type
|
||||
@@ -685,34 +661,15 @@ export interface VrmCollection {
|
||||
// Get user's VRM collection (ai.syui.vrm)
|
||||
export async function getVrm(did: string): Promise<VrmCollection | null> {
|
||||
const collection = 'ai.syui.vrm'
|
||||
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/self.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const record = await res.json()
|
||||
return record.value as VrmCollection
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const record = await res.json()
|
||||
return record.value as VrmCollection
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
const extractValue = (data: unknown) => (data as { value: VrmCollection }).value
|
||||
return fetchLocalThenRemote<VrmCollection>(
|
||||
did,
|
||||
`/at/${did}/${collection}/self.json`,
|
||||
remoteGetRecord(did, collection, 'self', extractValue),
|
||||
false,
|
||||
null,
|
||||
extractValue
|
||||
)
|
||||
}
|
||||
|
||||
// Link item type
|
||||
@@ -747,34 +704,15 @@ export interface CardAdminData {
|
||||
// Get card admin data (ai.syui.card.admin)
|
||||
export async function getCardAdmin(did: string): Promise<CardAdminData | null> {
|
||||
const collection = 'ai.syui.card.admin'
|
||||
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/self.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const record = await res.json()
|
||||
return record.value as CardAdminData
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const record = await res.json()
|
||||
return record.value as CardAdminData
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
const extractValue = (data: unknown) => (data as { value: CardAdminData }).value
|
||||
return fetchLocalThenRemote<CardAdminData>(
|
||||
did,
|
||||
`/at/${did}/${collection}/self.json`,
|
||||
remoteGetRecord(did, collection, 'self', extractValue),
|
||||
false,
|
||||
null,
|
||||
extractValue
|
||||
)
|
||||
}
|
||||
|
||||
// RSE admin data types
|
||||
@@ -797,65 +735,27 @@ export interface RseAdminData {
|
||||
// Get RSE admin data (ai.syui.rse.admin)
|
||||
export async function getRseAdmin(did: string): Promise<RseAdminData | null> {
|
||||
const collection = 'ai.syui.rse.admin'
|
||||
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/self.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const record = await res.json()
|
||||
return record.value as RseAdminData
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const record = await res.json()
|
||||
return record.value as RseAdminData
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
const extractValue = (data: unknown) => (data as { value: RseAdminData }).value
|
||||
return fetchLocalThenRemote<RseAdminData>(
|
||||
did,
|
||||
`/at/${did}/${collection}/self.json`,
|
||||
remoteGetRecord(did, collection, 'self', extractValue),
|
||||
false,
|
||||
null,
|
||||
extractValue
|
||||
)
|
||||
}
|
||||
|
||||
// Get user's links (ai.syui.at.link)
|
||||
export async function getLinks(did: string): Promise<LinkCollection | null> {
|
||||
const collection = 'ai.syui.at.link'
|
||||
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/at/${did}/${collection}/self.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const record = await res.json()
|
||||
return record.value as LinkCollection
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const record = await res.json()
|
||||
return record.value as LinkCollection
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
const extractValue = (data: unknown) => (data as { value: LinkCollection }).value
|
||||
return fetchLocalThenRemote<LinkCollection>(
|
||||
did,
|
||||
`/at/${did}/${collection}/self.json`,
|
||||
remoteGetRecord(did, collection, 'self', extractValue),
|
||||
false,
|
||||
null,
|
||||
extractValue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { marked } from 'marked'
|
||||
import hljs from 'highlight.js'
|
||||
import { escapeHtml } from './util'
|
||||
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
@@ -21,16 +22,6 @@ renderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
||||
|
||||
marked.use({ renderer })
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Render markdown to HTML
|
||||
export function renderMarkdown(content: string): string {
|
||||
return marked(content) as string
|
||||
|
||||
104
src/web/lib/util.ts
Normal file
104
src/web/lib/util.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Shared utility functions
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Escape for HTML attributes (same as escapeHtml)
|
||||
export function escapeAttr(text: string): string {
|
||||
return escapeHtml(text)
|
||||
}
|
||||
|
||||
// Format date as yyyy/mm/dd
|
||||
export function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}/${month}/${day}`
|
||||
}
|
||||
|
||||
// Format date/time as mm/dd hh:mm
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hour = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${month}/${day} ${hour}:${min}`
|
||||
}
|
||||
|
||||
// Format date in ja-JP locale
|
||||
export function formatDateJa(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('ja-JP', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// Get current language preference
|
||||
export function getLang(): string {
|
||||
return localStorage.getItem('preferred-lang') || 'ja'
|
||||
}
|
||||
|
||||
// Get localized text from a {ja, en} object
|
||||
export function getLocalizedText(obj: { ja: string; en: string } | undefined): string {
|
||||
if (!obj) return ''
|
||||
const lang = getLang()
|
||||
return obj[lang as 'ja' | 'en'] || obj.ja || obj.en || ''
|
||||
}
|
||||
|
||||
// Get translated content from a record with translations field
|
||||
export function getTranslatedContent(
|
||||
originalText: string,
|
||||
translations: Record<string, { content: string | { text: string } }> | undefined,
|
||||
originalLang: string = 'ja'
|
||||
): string {
|
||||
const currentLang = getLang()
|
||||
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
||||
const translated = translations[currentLang].content
|
||||
if (typeof translated === 'string') return translated
|
||||
if (translated && typeof translated === 'object' && 'text' in translated) return translated.text
|
||||
}
|
||||
return originalText
|
||||
}
|
||||
|
||||
// Extract rkey from AT URI
|
||||
export function getRkeyFromUri(uri: string): string {
|
||||
return uri.split('/').pop() || ''
|
||||
}
|
||||
|
||||
// Extract authority (DID) from AT URI
|
||||
export function getAuthorityFromUri(uri: string): string {
|
||||
return uri.replace('at://', '').split('/')[0]
|
||||
}
|
||||
|
||||
// Async button helper: find button by ID, add click handler with loading state
|
||||
export function setupAsyncButton(
|
||||
id: string,
|
||||
loadingText: string,
|
||||
handler: (btn: HTMLButtonElement) => Promise<void>
|
||||
): void {
|
||||
const btn = document.getElementById(id) as HTMLButtonElement | null
|
||||
if (!btn) return
|
||||
const originalText = btn.textContent || ''
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.textContent = loadingText
|
||||
btn.disabled = true
|
||||
try {
|
||||
await handler(btn)
|
||||
} catch (err) {
|
||||
console.error(`${id} failed:`, err)
|
||||
alert(`Failed: ${err}`)
|
||||
btn.textContent = originalText
|
||||
btn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
996
src/web/main.ts
996
src/web/main.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user