2
0

refact web

This commit is contained in:
2026-03-25 05:58:41 +09:00
parent 9c47732511
commit 3103090c33
16 changed files with 1241 additions and 1365 deletions

View File

@@ -125,6 +125,19 @@ pub fn run(content_dir: &Path) -> Result<()> {
total_updated += 1; 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)?)?;
} }
} }

View File

@@ -1,4 +1,5 @@
// AT-Browser: Server info and collection hierarchy // AT-Browser: Server info and collection hierarchy
import { escapeHtml } from '../lib/util'
// Group collections by service domain // Group collections by service domain
function groupCollectionsByService(collections: string[]): Map<string, string[]> { function groupCollectionsByService(collections: string[]): Map<string, string[]> {
@@ -241,11 +242,3 @@ function getRecordPreview(value: Record<string, unknown>): string {
return '' return ''
} }
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

View File

@@ -1,4 +1,5 @@
// Card display component for ai.syui.card.user collection // Card display component for ai.syui.card.user collection
import { getLocalizedText } from '../lib/util'
export interface UserCard { export interface UserCard {
id: number id: number
@@ -29,18 +30,6 @@ export interface CardAdminData {
card: CardAdminEntry[] 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 // Get rarity class name
function getRarityClass(card: UserCard): string { function getRarityClass(card: UserCard): string {
if (card.unique) return 'unique' if (card.unique) return 'unique'

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

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

View File

@@ -1,65 +1,28 @@
import type { ChatMessage, Profile } from '../types' import type { ChatMessage, Profile } from '../types'
import { renderMarkdown } from '../lib/markdown' import { getTranslatedContent as getTranslation } from '../lib/util'
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]
}
// Profile info for authors // Profile info for authors
interface AuthorInfo { export interface AuthorInfo {
did: string did: string
handle: string handle: string
avatarUrl?: 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 // Handle/DID resolver: maps both handle and DID to DID
let handleToDidMap = new Map<string, string>() let handleToDidMap = new Map<string, string>()
function resolveAuthorDid(authority: string): string { export function resolveAuthorDid(authority: string): string {
return handleToDidMap.get(authority) || authority return handleToDidMap.get(authority) || authority
} }
// Build author info map // Build author info map
function buildAuthorMap( export function buildAuthorMap(
userDid: string, userDid: string,
userHandle: string, userHandle: string,
botDid: string, botDid: string,
@@ -97,330 +60,17 @@ function buildAuthorMap(
} }
// Map collection name to chat type slug // 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' if (chatCollection === 'ai.syui.ue.chat') return 'ue'
return 'log' return 'log'
} }
// Render chat threads list (conversations this user started) // Get translated content for a chat message
export function renderChatThreadList( export function getTranslatedContent(msg: ChatMessage): string {
messages: ChatMessage[], const originalLang = msg.value.langs?.[0] || 'ja'
userDid: string, return getTranslation(msg.value.content.text, msg.value.translations, originalLang)
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 single chat thread (full conversation) // Re-export from split modules
export function renderChatThread( export { renderChatThreadList, renderChatListPage } from './chat-list'
messages: ChatMessage[], export { renderChatThread, renderChatThreadPage, renderChatEditForm } from './chat-thread'
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>
`
}

View File

@@ -1,24 +1,8 @@
import { searchPostsForUrl, getCurrentNetwork, type SearchPost } from '../lib/api' import { searchPostsForUrl, getCurrentNetwork, type SearchPost } from '../lib/api'
import { escapeHtml, formatDateJa } from '../lib/util'
const DISCUSSION_POST_LIMIT = 10 const DISCUSSION_POST_LIMIT = 10
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function getPostUrl(uri: string, appUrl: string): string { function getPostUrl(uri: string, appUrl: string): string {
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey // at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
const parts = uri.replace('at://', '').split('/') 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-name">${escapeHtml(displayName)}</span>
<span class="discussion-handle">@${escapeHtml(handle)}</span> <span class="discussion-handle">@${escapeHtml(handle)}</span>
</div> </div>
<span class="discussion-date">${formatDate(createdAt)}</span> <span class="discussion-date">${formatDateJa(createdAt)}</span>
</div> </div>
<div class="discussion-text">${escapeHtml(truncatedText)}</div> <div class="discussion-text">${escapeHtml(truncatedText)}</div>
</a> </a>

View File

@@ -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>` tabs += `<a href="/@${handle}/at/chat" class="tab ${activeTab === 'chat' ? 'active' : ''}">chat</a>`
} }
// Note tab only for local user (admin) with note posts
if (isLocalUser && hasNotes) {
tabs += `<a href="/@${handle}/at/note" class="tab ${activeTab === 'note' ? 'active' : ''}">note</a>`
}
if (isLoggedIn()) { 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>`
}
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>` 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>` tabs += `<a href="/@${handle}/at/link" class="tab ${activeTab === 'link' ? 'active' : ''}">link</a>`
} }

View File

@@ -1,5 +1,6 @@
import { renderMarkdown } from '../lib/markdown' import { renderMarkdown } from '../lib/markdown'
import type { Post } from '../types' import type { Post } from '../types'
import { escapeHtml, escapeAttr, formatDateJa } from '../lib/util'
// Note post has extra fields for member content // Note post has extra fields for member content
interface NotePost extends Post { interface NotePost extends Post {
@@ -42,12 +43,10 @@ export function renderNoteListPage(posts: NotePost[], handle: string): string {
export function renderNoteDetailPage( export function renderNoteDetailPage(
post: NotePost, post: NotePost,
_handle: string, _handle: string,
localOnly: boolean isOwner: boolean
): string { ): string {
const rkey = post.uri.split('/').pop() || '' const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.publishedAt).toLocaleDateString('ja-JP', { const date = formatDateJa(post.value.publishedAt)
year: 'numeric', month: '2-digit', day: '2-digit'
})
const freeText = post.value.content?.text || '' const freeText = post.value.content?.text || ''
const memberText = post.value.member?.text || '' const memberText = post.value.member?.text || ''
const bonusText = post.value.member?.bonus || '' const bonusText = post.value.member?.bonus || ''
@@ -59,7 +58,7 @@ export function renderNoteDetailPage(
let html = '' let html = ''
// Action buttons at top // Action buttons at top
if (localOnly) { if (isOwner) {
html += ` html += `
<div class="note-actions"> <div class="note-actions">
<button type="button" class="note-copy-btn" id="note-copy-title">Copy Title</button> <button type="button" class="note-copy-btn" id="note-copy-title">Copy Title</button>
@@ -103,7 +102,7 @@ export function renderNoteDetailPage(
html += `</div>` html += `</div>`
// Edit form (below content) // Edit form (below content)
if (localOnly) { if (isOwner) {
html += ` html += `
<div class="note-edit" id="note-edit-form" style="display:none"> <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"> <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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -1,7 +1,7 @@
import type { Post } from '../types' import type { Post } from '../types'
import { renderMarkdown } from '../lib/markdown' import { renderMarkdown } from '../lib/markdown'
import { renderDiscussion, loadDiscussionPosts } from './discussion' import { 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) // Extract text content from post content (handles both string and object formats)
function getContentText(content: unknown): string { function getContentText(content: unknown): string {
@@ -14,22 +14,13 @@ function getContentText(content: unknown): string {
return '' 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 // Render post list
export function renderPostList(posts: Post[], handle: string): string { export function renderPostList(posts: Post[], handle: string): string {
if (posts.length === 0) { if (posts.length === 0) {
return '<p class="no-posts">No posts yet.</p>' return '<p class="no-posts">No posts yet.</p>'
} }
const currentLang = getCurrentLang() const currentLang = getLang()
const items = posts.map(post => { const items = posts.map(post => {
const rkey = post.uri.split('/').pop() || '' 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>` : '' const editBtn = isOwner ? `<button type="button" class="post-edit-btn" id="post-edit-btn">Edit</button>` : ''
// Get current language and show appropriate content // Get current language and show appropriate content
const currentLang = getCurrentLang() const currentLang = getLang()
const translations = post.value.translations const translations = post.value.translations
const originalLang = post.value.langs?.[0] || 'ja' const originalLang = post.value.langs?.[0] || 'ja'
@@ -144,11 +135,3 @@ export function mountPostDetail(container: HTMLElement, html: string): void {
container.innerHTML = html container.innerHTML = html
} }
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

View File

@@ -1,5 +1,6 @@
import type { Profile } from '../types' import type { Profile } from '../types'
import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api' import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api'
import { escapeHtml } from '../lib/util'
// Service definitions for profile icons // Service definitions for profile icons
export interface ServiceLink { export interface ServiceLink {
@@ -122,11 +123,3 @@ export function mountProfile(container: HTMLElement, html: string): void {
container.innerHTML = html container.innerHTML = html
} }
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

View File

@@ -1,5 +1,6 @@
// RSE display component for ai.syui.rse.user collection // RSE display component for ai.syui.rse.user collection
import { renderCard, type UserCard, type CardAdminEntry, type CardAdminData } from './card' import { renderCard, type UserCard, type CardAdminEntry, type CardAdminData } from './card'
import { getLocalizedText } from '../lib/util'
export interface RseAdminItem { export interface RseAdminItem {
id: number id: number
@@ -25,18 +26,6 @@ export interface RseCollection {
updatedAt: string 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 // Get rarity class from unique flag
function getRarityClass(item: RseItem): string { function getRarityClass(item: RseItem): string {
if (item.unique) return 'unique' if (item.unique) return 'unique'

View File

@@ -17,6 +17,7 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeout
// Cache // Cache
let configCache: AppConfig | null = null let configCache: AppConfig | null = null
let networksCache: Networks | null = null let networksCache: Networks | null = null
const pdsCache = new Map<string, string | null>()
// Load config.json // Load config.json
export async function getConfig(): Promise<AppConfig> { export async function getConfig(): Promise<AppConfig> {
@@ -55,8 +56,10 @@ export async function resolveHandle(handle: string): Promise<string | null> {
return 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> { export async function getPds(did: string): Promise<string | null> {
if (pdsCache.has(did)) return pdsCache.get(did)!
const networks = await getNetworks() const networks = await getNetworks()
for (const network of Object.values(networks)) { 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 didDoc = await res.json()
const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer') const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')
if (service?.serviceEndpoint) { if (service?.serviceEndpoint) {
pdsCache.set(did, service.serviceEndpoint)
return service.serviceEndpoint return service.serviceEndpoint
} }
} }
@@ -73,6 +77,7 @@ export async function getPds(did: string): Promise<string | null> {
// Try next network (timeout or error) // Try next network (timeout or error)
} }
} }
pdsCache.set(did, null)
return null return null
} }
@@ -82,39 +87,62 @@ function isJsonResponse(res: Response): boolean {
return contentType?.includes('application/json') ?? false return contentType?.includes('application/json') ?? false
} }
// Load local profile // Generic local-then-remote fetch helper
async function getLocalProfile(did: string): Promise<Profile | null> { 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 { try {
const res = await fetch(`/at/${did}/app.bsky.actor.profile/self.json`) const res = await fetch(localPath)
if (res.ok && isJsonResponse(res)) return res.json() if (res.ok && isJsonResponse(res)) {
} catch { const data = await res.json()
// Not found 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
} }
return null
} }
// Load profile (local only for admin, remote for others) // Load profile (local only for admin, remote for others)
export async function getProfile(did: string, localOnly = false): Promise<Profile | null> { export async function getProfile(did: string, localOnly = false): Promise<Profile | null> {
// Try local first return fetchLocalThenRemote<Profile>(
const local = await getLocalProfile(did) did,
if (local) return local `/at/${did}/app.bsky.actor.profile/self.json`,
remoteGetRecord(did, 'app.bsky.actor.profile', 'self'),
// If local only mode, don't call API localOnly,
if (localOnly) return null 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
} }
// Get avatar URL (local only for admin, remote for others) // 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 // Load local posts
async function getLocalPosts(did: string, collection: string): Promise<Post[]> { 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 { try {
const indexRes = await fetch(`/at/${did}/${collection}/index.json`) const indexRes = await fetch(`/at/${did}/${collection}/index.json`)
if (indexRes.ok && isJsonResponse(indexRes)) { if (indexRes.ok && isJsonResponse(indexRes)) {
@@ -160,98 +200,67 @@ async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime() new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
) )
} }
} catch { } catch { /* not found */ }
// Not found
}
return [] return []
} }
// Load posts (local only for admin, remote for others) // Load posts (local only for admin, remote for others)
export async function getPosts(did: string, collection: string, localOnly = false): Promise<Post[]> { 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) const local = await getLocalPosts(did, collection)
if (local.length > 0) return local if (local.length > 0) return local
// If local only mode, don't call API
if (localOnly) return [] if (localOnly) return []
// Remote fallback
const pds = await getPds(did) const pds = await getPds(did)
if (!pds) return [] if (!pds) return []
try { try {
const host = pds.replace('https://', '') const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100` 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) { if (res.ok) {
const data: ListRecordsResponse<Post> = await res.json() const data: ListRecordsResponse<Post> = await res.json()
return data.records.sort((a, b) => return data.records.sort((a, b) =>
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime() new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
) )
} }
} catch { } catch { /* Failed */ }
// Failed
}
return [] return []
} }
// Get single post (local only for admin, remote for others) // 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> { export async function getPost(did: string, collection: string, rkey: string, localOnly = false): Promise<Post | null> {
// Try local first return fetchLocalThenRemote<Post>(
try { did,
const res = await fetch(`/at/${did}/${collection}/${rkey}.json`) `/at/${did}/${collection}/${rkey}.json`,
if (res.ok && isJsonResponse(res)) return res.json() remoteGetRecord(did, collection, rkey),
} catch { localOnly,
// Not found null
} )
// 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
} }
// Describe repo - get collections list // Describe repo - get collections list
export async function describeRepo(did: string): Promise<string[]> { export async function describeRepo(did: string): Promise<string[]> {
// Try local first const extractCollections = (data: unknown) => ((data as { collections?: string[] }).collections || []) as unknown as string[]
try { const result = await fetchLocalThenRemote<string[]>(
const res = await fetch(`/at/${did}/describe.json`) did,
if (res.ok && isJsonResponse(res)) { `/at/${did}/describe.json`,
const data = await res.json() async (pds: string) => {
return data.collections || [] const host = pds.replace('https://', '')
} const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
} catch { const res = await fetchWithTimeout(url, {}, 8000)
// Not found if (res.ok) {
} const data = await res.json()
return data.collections || []
// Remote }
const pds = await getPds(did) return null
if (!pds) return [] },
false,
try { null,
const host = pds.replace('https://', '') extractCollections
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}` )
const res = await fetchWithTimeout(url, {}, 8000) return result || []
if (res.ok) {
const data = await res.json()
return data.collections || []
}
} catch {
// Failed or timeout
}
return []
} }
// List records from any collection // List records from any collection
@@ -393,25 +402,31 @@ export async function getChatMessages(
): Promise<ChatMessage[]> { ): Promise<ChatMessage[]> {
// Load messages for a single DID // Load messages for a single DID
async function loadForDid(did: string): Promise<ChatMessage[]> { 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 { try {
const res = await fetch(`/at/${did}/${collection}/index.json`) const res = await fetch(`/at/${did}/${collection}/index.json`)
if (res.ok && isJsonResponse(res)) { if (res.ok && isJsonResponse(res)) {
const rkeys: string[] = await res.json() const rkeys: string[] = await res.json()
// Load all messages in parallel const results = await Promise.all(
const msgPromises = rkeys.map(async (rkey) => { rkeys.map(async (rkey) => {
const msgRes = await fetch(`/at/${did}/${collection}/${rkey}.json`) const msgRes = await fetch(`/at/${did}/${collection}/${rkey}.json`)
if (msgRes.ok && isJsonResponse(msgRes)) { if (msgRes.ok && isJsonResponse(msgRes)) {
return msgRes.json() as Promise<ChatMessage> return msgRes.json() as Promise<ChatMessage>
} }
return null return null
}) })
const results = await Promise.all(msgPromises) )
return results.filter((m): m is ChatMessage => m !== null) return results.filter((m): m is ChatMessage => m !== null)
} }
} catch { } catch { /* try remote */ }
// Try remote
}
// Remote fallback // Remote fallback
const pds = await getPds(did) const pds = await getPds(did)
@@ -420,14 +435,12 @@ export async function getChatMessages(
try { try {
const host = pds.replace('https://', '') const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100` 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) { if (res.ok) {
const data: ListRecordsResponse<ChatMessage> = await res.json() const data: ListRecordsResponse<ChatMessage> = await res.json()
return data.records return data.records
} }
} catch { } catch { /* failed */ }
// Failed
}
return [] return []
} }
@@ -591,33 +604,15 @@ export async function getCards(
did: string, did: string,
collection: string = 'ai.syui.card.user' collection: string = 'ai.syui.card.user'
): Promise<CardCollection | null> { ): Promise<CardCollection | null> {
// Try local first const extractValue = (data: unknown) => (data as { value: CardCollection }).value
try { return fetchLocalThenRemote<CardCollection>(
const res = await fetch(`/at/${did}/${collection}/self.json`) did,
if (res.ok && isJsonResponse(res)) { `/at/${did}/${collection}/self.json`,
const record = await res.json() remoteGetRecord(did, collection, 'self', extractValue),
return record.value as CardCollection false,
} null,
} catch { extractValue
// 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
} }
// RSE collection type // RSE collection type
@@ -639,34 +634,15 @@ export interface RseCollection {
// Get user's RSE collection (ai.syui.rse.user) // Get user's RSE collection (ai.syui.rse.user)
export async function getRse(did: string): Promise<RseCollection | null> { export async function getRse(did: string): Promise<RseCollection | null> {
const collection = 'ai.syui.rse.user' const collection = 'ai.syui.rse.user'
const extractValue = (data: unknown) => (data as { value: RseCollection }).value
// Try local first return fetchLocalThenRemote<RseCollection>(
try { did,
const res = await fetch(`/at/${did}/${collection}/self.json`) `/at/${did}/${collection}/self.json`,
if (res.ok && isJsonResponse(res)) { remoteGetRecord(did, collection, 'self', extractValue),
const record = await res.json() false,
return record.value as RseCollection null,
} extractValue
} 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
} }
// VRM collection type // VRM collection type
@@ -685,34 +661,15 @@ export interface VrmCollection {
// Get user's VRM collection (ai.syui.vrm) // Get user's VRM collection (ai.syui.vrm)
export async function getVrm(did: string): Promise<VrmCollection | null> { export async function getVrm(did: string): Promise<VrmCollection | null> {
const collection = 'ai.syui.vrm' const collection = 'ai.syui.vrm'
const extractValue = (data: unknown) => (data as { value: VrmCollection }).value
// Try local first return fetchLocalThenRemote<VrmCollection>(
try { did,
const res = await fetch(`/at/${did}/${collection}/self.json`) `/at/${did}/${collection}/self.json`,
if (res.ok && isJsonResponse(res)) { remoteGetRecord(did, collection, 'self', extractValue),
const record = await res.json() false,
return record.value as VrmCollection null,
} extractValue
} 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
} }
// Link item type // Link item type
@@ -747,34 +704,15 @@ export interface CardAdminData {
// Get card admin data (ai.syui.card.admin) // Get card admin data (ai.syui.card.admin)
export async function getCardAdmin(did: string): Promise<CardAdminData | null> { export async function getCardAdmin(did: string): Promise<CardAdminData | null> {
const collection = 'ai.syui.card.admin' const collection = 'ai.syui.card.admin'
const extractValue = (data: unknown) => (data as { value: CardAdminData }).value
// Try local first return fetchLocalThenRemote<CardAdminData>(
try { did,
const res = await fetch(`/at/${did}/${collection}/self.json`) `/at/${did}/${collection}/self.json`,
if (res.ok && isJsonResponse(res)) { remoteGetRecord(did, collection, 'self', extractValue),
const record = await res.json() false,
return record.value as CardAdminData null,
} extractValue
} 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
} }
// RSE admin data types // RSE admin data types
@@ -797,65 +735,27 @@ export interface RseAdminData {
// Get RSE admin data (ai.syui.rse.admin) // Get RSE admin data (ai.syui.rse.admin)
export async function getRseAdmin(did: string): Promise<RseAdminData | null> { export async function getRseAdmin(did: string): Promise<RseAdminData | null> {
const collection = 'ai.syui.rse.admin' const collection = 'ai.syui.rse.admin'
const extractValue = (data: unknown) => (data as { value: RseAdminData }).value
// Try local first return fetchLocalThenRemote<RseAdminData>(
try { did,
const res = await fetch(`/at/${did}/${collection}/self.json`) `/at/${did}/${collection}/self.json`,
if (res.ok && isJsonResponse(res)) { remoteGetRecord(did, collection, 'self', extractValue),
const record = await res.json() false,
return record.value as RseAdminData null,
} extractValue
} 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
} }
// Get user's links (ai.syui.at.link) // Get user's links (ai.syui.at.link)
export async function getLinks(did: string): Promise<LinkCollection | null> { export async function getLinks(did: string): Promise<LinkCollection | null> {
const collection = 'ai.syui.at.link' const collection = 'ai.syui.at.link'
const extractValue = (data: unknown) => (data as { value: LinkCollection }).value
// Try local first return fetchLocalThenRemote<LinkCollection>(
try { did,
const res = await fetch(`/at/${did}/${collection}/self.json`) `/at/${did}/${collection}/self.json`,
if (res.ok && isJsonResponse(res)) { remoteGetRecord(did, collection, 'self', extractValue),
const record = await res.json() false,
return record.value as LinkCollection null,
} extractValue
} 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
} }

View File

@@ -1,5 +1,6 @@
import { marked } from 'marked' import { marked } from 'marked'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import { escapeHtml } from './util'
// Configure marked // Configure marked
marked.setOptions({ marked.setOptions({
@@ -21,16 +22,6 @@ renderer.code = function({ text, lang }: { text: string; lang?: string }) {
marked.use({ renderer }) marked.use({ renderer })
// Escape HTML
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// Render markdown to HTML // Render markdown to HTML
export function renderMarkdown(content: string): string { export function renderMarkdown(content: string): string {
return marked(content) as string return marked(content) as string

104
src/web/lib/util.ts Normal file
View File

@@ -0,0 +1,104 @@
// Shared utility functions
// Escape HTML to prevent XSS
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// 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
}
})
}

File diff suppressed because it is too large Load Diff