refact web
This commit is contained in:
@@ -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)?)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
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 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>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.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 {
|
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>
|
||||||
|
|||||||
@@ -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 (isLoggedIn()) {
|
||||||
|
// Note tab only for logged-in admin with note posts
|
||||||
if (isLocalUser && hasNotes) {
|
if (isLocalUser && hasNotes) {
|
||||||
tabs += `<a href="/@${handle}/at/note" class="tab ${activeTab === 'note' ? 'active' : ''}">note</a>`
|
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/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>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '&').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 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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,87 +200,53 @@ 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 || []
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote
|
|
||||||
const pds = await getPds(did)
|
|
||||||
if (!pds) return []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const host = pds.replace('https://', '')
|
const host = pds.replace('https://', '')
|
||||||
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
|
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
|
||||||
const res = await fetchWithTimeout(url, {}, 8000)
|
const res = await fetchWithTimeout(url, {}, 8000)
|
||||||
@@ -248,10 +254,13 @@ export async function describeRepo(did: string): Promise<string[]> {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data.collections || []
|
return data.collections || []
|
||||||
}
|
}
|
||||||
} catch {
|
return null
|
||||||
// Failed or timeout
|
},
|
||||||
}
|
false,
|
||||||
return []
|
null,
|
||||||
|
extractCollections
|
||||||
|
)
|
||||||
|
return result || []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
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