From 43522fdbd3aa197cc21d0635b35ddd64a1a5a73e Mon Sep 17 00:00:00 2001 From: syui Date: Fri, 16 Jan 2026 16:48:55 +0900 Subject: [PATCH] add @user --- public/_redirects | 1 - scripts/generate.ts | 3 + src/components/browser.ts | 5 +- src/components/posts.ts | 6 +- src/lib/router.ts | 16 ++++- src/main.ts | 123 ++++++++++++++++++++++++++++++++++---- 6 files changed, 139 insertions(+), 15 deletions(-) delete mode 100644 public/_redirects diff --git a/public/_redirects b/public/_redirects deleted file mode 100644 index ad37e2c..0000000 --- a/public/_redirects +++ /dev/null @@ -1 +0,0 @@ -/* /index.html 200 diff --git a/scripts/generate.ts b/scripts/generate.ts index d0f64f7..78a128b 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -801,8 +801,11 @@ async function generate() { console.log('Generated: /app.html') // Generate _redirects for Cloudflare Pages (SPA routes) + // Note: Both literal @ and URL-encoded %40 patterns for compatibility const redirects = `/app / 301 /oauth/* /app.html 200 +/@* /app.html 200 +/* /app.html 200 ` fs.writeFileSync(path.join(distDir, '_redirects'), redirects) console.log('Generated: /_redirects') diff --git a/src/components/browser.ts b/src/components/browser.ts index 26ed08b..bf51378 100644 --- a/src/components/browser.ts +++ b/src/components/browser.ts @@ -13,6 +13,9 @@ export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHan ` + // Use logged-in user's handle for input if available + const inputHandle = isLoggedIn && userHandle ? userHandle : currentHandle + return `
@@ -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}" > ${loginBtn} diff --git a/src/components/posts.ts b/src/components/posts.ts index 230c4c5..15e4f83 100644 --- a/src/components/posts.ts +++ b/src/components/posts.ts @@ -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 = '

No posts yet

' 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 `
  • - + ${escapeHtml(post.title)} diff --git a/src/lib/router.ts b/src/lib/router.ts index 40c6d08..4412dcb 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -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': diff --git a/src/main.ts b/src/main.ts index 65ed7ff..4dc70f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 = ` Blog - + ` 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 { 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 { } 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 = `

    Failed to load: ${err}

    ` + } + 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 = '

    Post not found

    ' + } + } catch (err) { + console.error(err) + contentEl.innerHTML = `

    Failed to load: ${err}

    ` + } + 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 = `

    Failed to load: ${err}

    `