init
This commit is contained in:
340
src/web/lib/api.ts
Normal file
340
src/web/lib/api.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
|
||||
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse } from '../types'
|
||||
|
||||
// Cache
|
||||
let configCache: AppConfig | null = null
|
||||
let networksCache: Networks | null = null
|
||||
|
||||
// Load config.json
|
||||
export async function getConfig(): Promise<AppConfig> {
|
||||
if (configCache) return configCache
|
||||
const res = await fetch('/config.json')
|
||||
configCache = await res.json()
|
||||
return configCache!
|
||||
}
|
||||
|
||||
// Load networks.json
|
||||
export async function getNetworks(): Promise<Networks> {
|
||||
if (networksCache) return networksCache
|
||||
const res = await fetch('/networks.json')
|
||||
networksCache = await res.json()
|
||||
return networksCache!
|
||||
}
|
||||
|
||||
// Resolve handle to DID (try all networks)
|
||||
export async function resolveHandle(handle: string): Promise<string | null> {
|
||||
const networks = await getNetworks()
|
||||
|
||||
// Try each network until one succeeds
|
||||
for (const network of Object.values(networks)) {
|
||||
try {
|
||||
const host = network.bsky.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoIdentity.resolveHandle)}?handle=${handle}`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return data.did
|
||||
}
|
||||
} catch {
|
||||
// Try next network
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Get PDS endpoint for DID (try all networks)
|
||||
export async function getPds(did: string): Promise<string | null> {
|
||||
const networks = await getNetworks()
|
||||
|
||||
for (const network of Object.values(networks)) {
|
||||
try {
|
||||
const res = await fetch(`${network.plc}/${did}`)
|
||||
if (res.ok) {
|
||||
const didDoc = await res.json()
|
||||
const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')
|
||||
if (service?.serviceEndpoint) {
|
||||
return service.serviceEndpoint
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try next network
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Load local profile
|
||||
async function getLocalProfile(did: string): Promise<Profile | null> {
|
||||
try {
|
||||
const res = await fetch(`/content/${did}/app.bsky.actor.profile/self.json`)
|
||||
if (res.ok) return res.json()
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Load profile (local first for admin, remote for others)
|
||||
export async function getProfile(did: string, localFirst = true): Promise<Profile | null> {
|
||||
if (localFirst) {
|
||||
const local = await getLocalProfile(did)
|
||||
if (local) return local
|
||||
}
|
||||
|
||||
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 fetch(url)
|
||||
if (res.ok) return res.json()
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Get avatar URL
|
||||
export async function getAvatarUrl(did: string, profile: Profile): Promise<string | null> {
|
||||
if (!profile.value.avatar) return null
|
||||
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return null
|
||||
|
||||
return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}`
|
||||
}
|
||||
|
||||
// Load local posts
|
||||
async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
|
||||
try {
|
||||
const indexRes = await fetch(`/content/${did}/${collection}/index.json`)
|
||||
if (indexRes.ok) {
|
||||
const rkeys: string[] = await indexRes.json()
|
||||
const posts: Post[] = []
|
||||
for (const rkey of rkeys) {
|
||||
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||
if (res.ok) posts.push(await res.json())
|
||||
}
|
||||
return posts.sort((a, b) =>
|
||||
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Load posts (local first for admin, remote for others)
|
||||
export async function getPosts(did: string, collection: string, localFirst = true): Promise<Post[]> {
|
||||
if (localFirst) {
|
||||
const local = await getLocalPosts(did, collection)
|
||||
if (local.length > 0) return local
|
||||
}
|
||||
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return []
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data: ListRecordsResponse<Post> = await res.json()
|
||||
return data.records.sort((a, b) =>
|
||||
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Get single post
|
||||
export async function getPost(did: string, collection: string, rkey: string, localFirst = true): Promise<Post | null> {
|
||||
if (localFirst) {
|
||||
try {
|
||||
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||
if (res.ok) return res.json()
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
export async function describeRepo(did: string): Promise<string[]> {
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/content/${did}/describe.json`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return data.collections || []
|
||||
}
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
|
||||
// Remote
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return []
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return data.collections || []
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// List records from any collection
|
||||
export async function listRecords(did: string, collection: string, limit = 50): Promise<{ uri: string; cid: string; value: unknown }[]> {
|
||||
const pds = await getPds(did)
|
||||
if (!pds) return []
|
||||
|
||||
try {
|
||||
const host = pds.replace('https://', '')
|
||||
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=${limit}`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return data.records || []
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Get single record from any collection
|
||||
export async function getRecord(did: string, collection: string, rkey: string): Promise<{ uri: string; cid: string; value: unknown } | null> {
|
||||
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
|
||||
}
|
||||
|
||||
// Constants for search
|
||||
const SEARCH_TIMEOUT_MS = 5000
|
||||
|
||||
// Get current network config
|
||||
export async function getCurrentNetwork(): Promise<{ plc: string; bsky: string; web: string }> {
|
||||
const config = await getConfig()
|
||||
const networks = await getNetworks()
|
||||
const networkKey = config.network || 'bsky.social'
|
||||
const network = networks[networkKey]
|
||||
return {
|
||||
plc: network?.plc || 'https://plc.directory',
|
||||
bsky: network?.bsky || 'https://public.api.bsky.app',
|
||||
web: network?.web || 'https://bsky.app'
|
||||
}
|
||||
}
|
||||
|
||||
// Get search endpoint for current network
|
||||
async function getSearchEndpoint(): Promise<string> {
|
||||
const network = await getCurrentNetwork()
|
||||
return network.bsky
|
||||
}
|
||||
|
||||
// Search posts that link to a URL
|
||||
export async function searchPostsForUrl(url: string): Promise<SearchPost[]> {
|
||||
// Use current network's endpoint for search
|
||||
const endpoint = await getSearchEndpoint()
|
||||
|
||||
// Extract search-friendly patterns from URL
|
||||
// Note: Search API doesn't index paths well, so search by domain and filter client-side
|
||||
const searchQueries: string[] = []
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
// Search by domain only (paths with / don't return results)
|
||||
searchQueries.push(urlObj.host)
|
||||
} catch {
|
||||
searchQueries.push(url)
|
||||
}
|
||||
|
||||
const allPosts: SearchPost[] = []
|
||||
const seenUris = new Set<string>()
|
||||
|
||||
for (const query of searchQueries) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
|
||||
|
||||
const res = await fetch(
|
||||
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!res.ok) continue
|
||||
|
||||
const data = await res.json()
|
||||
const posts = (data.posts || []).filter((post: SearchPost) => {
|
||||
const embedUri = (post.record as { embed?: { external?: { uri?: string } } })?.embed?.external?.uri
|
||||
const text = (post.record as { text?: string })?.text || ''
|
||||
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
for (const post of posts) {
|
||||
if (!seenUris.has(post.uri)) {
|
||||
seenUris.add(post.uri)
|
||||
allPosts.push(post)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Timeout or network error
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
allPosts.sort((a, b) => {
|
||||
const aDate = (a.record as { createdAt?: string })?.createdAt || ''
|
||||
const bDate = (b.record as { createdAt?: string })?.createdAt || ''
|
||||
return new Date(bDate).getTime() - new Date(aDate).getTime()
|
||||
})
|
||||
|
||||
return allPosts
|
||||
}
|
||||
|
||||
// Search post type
|
||||
export interface SearchPost {
|
||||
uri: string
|
||||
cid: string
|
||||
author: {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
record: unknown
|
||||
}
|
||||
293
src/web/lib/auth.ts
Normal file
293
src/web/lib/auth.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
||||
import { Agent } from '@atproto/api'
|
||||
import { getNetworks } from './api'
|
||||
|
||||
let oauthClient: BrowserOAuthClient | null = null
|
||||
let agent: Agent | null = null
|
||||
let sessionDid: string | null = null
|
||||
let sessionHandle: string | null = null
|
||||
let currentNetworkConfig: { bsky: string; plc: string } | null = null
|
||||
|
||||
// Get client ID based on environment
|
||||
function getClientId(): string {
|
||||
const host = window.location.host
|
||||
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
const port = window.location.port || '5173'
|
||||
const redirectUri = `http://127.0.0.1:${port}/`
|
||||
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
|
||||
}
|
||||
|
||||
return `${window.location.origin}/client-metadata.json`
|
||||
}
|
||||
|
||||
// Set network config (call before login)
|
||||
export async function setNetworkConfig(handle: string): Promise<void> {
|
||||
const networks = await getNetworks()
|
||||
|
||||
for (const [domain, network] of Object.entries(networks)) {
|
||||
if (handle.endsWith(`.${domain}`)) {
|
||||
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
||||
oauthClient = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check syui.ai -> syu.is
|
||||
if (handle.endsWith('.syui.ai')) {
|
||||
const network = networks['syu.is']
|
||||
if (network) {
|
||||
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
||||
oauthClient = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Default to first network
|
||||
const first = Object.values(networks)[0]
|
||||
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
|
||||
oauthClient = null
|
||||
}
|
||||
|
||||
// Initialize OAuth client
|
||||
async function initOAuthClient(): Promise<BrowserOAuthClient> {
|
||||
if (oauthClient) return oauthClient
|
||||
|
||||
const handleResolver = currentNetworkConfig?.bsky || 'https://bsky.social'
|
||||
const plcDirectoryUrl = currentNetworkConfig?.plc || 'https://plc.directory'
|
||||
|
||||
oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: getClientId(),
|
||||
handleResolver,
|
||||
plcDirectoryUrl,
|
||||
})
|
||||
|
||||
return oauthClient
|
||||
}
|
||||
|
||||
// Login with handle
|
||||
export async function login(handle: string): Promise<void> {
|
||||
await setNetworkConfig(handle)
|
||||
|
||||
try {
|
||||
const client = await initOAuthClient()
|
||||
await client.signIn(handle, {
|
||||
scope: 'atproto transition:generic'
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Login failed:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OAuth callback
|
||||
export async function handleCallback(): Promise<string | null> {
|
||||
// Check query params first, then hash fragment
|
||||
let params = new URLSearchParams(window.location.search)
|
||||
|
||||
if (!params.has('code') && !params.has('state')) {
|
||||
// Try hash fragment
|
||||
if (window.location.hash && window.location.hash.length > 1) {
|
||||
params = new URLSearchParams(window.location.hash.slice(1))
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.has('code') && !params.has('state')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Detect network from issuer (iss param) and set config before init
|
||||
const iss = params.get('iss') || ''
|
||||
if (iss && !currentNetworkConfig) {
|
||||
const networks = await getNetworks()
|
||||
for (const [domain, network] of Object.entries(networks)) {
|
||||
if (iss.includes(domain)) {
|
||||
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client = await initOAuthClient()
|
||||
|
||||
// Initialize client to restore state from storage
|
||||
await client.init()
|
||||
|
||||
const result = await client.callback(params)
|
||||
sessionDid = result.session.did
|
||||
|
||||
// Create agent and get handle
|
||||
agent = new Agent(result.session)
|
||||
try {
|
||||
const profile = await agent.getProfile({ actor: sessionDid })
|
||||
sessionHandle = profile.data.handle
|
||||
} catch {
|
||||
// Could not get handle
|
||||
}
|
||||
|
||||
// Clear URL params and hash
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
|
||||
return sessionDid
|
||||
} catch (e) {
|
||||
console.error('OAuth callback error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
export async function logout(): Promise<void> {
|
||||
// Clear module state
|
||||
sessionDid = null
|
||||
sessionHandle = null
|
||||
agent = null
|
||||
oauthClient = null
|
||||
currentNetworkConfig = null
|
||||
|
||||
// Clear all storage
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
|
||||
// Clear IndexedDB (used by OAuth client)
|
||||
try {
|
||||
const databases = await indexedDB.databases()
|
||||
for (const db of databases) {
|
||||
if (db.name) {
|
||||
indexedDB.deleteDatabase(db.name)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// IndexedDB.databases() not supported in some browsers
|
||||
console.warn('Could not clear IndexedDB:', e)
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Restore session from storage
|
||||
export async function restoreSession(): Promise<string | null> {
|
||||
try {
|
||||
// Try to initialize with default network first
|
||||
const networks = await getNetworks()
|
||||
const first = Object.values(networks)[0]
|
||||
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
|
||||
|
||||
const client = await initOAuthClient()
|
||||
const result = await client.init()
|
||||
|
||||
if (result?.session) {
|
||||
sessionDid = result.session.did
|
||||
|
||||
// Create agent and get handle
|
||||
agent = new Agent(result.session)
|
||||
try {
|
||||
const profile = await agent.getProfile({ actor: sessionDid })
|
||||
sessionHandle = profile.data.handle
|
||||
} catch {
|
||||
// Could not get handle
|
||||
}
|
||||
|
||||
return sessionDid
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - no session to restore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if logged in
|
||||
export function isLoggedIn(): boolean {
|
||||
return sessionDid !== null
|
||||
}
|
||||
|
||||
// Get logged in DID
|
||||
export function getLoggedInDid(): string | null {
|
||||
return sessionDid
|
||||
}
|
||||
|
||||
// Get logged in handle
|
||||
export function getLoggedInHandle(): string | null {
|
||||
return sessionHandle
|
||||
}
|
||||
|
||||
// Get agent
|
||||
export function getAgent(): Agent | null {
|
||||
return agent
|
||||
}
|
||||
|
||||
// Create post
|
||||
export async function createPost(
|
||||
collection: string,
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<{ uri: string; cid: string } | null> {
|
||||
if (!agent) return null
|
||||
|
||||
try {
|
||||
const result = await agent.com.atproto.repo.createRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
record: {
|
||||
$type: collection,
|
||||
title,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
return { uri: result.data.uri, cid: result.data.cid }
|
||||
} catch (err) {
|
||||
console.error('Create post error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Update post
|
||||
export async function updatePost(
|
||||
collection: string,
|
||||
rkey: string,
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<{ uri: string; cid: string } | null> {
|
||||
if (!agent) return null
|
||||
|
||||
try {
|
||||
const result = await agent.com.atproto.repo.putRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
record: {
|
||||
$type: collection,
|
||||
title,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
return { uri: result.data.uri, cid: result.data.cid }
|
||||
} catch (err) {
|
||||
console.error('Update post error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete record
|
||||
export async function deleteRecord(
|
||||
collection: string,
|
||||
rkey: string
|
||||
): Promise<boolean> {
|
||||
if (!agent) return false
|
||||
|
||||
try {
|
||||
await agent.com.atproto.repo.deleteRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Delete record error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
37
src/web/lib/markdown.ts
Normal file
37
src/web/lib/markdown.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { marked } from 'marked'
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
// Custom renderer for syntax highlighting
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
const highlighted = hljs.highlight(text, { language: lang }).value
|
||||
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`
|
||||
}
|
||||
const escaped = escapeHtml(text)
|
||||
return `<pre><code>${escaped}</code></pre>`
|
||||
}
|
||||
|
||||
marked.use({ renderer })
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Render markdown to HTML
|
||||
export function renderMarkdown(content: string): string {
|
||||
return marked(content) as string
|
||||
}
|
||||
103
src/web/lib/router.ts
Normal file
103
src/web/lib/router.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export interface Route {
|
||||
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
|
||||
handle?: string
|
||||
rkey?: string
|
||||
service?: string
|
||||
collection?: string
|
||||
}
|
||||
|
||||
// Parse current URL to route
|
||||
export function parseRoute(): Route {
|
||||
const path = window.location.pathname
|
||||
|
||||
// Home: / or /app
|
||||
if (path === '/' || path === '' || path === '/app' || path === '/app/') {
|
||||
return { type: 'home' }
|
||||
}
|
||||
|
||||
// AT-Browser main: /@handle/at or /@handle/at/
|
||||
const atBrowserMatch = path.match(/^\/@([^/]+)\/at\/?$/)
|
||||
if (atBrowserMatch) {
|
||||
return { type: 'atbrowser', handle: atBrowserMatch[1] }
|
||||
}
|
||||
|
||||
// AT-Browser service: /@handle/at/service/domain.tld
|
||||
const serviceMatch = path.match(/^\/@([^/]+)\/at\/service\/([^/]+)$/)
|
||||
if (serviceMatch) {
|
||||
return { type: 'service', handle: serviceMatch[1], service: decodeURIComponent(serviceMatch[2]) }
|
||||
}
|
||||
|
||||
// AT-Browser collection: /@handle/at/collection/namespace.name
|
||||
const collectionMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)$/)
|
||||
if (collectionMatch) {
|
||||
return { type: 'collection', handle: collectionMatch[1], collection: collectionMatch[2] }
|
||||
}
|
||||
|
||||
// AT-Browser record: /@handle/at/collection/namespace.name/rkey
|
||||
const recordMatch = path.match(/^\/@([^/]+)\/at\/collection\/([^/]+)\/([^/]+)$/)
|
||||
if (recordMatch) {
|
||||
return { type: 'record', handle: recordMatch[1], collection: recordMatch[2], rkey: recordMatch[3] }
|
||||
}
|
||||
|
||||
// User page: /@handle or /@handle/
|
||||
const userMatch = path.match(/^\/@([^/]+)\/?$/)
|
||||
if (userMatch) {
|
||||
return { type: 'user', handle: userMatch[1] }
|
||||
}
|
||||
|
||||
// Post form page: /@handle/at/post
|
||||
const postPageMatch = path.match(/^\/@([^/]+)\/at\/post\/?$/)
|
||||
if (postPageMatch) {
|
||||
return { type: 'postpage', handle: postPageMatch[1] }
|
||||
}
|
||||
|
||||
// Post detail page: /@handle/rkey (for config.collection)
|
||||
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
|
||||
if (postMatch) {
|
||||
return { type: 'post', handle: postMatch[1], rkey: postMatch[2] }
|
||||
}
|
||||
|
||||
// Default to home
|
||||
return { type: 'home' }
|
||||
}
|
||||
|
||||
// Navigate to a route
|
||||
export function navigate(route: Route): void {
|
||||
let path = '/'
|
||||
|
||||
if (route.type === 'user' && route.handle) {
|
||||
path = `/@${route.handle}`
|
||||
} else if (route.type === 'postpage' && route.handle) {
|
||||
path = `/@${route.handle}/at/post`
|
||||
} else if (route.type === 'post' && route.handle && route.rkey) {
|
||||
path = `/@${route.handle}/${route.rkey}`
|
||||
} else if (route.type === 'atbrowser' && route.handle) {
|
||||
path = `/@${route.handle}/at`
|
||||
} else if (route.type === 'service' && route.handle && route.service) {
|
||||
path = `/@${route.handle}/at/service/${encodeURIComponent(route.service)}`
|
||||
} else if (route.type === 'collection' && route.handle && route.collection) {
|
||||
path = `/@${route.handle}/at/collection/${route.collection}`
|
||||
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
|
||||
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
// Subscribe to route changes
|
||||
export function onRouteChange(callback: (route: Route) => void): void {
|
||||
const handler = () => callback(parseRoute())
|
||||
window.addEventListener('popstate', handler)
|
||||
|
||||
// Handle link clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
const anchor = target.closest('a')
|
||||
if (anchor && anchor.href.startsWith(window.location.origin)) {
|
||||
e.preventDefault()
|
||||
window.history.pushState({}, '', anchor.href)
|
||||
handler()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user