add @user

This commit is contained in:
2026-01-16 16:48:55 +09:00
parent 40815a3b60
commit e704e52761
20 changed files with 328 additions and 41 deletions

View File

@@ -1,6 +1,17 @@
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js'
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlcForPds } from '../lib/api.js'
import { deleteRecord } from '../lib/auth.js'
import { escapeHtml } from '../lib/utils.js'
import type { Networks } from '../types.js'
// Cache networks config
let networksConfig: Networks | null = null
async function loadNetworks(): Promise<Networks> {
if (networksConfig) return networksConfig
const res = await fetch('/networks.json')
networksConfig = await res.json()
return networksConfig!
}
function extractRkey(uri: string): string {
const parts = uri.split('/')
@@ -8,13 +19,15 @@ function extractRkey(uri: string): string {
}
async function renderServices(did: string, handle: string): Promise<string> {
const [collections, pds] = await Promise.all([
const [collections, pds, networks] = await Promise.all([
describeRepo(did),
resolvePds(did)
resolvePds(did),
loadNetworks()
])
// Server info section
const plcUrl = `${getPlc()}/${did}/log`
// Server info section - use PLC based on PDS
const plc = getPlcForPds(pds, networks)
const plcUrl = `${plc}/${did}/log`
const serverHtml = `
<div class="server-info">
<h3>Server</h3>

View File

@@ -13,6 +13,9 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
</svg>
</button>`
// Use logged-in user's handle for input if available
const inputHandle = isLoggedIn && userHandle ? userHandle : currentHandle
return `
<div class="header">
<form class="header-form" id="header-form">
@@ -21,7 +24,7 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan
class="header-input"
id="header-input"
placeholder="handle (e.g., syui.ai)"
value="${currentHandle}"
value="${inputHandle}"
>
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
${loginBtn}

View File

@@ -4,7 +4,7 @@ import { renderMarkdown } from '../lib/markdown.js'
import { escapeHtml, formatDate } from '../lib/utils.js'
import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js'
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
export function mountPostList(container: HTMLElement, posts: BlogPost[], userHandle?: string): void {
if (posts.length === 0) {
container.innerHTML = '<p class="no-posts">No posts yet</p>'
return
@@ -12,9 +12,11 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
const html = posts.map(post => {
const rkey = post.uri.split('/').pop()
// Use /@handle/post/rkey for user pages, /post/rkey for own blog
const postUrl = userHandle ? `/@${userHandle}/post/${rkey}` : `/post/${rkey}`
return `
<li class="post-item">
<a href="/post/${rkey}" class="post-link">
<a href="${postUrl}" class="post-link">
<span class="post-title">${escapeHtml(post.title)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span>
</a>

View File

@@ -14,6 +14,33 @@ export function getPlc(): string {
return networkConfig?.plc || 'https://plc.directory'
}
// Get PLC URL based on PDS endpoint
export function getPlcForPds(pds: string, networks: Record<string, { plc: string; bsky: string; web?: string }>): string {
// Check if PDS matches any network
for (const [_key, config] of Object.entries(networks)) {
// Match by domain (e.g., "https://syu.is" or "https://bsky.syu.is")
try {
const pdsHost = new URL(pds).hostname
const bskyHost = new URL(config.bsky).hostname
// Check if PDS host matches network's bsky host
if (pdsHost === bskyHost || pdsHost.endsWith('.' + bskyHost)) {
return config.plc
}
// Also check web host if available
if (config.web) {
const webHost = new URL(config.web).hostname
if (pdsHost === webHost || pdsHost.endsWith('.' + webHost)) {
return config.plc
}
}
} catch {
continue
}
}
// Default to plc.directory
return 'https://plc.directory'
}
function getBsky(): string {
return networkConfig?.bsky || 'https://public.api.bsky.app'
}

View File

@@ -1,5 +1,5 @@
export interface Route {
type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new'
type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new' | 'user-blog' | 'user-post'
handle?: string
collection?: string
rkey?: string
@@ -33,6 +33,16 @@ export function parseRoute(pathname: string): Route {
return { type: 'new' }
}
// /@${handle} - User blog (any user's posts)
// /@${handle}/post/${rkey} - User post detail
if (parts[0].startsWith('@')) {
const handle = parts[0].slice(1) // Remove @ prefix
if (parts[1] === 'post' && parts[2]) {
return { type: 'user-post', handle, rkey: parts[2] }
}
return { type: 'user-blog', handle }
}
// /at/${handle} - Browser services
// /at/${handle}/${service-or-collection} - Browser collections or records
// /at/${handle}/${collection}/${rkey} - Browser record detail
@@ -75,6 +85,10 @@ export function buildPath(route: Route): string {
return '/new'
case 'post':
return `/post/${route.rkey}`
case 'user-blog':
return `/@${route.handle}`
case 'user-post':
return `/@${route.handle}/post/${route.rkey}`
case 'browser-services':
return `/at/${route.handle}`
case 'browser-collections':

View File

@@ -94,6 +94,40 @@ function renderFooter(handle: string): string {
`
}
// Detect network from handle domain
// e.g., syui.ai → bsky.social, syui.syui.ai → syu.is
function detectNetworkFromHandle(handle: string): string {
const parts = handle.split('.')
if (parts.length >= 2) {
// Get domain part (last 2 parts for most cases)
const domain = parts.slice(-2).join('.')
// Check if domain matches any network key
if (networks[domain]) {
return domain
}
// Check if it's a subdomain of a known network
for (const networkKey of Object.keys(networks)) {
if (handle.endsWith(`.${networkKey}`) || handle.endsWith(networkKey)) {
return networkKey
}
}
}
// Default to bsky.social
return 'bsky.social'
}
function switchNetwork(newNetwork: string): void {
if (newNetwork === browserNetwork) return
browserNetwork = newNetwork
localStorage.setItem('browserNetwork', newNetwork)
const networkConfig = networks[newNetwork]
if (networkConfig) {
setNetworkConfig(networkConfig)
setAuthNetworkConfig(networkConfig)
}
updatePdsSelector()
}
function renderPdsSelector(): string {
const networkKeys = Object.keys(networks)
const options = networkKeys.map(key => {
@@ -220,10 +254,11 @@ function applyTitleTranslations(): void {
}
}
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean, handle?: string): string {
const browserHandle = handle || config.handle
let tabs = `
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
<button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${config.handle}">Browser</button>
<button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${browserHandle}">Browser</button>
`
if (isLoggedIn) {
@@ -242,6 +277,10 @@ function openBrowser(handle: string, service: string | null = null, collection:
if (!contentEl || !tabsEl) return
// Auto-detect and switch network based on handle
const detectedNetwork = detectNetworkFromHandle(handle)
switchNetwork(detectedNetwork)
// Save current content if not already in browser mode
if (!browserMode) {
savedContent = {
@@ -678,8 +717,10 @@ async function render(): Promise<void> {
const handle = route.handle || config.handle
// Skip re-rendering for static blog/post pages (but still mount header for login)
// Exception: if logged in on blog page, re-render to show user's blog
const isStaticRoute = route.type === 'blog' || route.type === 'post'
if (isStatic && isStaticRoute) {
const shouldUseStatic = isStatic && isStaticRoute && !(isLoggedIn && route.type === 'blog')
if (shouldUseStatic) {
// Only mount header for login functionality (pass isStatic=true to skip unnecessary re-render)
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => {
@@ -826,21 +867,83 @@ async function render(): Promise<void> {
}
break
case 'blog':
default:
case 'user-blog':
// /@{handle} - Any user's blog
try {
const profile = await getProfile(config.handle)
const webUrl = networks[config.network]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn)
const userHandle = route.handle!
// Auto-detect and switch network based on handle
const detectedNetwork = detectNetworkFromHandle(userHandle)
switchNetwork(detectedNetwork)
const profile = await getProfile(userHandle)
const webUrl = networks[browserNetwork]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(config.handle)
const servicesHtml = await renderServices(userHandle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const posts = await listRecords(profile.did, config.collection)
mountPostList(contentEl, posts)
mountPostList(contentEl, posts, userHandle)
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
break
case 'user-post':
// /@{handle}/post/{rkey} - Any user's post detail
try {
const userHandle = route.handle!
// Auto-detect and switch network based on handle
const detectedNetwork = detectNetworkFromHandle(userHandle)
switchNetwork(detectedNetwork)
const profile = await getProfile(userHandle)
const webUrl = networks[browserNetwork]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn, userHandle)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(userHandle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const post = await getRecord(profile.did, config.collection, route.rkey!)
if (post) {
const canEdit = isLoggedIn && authSession?.did === profile.did
mountPostDetail(contentEl, post, userHandle, config.collection, canEdit, undefined, browserNetwork)
} else {
contentEl.innerHTML = '<p>Post not found</p>'
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
break
case 'blog':
default:
try {
// If logged in, show logged-in user's blog instead of site owner's
const blogHandle = isLoggedIn ? authSession!.handle : config.handle
const detectedNetwork = isLoggedIn ? detectNetworkFromHandle(blogHandle) : config.network
if (isLoggedIn) {
switchNetwork(detectedNetwork)
}
const profile = await getProfile(blogHandle)
const webUrl = networks[detectedNetwork]?.web
profileEl.innerHTML = renderTabs('blog', isLoggedIn, blogHandle)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile, webUrl)
const servicesHtml = await renderServices(blogHandle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const posts = await listRecords(profile.did, config.collection)
// Use handle for post links if logged in user
mountPostList(contentEl, posts, isLoggedIn ? blogHandle : undefined)
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`