fix oauth

This commit is contained in:
2026-01-18 14:26:43 +09:00
parent 1fd619e32b
commit 3c55d7feae
27 changed files with 3487 additions and 8 deletions

View File

@@ -1,4 +1,20 @@
{
"name": "ailog",
"version": "0.2.0"
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/oauth-client-browser": "^0.3.19",
"marked": "^15.0.6",
"highlight.js": "^11.11.1"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

3
public/_redirects Normal file
View File

@@ -0,0 +1,3 @@
/app/* /index.html 200
/oauth/* /index.html 200
/* /index.html 200

16
public/ai.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,12 @@
{
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "syui.ai",
"client_uri": "https://syui.ai",
"redirect_uris": ["https://syui.ai/oauth/callback"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"application_type": "web",
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
}

8
public/config.json Normal file
View File

@@ -0,0 +1,8 @@
{
"title": "syui.ai",
"handle": "syui.syui.ai",
"collection": "ai.syui.log.post",
"network": "syu.is",
"color": "#EF454A",
"siteUrl": "https://syui.ai"
}

12
public/networks.json Normal file
View File

@@ -0,0 +1,12 @@
{
"bsky.social": {
"plc": "https://plc.directory",
"bsky": "https://public.api.bsky.app",
"web": "https://bsky.app"
},
"syu.is": {
"plc": "https://plc.syu.is",
"bsky": "https://bsky.syu.is",
"web": "https://syu.is"
}
}

View File

@@ -312,6 +312,35 @@ cargo build
}
```
## Assets
### PNG to SVG Conversion (Vector Trace)
Convert PNG images to true vector SVG using vtracer (Rust):
```bash
# Install vtracer
cargo install vtracer
# Convert PNG to SVG (color mode)
vtracer --input input.png --output output.svg --colormode color
# Convert PNG to SVG (black and white)
vtracer --input input.png --output output.svg
```
**Options:**
- `--colormode color` : Preserve colors (recommended for icons)
- `--colormode binary` : Black and white only
- `--filter_speckle 4` : Remove small artifacts
- `--corner_threshold 60` : Adjust corner detection
**Alternative tools:**
- potrace: `potrace input.pbm -s -o output.svg` (B&W only, requires PBM input)
- Inkscape CLI: `inkscape input.png --export-type=svg` (embeds image, no trace)
**Note:** Inkscape's CLI `--export-type=svg` only embeds the PNG, it does not trace. For true vectorization, use vtracer or potrace.
## License
MIT

View File

@@ -21,7 +21,7 @@ struct EndpointInfo {
method: String, // GET or POST
}
/// Generate lexicon Rust code from ATProto lexicon JSON files
/// Generate lexicon code from ATProto lexicon JSON files
pub fn generate(input: &str, output: &str) -> Result<()> {
let input_path = Path::new(input);
@@ -47,14 +47,20 @@ pub fn generate(input: &str, output: &str) -> Result<()> {
}
// Generate Rust code
let code = generate_rust_code(&namespaces);
// Write output
let output_path = Path::new(output).join("mod.rs");
let rust_code = generate_rust_code(&namespaces);
let rust_output_path = Path::new(output).join("mod.rs");
fs::create_dir_all(output)?;
fs::write(&output_path, &code)?;
fs::write(&rust_output_path, &rust_code)?;
println!("Generated Rust: {}", rust_output_path.display());
// Generate TypeScript code
let ts_output = output.replace("src/lexicons", "src/web/lexicons");
let ts_code = generate_typescript_code(&namespaces);
let ts_output_path = Path::new(&ts_output).join("index.ts");
fs::create_dir_all(&ts_output)?;
fs::write(&ts_output_path, &ts_code)?;
println!("Generated TypeScript: {}", ts_output_path.display());
println!("Generated: {}", output_path.display());
println!("Total namespaces: {}", namespaces.len());
let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum();
println!("Total endpoints: {}", total_endpoints);
@@ -178,6 +184,52 @@ fn generate_rust_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> Strin
code
}
fn generate_typescript_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
let mut code = String::new();
// Header
code.push_str("// Auto-generated from ATProto lexicons\n");
code.push_str("// Run `ailog gen` to regenerate\n");
code.push_str("// Do not edit manually\n\n");
// Endpoint type
code.push_str("export interface Endpoint {\n");
code.push_str(" nsid: string\n");
code.push_str(" method: 'GET' | 'POST'\n");
code.push_str("}\n\n");
// URL helper function
code.push_str("/** Build XRPC URL for an endpoint */\n");
code.push_str("export function xrpcUrl(pds: string, endpoint: Endpoint): string {\n");
code.push_str(" return `https://${pds}/xrpc/${endpoint.nsid}`\n");
code.push_str("}\n\n");
// Generate namespaces
for (ns, endpoints) in namespaces {
// Convert namespace to object name: com.atproto.repo -> comAtprotoRepo
let obj_name = to_camel_case(&ns.replace('.', "_"));
code.push_str(&format!("export const {} = {{\n", obj_name));
for endpoint in endpoints {
// Extract the method name from NSID: com.atproto.repo.listRecords -> listRecords
let method_name = endpoint.nsid
.rsplit('.')
.next()
.unwrap_or(&endpoint.nsid);
code.push_str(&format!(
" {}: {{ nsid: '{}', method: '{}' }} as Endpoint,\n",
method_name, endpoint.nsid, endpoint.method
));
}
code.push_str("} as const\n\n");
}
code
}
fn to_screaming_snake_case(s: &str) -> String {
let mut result = String::new();
@@ -190,3 +242,23 @@ fn to_screaming_snake_case(s: &str) -> String {
result
}
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for (i, c) in s.chars().enumerate() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else if i == 0 {
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}

View File

@@ -0,0 +1,205 @@
// AT-Browser: Server info and collection hierarchy
// Group collections by service domain
function groupCollectionsByService(collections: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>()
for (const collection of collections) {
// Extract service from collection (e.g., "app.bsky.feed.post" -> "bsky.app")
const parts = collection.split('.')
let service: string
if (parts.length >= 2) {
// Reverse first two parts: app.bsky -> bsky.app, ai.syui -> syui.ai
service = `${parts[1]}.${parts[0]}`
} else {
service = collection
}
if (!groups.has(service)) {
groups.set(service, [])
}
groups.get(service)!.push(collection)
}
return groups
}
// Get favicon URL for service
function getFaviconUrl(service: string): string {
return `https://www.google.com/s2/favicons?domain=${service}&sz=32`
}
// Render compact collection buttons for user page (horizontal)
export function renderCollectionButtons(collections: string[], handle: string): string {
if (collections.length === 0) {
return ''
}
const groups = groupCollectionsByService(collections)
const buttons = Array.from(groups.keys()).map(service => {
const favicon = getFaviconUrl(service)
return `
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="collection-btn" title="${service}">
<img src="${favicon}" alt="" class="collection-btn-icon" onerror="this.style.display='none'">
<span>${service}</span>
</a>
`
}).join('')
return `<div class="collection-buttons">${buttons}</div>`
}
// Render server info section (for AT-Browser)
export function renderServerInfo(did: string, pds: string | null): string {
return `
<div class="server-info">
<h3>Server</h3>
<dl class="server-details">
<div class="server-row">
<dt>PDS</dt>
<dd>${pds || 'Unknown'}</dd>
</div>
<div class="server-row">
<dt>DID</dt>
<dd>${did}</dd>
</div>
</dl>
</div>
`
}
// Render service list (grouped collections) for AT-Browser
export function renderServiceList(collections: string[], handle: string): string {
if (collections.length === 0) {
return '<p class="no-collections">No collections found.</p>'
}
const groups = groupCollectionsByService(collections)
const items = Array.from(groups.entries()).map(([service, cols]) => {
const favicon = getFaviconUrl(service)
const count = cols.length
return `
<li class="service-list-item">
<a href="/@${handle}/at/service/${encodeURIComponent(service)}" class="service-list-link">
<img src="${favicon}" alt="" class="service-list-favicon" onerror="this.style.display='none'">
<span class="service-list-name">${service}</span>
<span class="service-list-count">${count}</span>
</a>
</li>
`
}).join('')
return `
<div class="services-list">
<h3>Collections</h3>
<ul class="service-list">${items}</ul>
</div>
`
}
// Render collections for a specific service
export function renderCollectionList(
collections: string[],
handle: string,
service: string
): string {
const favicon = getFaviconUrl(service)
const items = collections.map(collection => {
return `
<li class="collection-item">
<a href="/@${handle}/at/collection/${collection}" class="collection-link">
<span class="collection-nsid">${collection}</span>
</a>
</li>
`
}).join('')
return `
<div class="collections">
<h3 class="collection-header">
<img src="${favicon}" alt="" class="collection-header-favicon" onerror="this.style.display='none'">
${service}
</h3>
<ul class="collection-list">${items}</ul>
</div>
`
}
// Render records list
export function renderRecordList(
records: { uri: string; cid: string; value: unknown }[],
handle: string,
collection: string
): string {
if (records.length === 0) {
return '<p class="no-records">No records found.</p>'
}
const items = records.map(record => {
const rkey = record.uri.split('/').pop() || ''
const value = record.value as Record<string, unknown>
const preview = getRecordPreview(value)
return `
<li class="record-item">
<a href="/@${handle}/at/collection/${collection}/${rkey}" class="record-link">
<span class="record-rkey">${rkey}</span>
<span class="record-preview">${escapeHtml(preview)}</span>
</a>
</li>
`
}).join('')
return `
<div class="records">
<h3>${collection}</h3>
<p class="record-count">${records.length} records</p>
<ul class="record-list">${items}</ul>
</div>
`
}
// Render single record detail
export function renderRecordDetail(
record: { uri: string; cid: string; value: unknown },
collection: string
): string {
return `
<article class="record-detail">
<header class="record-header">
<h3>${collection}</h3>
<p class="record-uri">URI: ${record.uri}</p>
<p class="record-cid">CID: ${record.cid}</p>
</header>
<div class="json-view">
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>
</div>
</article>
`
}
// Get preview text from record value
function getRecordPreview(value: Record<string, unknown>): string {
if (typeof value.text === 'string') return value.text.slice(0, 60)
if (typeof value.title === 'string') return value.title
if (typeof value.name === 'string') return value.name
if (typeof value.displayName === 'string') return value.displayName
if (typeof value.handle === 'string') return value.handle
if (typeof value.subject === 'string') return value.subject
if (typeof value.description === 'string') return value.description.slice(0, 60)
return ''
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

View File

@@ -0,0 +1,12 @@
export function renderFooter(handle: string): string {
// Extract username from handle: {username}.{name}.{domain} -> username
const username = handle.split('.')[0] || handle
return `
<footer id="footer" class="footer">
<div class="footer-content">
<span class="footer-copy">© ${username}</span>
</div>
</footer>
`
}

View File

@@ -0,0 +1,45 @@
import { isLoggedIn, getLoggedInDid } from '../lib/auth'
export function renderHeader(currentHandle: string): string {
const loggedIn = isLoggedIn()
const did = getLoggedInDid()
const loginBtn = loggedIn
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout (${did?.slice(0, 20)}...)">✓</button>`
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login">↗</button>`
return `
<header id="header">
<form class="header-form" id="header-form">
<input
type="text"
class="header-input"
id="header-input"
placeholder="handle (e.g., syui.ai)"
value="${currentHandle}"
>
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
${loginBtn}
</form>
</header>
`
}
export function mountHeader(
container: HTMLElement,
currentHandle: string,
onBrowse: (handle: string) => void
): void {
container.innerHTML = renderHeader(currentHandle)
const form = document.getElementById('header-form') as HTMLFormElement
const input = document.getElementById('header-input') as HTMLInputElement
form?.addEventListener('submit', (e) => {
e.preventDefault()
const handle = input.value.trim()
if (handle) {
onBrowse(handle)
}
})
}

View File

@@ -0,0 +1,63 @@
import type { Post } from '../types'
import { renderMarkdown } from '../lib/markdown'
// Render post list
export function renderPostList(posts: Post[], handle: string): string {
if (posts.length === 0) {
return '<p class="no-posts">No posts yet.</p>'
}
const items = posts.map(post => {
const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP')
return `
<article class="post-item">
<a href="/@${handle}/${rkey}" class="post-link">
<h2 class="post-title">${escapeHtml(post.value.title)}</h2>
<time class="post-date">${date}</time>
</a>
</article>
`
}).join('')
return `<div class="post-list">${items}</div>`
}
// Render single post detail
export function renderPostDetail(post: Post, handle: string, collection: string): string {
const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('ja-JP')
const content = renderMarkdown(post.value.content)
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}`
return `
<article class="post-detail">
<header class="post-header">
<h1 class="post-title">${escapeHtml(post.value.title)}</h1>
<div class="post-meta">
<time class="post-date">${date}</time>
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
</header>
<div class="post-content">${content}</div>
</article>
`
}
export function mountPostList(container: HTMLElement, html: string): void {
container.innerHTML = html
}
export function mountPostDetail(container: HTMLElement, html: string): void {
container.innerHTML = html
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

View File

@@ -0,0 +1,55 @@
import type { Profile } from '../types'
import { getAvatarUrl } from '../lib/api'
export async function renderProfile(
did: string,
profile: Profile,
handle: string,
webUrl?: string
): Promise<string> {
const avatarUrl = await getAvatarUrl(did, profile)
const displayName = profile.value.displayName || handle || 'Unknown'
const description = profile.value.description || ''
// Build profile link (e.g., https://bsky.app/profile/did:plc:xxx)
const profileLink = webUrl ? `${webUrl}/profile/${did}` : null
const handleHtml = profileLink
? `<a href="${profileLink}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(handle)}</a>`
: `<span>@${escapeHtml(handle)}</span>`
const avatarHtml = avatarUrl
? `<a href="/"><img class="profile-avatar" src="${avatarUrl}" alt="${displayName}"></a>`
: `<a href="/"><div class="profile-avatar-placeholder"></div></a>`
return `
<div class="profile">
<div class="profile-row">
${avatarHtml}
<div class="profile-meta">
<span class="profile-name">${escapeHtml(displayName)}</span>
<span class="profile-handle">${handleHtml}</span>
</div>
</div>
${description ? `
<div class="profile-row">
<div class="profile-avatar-spacer"></div>
<p class="profile-description">${escapeHtml(description)}</p>
</div>
` : ''}
</div>
`
}
export function mountProfile(container: HTMLElement, html: string): void {
container.innerHTML = html
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}

13
src/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ailog</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>

260
src/web/lexicons/index.ts Normal file
View File

@@ -0,0 +1,260 @@
// Auto-generated from ATProto lexicons
// Run `ailog gen` to regenerate
// Do not edit manually
export interface Endpoint {
nsid: string
method: 'GET' | 'POST'
}
/** Build XRPC URL for an endpoint */
export function xrpcUrl(pds: string, endpoint: Endpoint): string {
return `https://${pds}/xrpc/${endpoint.nsid}`
}
export const appBskyActor = {
getPreferences: { nsid: 'app.bsky.actor.getPreferences', method: 'GET' } as Endpoint,
getProfile: { nsid: 'app.bsky.actor.getProfile', method: 'GET' } as Endpoint,
getProfiles: { nsid: 'app.bsky.actor.getProfiles', method: 'GET' } as Endpoint,
getSuggestions: { nsid: 'app.bsky.actor.getSuggestions', method: 'GET' } as Endpoint,
putPreferences: { nsid: 'app.bsky.actor.putPreferences', method: 'POST' } as Endpoint,
searchActors: { nsid: 'app.bsky.actor.searchActors', method: 'GET' } as Endpoint,
searchActorsTypeahead: { nsid: 'app.bsky.actor.searchActorsTypeahead', method: 'GET' } as Endpoint,
} as const
export const appBskyAgeassurance = {
begin: { nsid: 'app.bsky.ageassurance.begin', method: 'POST' } as Endpoint,
getConfig: { nsid: 'app.bsky.ageassurance.getConfig', method: 'GET' } as Endpoint,
getState: { nsid: 'app.bsky.ageassurance.getState', method: 'GET' } as Endpoint,
} as const
export const appBskyBookmark = {
createBookmark: { nsid: 'app.bsky.bookmark.createBookmark', method: 'POST' } as Endpoint,
deleteBookmark: { nsid: 'app.bsky.bookmark.deleteBookmark', method: 'POST' } as Endpoint,
getBookmarks: { nsid: 'app.bsky.bookmark.getBookmarks', method: 'GET' } as Endpoint,
} as const
export const appBskyContact = {
dismissMatch: { nsid: 'app.bsky.contact.dismissMatch', method: 'POST' } as Endpoint,
getMatches: { nsid: 'app.bsky.contact.getMatches', method: 'GET' } as Endpoint,
getSyncStatus: { nsid: 'app.bsky.contact.getSyncStatus', method: 'GET' } as Endpoint,
importContacts: { nsid: 'app.bsky.contact.importContacts', method: 'POST' } as Endpoint,
removeData: { nsid: 'app.bsky.contact.removeData', method: 'POST' } as Endpoint,
sendNotification: { nsid: 'app.bsky.contact.sendNotification', method: 'POST' } as Endpoint,
startPhoneVerification: { nsid: 'app.bsky.contact.startPhoneVerification', method: 'POST' } as Endpoint,
verifyPhone: { nsid: 'app.bsky.contact.verifyPhone', method: 'POST' } as Endpoint,
} as const
export const appBskyDraft = {
createDraft: { nsid: 'app.bsky.draft.createDraft', method: 'POST' } as Endpoint,
deleteDraft: { nsid: 'app.bsky.draft.deleteDraft', method: 'POST' } as Endpoint,
getDrafts: { nsid: 'app.bsky.draft.getDrafts', method: 'GET' } as Endpoint,
updateDraft: { nsid: 'app.bsky.draft.updateDraft', method: 'POST' } as Endpoint,
} as const
export const appBskyFeed = {
describeFeedGenerator: { nsid: 'app.bsky.feed.describeFeedGenerator', method: 'GET' } as Endpoint,
getActorFeeds: { nsid: 'app.bsky.feed.getActorFeeds', method: 'GET' } as Endpoint,
getActorLikes: { nsid: 'app.bsky.feed.getActorLikes', method: 'GET' } as Endpoint,
getAuthorFeed: { nsid: 'app.bsky.feed.getAuthorFeed', method: 'GET' } as Endpoint,
getFeed: { nsid: 'app.bsky.feed.getFeed', method: 'GET' } as Endpoint,
getFeedGenerator: { nsid: 'app.bsky.feed.getFeedGenerator', method: 'GET' } as Endpoint,
getFeedGenerators: { nsid: 'app.bsky.feed.getFeedGenerators', method: 'GET' } as Endpoint,
getFeedSkeleton: { nsid: 'app.bsky.feed.getFeedSkeleton', method: 'GET' } as Endpoint,
getLikes: { nsid: 'app.bsky.feed.getLikes', method: 'GET' } as Endpoint,
getListFeed: { nsid: 'app.bsky.feed.getListFeed', method: 'GET' } as Endpoint,
getPostThread: { nsid: 'app.bsky.feed.getPostThread', method: 'GET' } as Endpoint,
getPosts: { nsid: 'app.bsky.feed.getPosts', method: 'GET' } as Endpoint,
getQuotes: { nsid: 'app.bsky.feed.getQuotes', method: 'GET' } as Endpoint,
getRepostedBy: { nsid: 'app.bsky.feed.getRepostedBy', method: 'GET' } as Endpoint,
getSuggestedFeeds: { nsid: 'app.bsky.feed.getSuggestedFeeds', method: 'GET' } as Endpoint,
getTimeline: { nsid: 'app.bsky.feed.getTimeline', method: 'GET' } as Endpoint,
searchPosts: { nsid: 'app.bsky.feed.searchPosts', method: 'GET' } as Endpoint,
sendInteractions: { nsid: 'app.bsky.feed.sendInteractions', method: 'POST' } as Endpoint,
} as const
export const appBskyGraph = {
getActorStarterPacks: { nsid: 'app.bsky.graph.getActorStarterPacks', method: 'GET' } as Endpoint,
getBlocks: { nsid: 'app.bsky.graph.getBlocks', method: 'GET' } as Endpoint,
getFollowers: { nsid: 'app.bsky.graph.getFollowers', method: 'GET' } as Endpoint,
getFollows: { nsid: 'app.bsky.graph.getFollows', method: 'GET' } as Endpoint,
getKnownFollowers: { nsid: 'app.bsky.graph.getKnownFollowers', method: 'GET' } as Endpoint,
getList: { nsid: 'app.bsky.graph.getList', method: 'GET' } as Endpoint,
getListBlocks: { nsid: 'app.bsky.graph.getListBlocks', method: 'GET' } as Endpoint,
getListMutes: { nsid: 'app.bsky.graph.getListMutes', method: 'GET' } as Endpoint,
getLists: { nsid: 'app.bsky.graph.getLists', method: 'GET' } as Endpoint,
getListsWithMembership: { nsid: 'app.bsky.graph.getListsWithMembership', method: 'GET' } as Endpoint,
getMutes: { nsid: 'app.bsky.graph.getMutes', method: 'GET' } as Endpoint,
getRelationships: { nsid: 'app.bsky.graph.getRelationships', method: 'GET' } as Endpoint,
getStarterPack: { nsid: 'app.bsky.graph.getStarterPack', method: 'GET' } as Endpoint,
getStarterPacks: { nsid: 'app.bsky.graph.getStarterPacks', method: 'GET' } as Endpoint,
getStarterPacksWithMembership: { nsid: 'app.bsky.graph.getStarterPacksWithMembership', method: 'GET' } as Endpoint,
getSuggestedFollowsByActor: { nsid: 'app.bsky.graph.getSuggestedFollowsByActor', method: 'GET' } as Endpoint,
muteActor: { nsid: 'app.bsky.graph.muteActor', method: 'POST' } as Endpoint,
muteActorList: { nsid: 'app.bsky.graph.muteActorList', method: 'POST' } as Endpoint,
muteThread: { nsid: 'app.bsky.graph.muteThread', method: 'POST' } as Endpoint,
searchStarterPacks: { nsid: 'app.bsky.graph.searchStarterPacks', method: 'GET' } as Endpoint,
unmuteActor: { nsid: 'app.bsky.graph.unmuteActor', method: 'POST' } as Endpoint,
unmuteActorList: { nsid: 'app.bsky.graph.unmuteActorList', method: 'POST' } as Endpoint,
unmuteThread: { nsid: 'app.bsky.graph.unmuteThread', method: 'POST' } as Endpoint,
} as const
export const appBskyLabeler = {
getServices: { nsid: 'app.bsky.labeler.getServices', method: 'GET' } as Endpoint,
} as const
export const appBskyNotification = {
getPreferences: { nsid: 'app.bsky.notification.getPreferences', method: 'GET' } as Endpoint,
getUnreadCount: { nsid: 'app.bsky.notification.getUnreadCount', method: 'GET' } as Endpoint,
listActivitySubscriptions: { nsid: 'app.bsky.notification.listActivitySubscriptions', method: 'GET' } as Endpoint,
listNotifications: { nsid: 'app.bsky.notification.listNotifications', method: 'GET' } as Endpoint,
putActivitySubscription: { nsid: 'app.bsky.notification.putActivitySubscription', method: 'POST' } as Endpoint,
putPreferences: { nsid: 'app.bsky.notification.putPreferences', method: 'POST' } as Endpoint,
putPreferencesV2: { nsid: 'app.bsky.notification.putPreferencesV2', method: 'POST' } as Endpoint,
registerPush: { nsid: 'app.bsky.notification.registerPush', method: 'POST' } as Endpoint,
unregisterPush: { nsid: 'app.bsky.notification.unregisterPush', method: 'POST' } as Endpoint,
updateSeen: { nsid: 'app.bsky.notification.updateSeen', method: 'POST' } as Endpoint,
} as const
export const appBskyUnspecced = {
getAgeAssuranceState: { nsid: 'app.bsky.unspecced.getAgeAssuranceState', method: 'GET' } as Endpoint,
getConfig: { nsid: 'app.bsky.unspecced.getConfig', method: 'GET' } as Endpoint,
getOnboardingSuggestedStarterPacks: { nsid: 'app.bsky.unspecced.getOnboardingSuggestedStarterPacks', method: 'GET' } as Endpoint,
getOnboardingSuggestedStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton', method: 'GET' } as Endpoint,
getPopularFeedGenerators: { nsid: 'app.bsky.unspecced.getPopularFeedGenerators', method: 'GET' } as Endpoint,
getPostThreadOtherV2: { nsid: 'app.bsky.unspecced.getPostThreadOtherV2', method: 'GET' } as Endpoint,
getPostThreadV2: { nsid: 'app.bsky.unspecced.getPostThreadV2', method: 'GET' } as Endpoint,
getSuggestedFeeds: { nsid: 'app.bsky.unspecced.getSuggestedFeeds', method: 'GET' } as Endpoint,
getSuggestedFeedsSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedFeedsSkeleton', method: 'GET' } as Endpoint,
getSuggestedStarterPacks: { nsid: 'app.bsky.unspecced.getSuggestedStarterPacks', method: 'GET' } as Endpoint,
getSuggestedStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedStarterPacksSkeleton', method: 'GET' } as Endpoint,
getSuggestedUsers: { nsid: 'app.bsky.unspecced.getSuggestedUsers', method: 'GET' } as Endpoint,
getSuggestedUsersSkeleton: { nsid: 'app.bsky.unspecced.getSuggestedUsersSkeleton', method: 'GET' } as Endpoint,
getSuggestionsSkeleton: { nsid: 'app.bsky.unspecced.getSuggestionsSkeleton', method: 'GET' } as Endpoint,
getTaggedSuggestions: { nsid: 'app.bsky.unspecced.getTaggedSuggestions', method: 'GET' } as Endpoint,
getTrendingTopics: { nsid: 'app.bsky.unspecced.getTrendingTopics', method: 'GET' } as Endpoint,
getTrends: { nsid: 'app.bsky.unspecced.getTrends', method: 'GET' } as Endpoint,
getTrendsSkeleton: { nsid: 'app.bsky.unspecced.getTrendsSkeleton', method: 'GET' } as Endpoint,
initAgeAssurance: { nsid: 'app.bsky.unspecced.initAgeAssurance', method: 'POST' } as Endpoint,
searchActorsSkeleton: { nsid: 'app.bsky.unspecced.searchActorsSkeleton', method: 'GET' } as Endpoint,
searchPostsSkeleton: { nsid: 'app.bsky.unspecced.searchPostsSkeleton', method: 'GET' } as Endpoint,
searchStarterPacksSkeleton: { nsid: 'app.bsky.unspecced.searchStarterPacksSkeleton', method: 'GET' } as Endpoint,
} as const
export const appBskyVideo = {
getJobStatus: { nsid: 'app.bsky.video.getJobStatus', method: 'GET' } as Endpoint,
getUploadLimits: { nsid: 'app.bsky.video.getUploadLimits', method: 'GET' } as Endpoint,
uploadVideo: { nsid: 'app.bsky.video.uploadVideo', method: 'POST' } as Endpoint,
} as const
export const comAtprotoAdmin = {
deleteAccount: { nsid: 'com.atproto.admin.deleteAccount', method: 'POST' } as Endpoint,
disableAccountInvites: { nsid: 'com.atproto.admin.disableAccountInvites', method: 'POST' } as Endpoint,
disableInviteCodes: { nsid: 'com.atproto.admin.disableInviteCodes', method: 'POST' } as Endpoint,
enableAccountInvites: { nsid: 'com.atproto.admin.enableAccountInvites', method: 'POST' } as Endpoint,
getAccountInfo: { nsid: 'com.atproto.admin.getAccountInfo', method: 'GET' } as Endpoint,
getAccountInfos: { nsid: 'com.atproto.admin.getAccountInfos', method: 'GET' } as Endpoint,
getInviteCodes: { nsid: 'com.atproto.admin.getInviteCodes', method: 'GET' } as Endpoint,
getSubjectStatus: { nsid: 'com.atproto.admin.getSubjectStatus', method: 'GET' } as Endpoint,
searchAccounts: { nsid: 'com.atproto.admin.searchAccounts', method: 'GET' } as Endpoint,
sendEmail: { nsid: 'com.atproto.admin.sendEmail', method: 'POST' } as Endpoint,
updateAccountEmail: { nsid: 'com.atproto.admin.updateAccountEmail', method: 'POST' } as Endpoint,
updateAccountHandle: { nsid: 'com.atproto.admin.updateAccountHandle', method: 'POST' } as Endpoint,
updateAccountPassword: { nsid: 'com.atproto.admin.updateAccountPassword', method: 'POST' } as Endpoint,
updateAccountSigningKey: { nsid: 'com.atproto.admin.updateAccountSigningKey', method: 'POST' } as Endpoint,
updateSubjectStatus: { nsid: 'com.atproto.admin.updateSubjectStatus', method: 'POST' } as Endpoint,
} as const
export const comAtprotoIdentity = {
getRecommendedDidCredentials: { nsid: 'com.atproto.identity.getRecommendedDidCredentials', method: 'GET' } as Endpoint,
refreshIdentity: { nsid: 'com.atproto.identity.refreshIdentity', method: 'POST' } as Endpoint,
requestPlcOperationSignature: { nsid: 'com.atproto.identity.requestPlcOperationSignature', method: 'POST' } as Endpoint,
resolveDid: { nsid: 'com.atproto.identity.resolveDid', method: 'GET' } as Endpoint,
resolveHandle: { nsid: 'com.atproto.identity.resolveHandle', method: 'GET' } as Endpoint,
resolveIdentity: { nsid: 'com.atproto.identity.resolveIdentity', method: 'GET' } as Endpoint,
signPlcOperation: { nsid: 'com.atproto.identity.signPlcOperation', method: 'POST' } as Endpoint,
submitPlcOperation: { nsid: 'com.atproto.identity.submitPlcOperation', method: 'POST' } as Endpoint,
updateHandle: { nsid: 'com.atproto.identity.updateHandle', method: 'POST' } as Endpoint,
} as const
export const comAtprotoLabel = {
queryLabels: { nsid: 'com.atproto.label.queryLabels', method: 'GET' } as Endpoint,
} as const
export const comAtprotoLexicon = {
resolveLexicon: { nsid: 'com.atproto.lexicon.resolveLexicon', method: 'GET' } as Endpoint,
} as const
export const comAtprotoModeration = {
createReport: { nsid: 'com.atproto.moderation.createReport', method: 'POST' } as Endpoint,
} as const
export const comAtprotoRepo = {
applyWrites: { nsid: 'com.atproto.repo.applyWrites', method: 'POST' } as Endpoint,
createRecord: { nsid: 'com.atproto.repo.createRecord', method: 'POST' } as Endpoint,
deleteRecord: { nsid: 'com.atproto.repo.deleteRecord', method: 'POST' } as Endpoint,
describeRepo: { nsid: 'com.atproto.repo.describeRepo', method: 'GET' } as Endpoint,
getRecord: { nsid: 'com.atproto.repo.getRecord', method: 'GET' } as Endpoint,
importRepo: { nsid: 'com.atproto.repo.importRepo', method: 'POST' } as Endpoint,
listMissingBlobs: { nsid: 'com.atproto.repo.listMissingBlobs', method: 'GET' } as Endpoint,
listRecords: { nsid: 'com.atproto.repo.listRecords', method: 'GET' } as Endpoint,
putRecord: { nsid: 'com.atproto.repo.putRecord', method: 'POST' } as Endpoint,
uploadBlob: { nsid: 'com.atproto.repo.uploadBlob', method: 'POST' } as Endpoint,
} as const
export const comAtprotoServer = {
activateAccount: { nsid: 'com.atproto.server.activateAccount', method: 'POST' } as Endpoint,
checkAccountStatus: { nsid: 'com.atproto.server.checkAccountStatus', method: 'GET' } as Endpoint,
confirmEmail: { nsid: 'com.atproto.server.confirmEmail', method: 'POST' } as Endpoint,
createAccount: { nsid: 'com.atproto.server.createAccount', method: 'POST' } as Endpoint,
createAppPassword: { nsid: 'com.atproto.server.createAppPassword', method: 'POST' } as Endpoint,
createInviteCode: { nsid: 'com.atproto.server.createInviteCode', method: 'POST' } as Endpoint,
createInviteCodes: { nsid: 'com.atproto.server.createInviteCodes', method: 'POST' } as Endpoint,
createSession: { nsid: 'com.atproto.server.createSession', method: 'POST' } as Endpoint,
deactivateAccount: { nsid: 'com.atproto.server.deactivateAccount', method: 'POST' } as Endpoint,
deleteAccount: { nsid: 'com.atproto.server.deleteAccount', method: 'POST' } as Endpoint,
deleteSession: { nsid: 'com.atproto.server.deleteSession', method: 'POST' } as Endpoint,
describeServer: { nsid: 'com.atproto.server.describeServer', method: 'GET' } as Endpoint,
getAccountInviteCodes: { nsid: 'com.atproto.server.getAccountInviteCodes', method: 'GET' } as Endpoint,
getServiceAuth: { nsid: 'com.atproto.server.getServiceAuth', method: 'GET' } as Endpoint,
getSession: { nsid: 'com.atproto.server.getSession', method: 'GET' } as Endpoint,
listAppPasswords: { nsid: 'com.atproto.server.listAppPasswords', method: 'GET' } as Endpoint,
refreshSession: { nsid: 'com.atproto.server.refreshSession', method: 'POST' } as Endpoint,
requestAccountDelete: { nsid: 'com.atproto.server.requestAccountDelete', method: 'POST' } as Endpoint,
requestEmailConfirmation: { nsid: 'com.atproto.server.requestEmailConfirmation', method: 'POST' } as Endpoint,
requestEmailUpdate: { nsid: 'com.atproto.server.requestEmailUpdate', method: 'POST' } as Endpoint,
requestPasswordReset: { nsid: 'com.atproto.server.requestPasswordReset', method: 'POST' } as Endpoint,
reserveSigningKey: { nsid: 'com.atproto.server.reserveSigningKey', method: 'POST' } as Endpoint,
resetPassword: { nsid: 'com.atproto.server.resetPassword', method: 'POST' } as Endpoint,
revokeAppPassword: { nsid: 'com.atproto.server.revokeAppPassword', method: 'POST' } as Endpoint,
updateEmail: { nsid: 'com.atproto.server.updateEmail', method: 'POST' } as Endpoint,
} as const
export const comAtprotoSync = {
getBlob: { nsid: 'com.atproto.sync.getBlob', method: 'GET' } as Endpoint,
getBlocks: { nsid: 'com.atproto.sync.getBlocks', method: 'GET' } as Endpoint,
getCheckout: { nsid: 'com.atproto.sync.getCheckout', method: 'GET' } as Endpoint,
getHead: { nsid: 'com.atproto.sync.getHead', method: 'GET' } as Endpoint,
getHostStatus: { nsid: 'com.atproto.sync.getHostStatus', method: 'GET' } as Endpoint,
getLatestCommit: { nsid: 'com.atproto.sync.getLatestCommit', method: 'GET' } as Endpoint,
getRecord: { nsid: 'com.atproto.sync.getRecord', method: 'GET' } as Endpoint,
getRepo: { nsid: 'com.atproto.sync.getRepo', method: 'GET' } as Endpoint,
getRepoStatus: { nsid: 'com.atproto.sync.getRepoStatus', method: 'GET' } as Endpoint,
listBlobs: { nsid: 'com.atproto.sync.listBlobs', method: 'GET' } as Endpoint,
listHosts: { nsid: 'com.atproto.sync.listHosts', method: 'GET' } as Endpoint,
listRepos: { nsid: 'com.atproto.sync.listRepos', method: 'GET' } as Endpoint,
listReposByCollection: { nsid: 'com.atproto.sync.listReposByCollection', method: 'GET' } as Endpoint,
notifyOfUpdate: { nsid: 'com.atproto.sync.notifyOfUpdate', method: 'POST' } as Endpoint,
requestCrawl: { nsid: 'com.atproto.sync.requestCrawl', method: 'POST' } as Endpoint,
} as const
export const comAtprotoTemp = {
addReservedHandle: { nsid: 'com.atproto.temp.addReservedHandle', method: 'POST' } as Endpoint,
checkHandleAvailability: { nsid: 'com.atproto.temp.checkHandleAvailability', method: 'GET' } as Endpoint,
checkSignupQueue: { nsid: 'com.atproto.temp.checkSignupQueue', method: 'GET' } as Endpoint,
dereferenceScope: { nsid: 'com.atproto.temp.dereferenceScope', method: 'GET' } as Endpoint,
fetchLabels: { nsid: 'com.atproto.temp.fetchLabels', method: 'GET' } as Endpoint,
requestPhoneVerification: { nsid: 'com.atproto.temp.requestPhoneVerification', method: 'POST' } as Endpoint,
revokeAccountCredentials: { nsid: 'com.atproto.temp.revokeAccountCredentials', method: 'POST' } as Endpoint,
} as const

244
src/web/lib/api.ts Normal file
View File

@@ -0,0 +1,244 @@
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
}

142
src/web/lib/auth.ts Normal file
View File

@@ -0,0 +1,142 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { getNetworks } from './api'
let oauthClient: BrowserOAuthClient | null = null
let sessionDid: string | null = null
type NetworkConfig = { bsky: string; plc: string }
// 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`
}
// Get default network config (first entry)
async function getDefaultNetwork(): Promise<NetworkConfig> {
const networks = await getNetworks()
const first = Object.values(networks)[0]
return { bsky: first.bsky, plc: first.plc }
}
// Get network config by matching handle domain
async function getNetworkByHandle(handle: string): Promise<NetworkConfig> {
const networks = await getNetworks()
for (const [domain, network] of Object.entries(networks)) {
if (handle.endsWith(`.${domain}`) || handle.includes(domain.split('.')[0])) {
return { bsky: network.bsky, plc: network.plc }
}
}
// Check for syui.ai -> syu.is mapping
if (handle.endsWith('.syui.ai')) {
const syuisNetwork = networks['syu.is']
if (syuisNetwork) {
return { bsky: syuisNetwork.bsky, plc: syuisNetwork.plc }
}
}
return getDefaultNetwork()
}
// Get network config by matching issuer URL
async function getNetworkByIssuer(iss: string): Promise<NetworkConfig> {
const networks = await getNetworks()
for (const [domain, network] of Object.entries(networks)) {
// Check if issuer contains the domain
if (iss.includes(domain)) {
return { bsky: network.bsky, plc: network.plc }
}
// Check if issuer matches bsky or plc URL
if (iss === network.bsky || iss === network.plc || iss === network.web) {
return { bsky: network.bsky, plc: network.plc }
}
}
return getDefaultNetwork()
}
// Login with handle
export async function login(handle: string): Promise<void> {
const config = await getNetworkByHandle(handle)
try {
oauthClient = await BrowserOAuthClient.load({
clientId: getClientId(),
handleResolver: config.bsky,
plcDirectoryUrl: config.plc,
})
await oauthClient.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 both query params and hash fragment
let params = new URLSearchParams(window.location.search)
if (window.location.hash && window.location.hash.length > 1) {
const hashParams = new URLSearchParams(window.location.hash.slice(1))
if (hashParams.has('code') || hashParams.has('state')) {
params = hashParams
}
}
if (!params.has('code') && !params.has('state')) {
return null
}
try {
const iss = params.get('iss') || ''
const config = await getNetworkByIssuer(iss)
oauthClient = await BrowserOAuthClient.load({
clientId: getClientId(),
handleResolver: config.bsky,
plcDirectoryUrl: config.plc,
})
const result = await oauthClient.callback(params)
sessionDid = result.session.did
// 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> {
sessionDid = null
oauthClient = null
window.location.reload()
}
// Check if logged in
export function isLoggedIn(): boolean {
return sessionDid !== null
}
// Get logged in DID
export function getLoggedInDid(): string | null {
return sessionDid
}

37
src/web/lib/markdown.ts Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// Render markdown to HTML
export function renderMarkdown(content: string): string {
return marked(content) as string
}

95
src/web/lib/router.ts Normal file
View File

@@ -0,0 +1,95 @@
export interface Route {
type: 'home' | 'user' | 'post' | '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 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 === '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()
}
})
}

221
src/web/main.ts Normal file
View File

@@ -0,0 +1,221 @@
import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback } from './lib/auth'
import { renderHeader } from './components/header'
import { renderProfile } from './components/profile'
import { renderPostList, renderPostDetail } from './components/posts'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
import { renderFooter } from './components/footer'
const app = document.getElementById('app')!
let currentHandle = ''
// Filter collections by service domain
function filterCollectionsByService(collections: string[], service: string): string[] {
return collections.filter(col => {
const parts = col.split('.')
if (parts.length >= 2) {
const colService = `${parts[1]}.${parts[0]}`
return colService === service
}
return false
})
}
// Get web URL for handle from networks
async function getWebUrl(handle: string): Promise<string | undefined> {
const networks = await getNetworks()
// Check each network for matching handle domain
for (const [domain, network] of Object.entries(networks)) {
// Direct domain match (e.g., handle.syu.is -> syu.is)
if (handle.endsWith(`.${domain}`)) {
return network.web
}
// Check if handle domain matches network's web domain (e.g., syui.syui.ai -> syu.is via web: syu.is)
const webDomain = network.web?.replace(/^https?:\/\//, '')
if (webDomain && handle.endsWith(`.${webDomain}`)) {
return network.web
}
}
// Check for syui.ai handles -> syu.is network
if (handle.endsWith('.syui.ai')) {
return networks['syu.is']?.web
}
// Default to first network's web
const firstNetwork = Object.values(networks)[0]
return firstNetwork?.web
}
async function render(route: Route): Promise<void> {
try {
const config = await getConfig()
// Apply theme color from config
if (config.color) {
document.documentElement.style.setProperty('--btn-color', config.color)
}
// Handle OAuth callback if present (check both ? and #)
const searchParams = new URLSearchParams(window.location.search)
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
if (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state')) {
await handleCallback()
}
// Determine handle and whether to use local data
let handle: string
let localFirst: boolean
if (route.type === 'home') {
handle = config.handle
localFirst = true
} else if (route.handle) {
handle = route.handle
localFirst = handle === config.handle
} else {
handle = config.handle
localFirst = true
}
currentHandle = handle
// Resolve handle to DID
const did = await resolveHandle(handle)
if (!did) {
app.innerHTML = `
${renderHeader(handle)}
<div class="error">Could not resolve handle: ${handle}</div>
${renderFooter(handle)}
`
setupEventHandlers()
return
}
// Load profile
const profile = await getProfile(did, localFirst)
const webUrl = await getWebUrl(handle)
// Build page
let html = renderHeader(handle)
// Profile section
if (profile) {
html += await renderProfile(did, profile, handle, webUrl)
}
// Content section based on route type
if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view
const record = await getRecord(did, route.collection, route.rkey)
if (record) {
html += `<div id="content">${renderRecordDetail(record, route.collection)}</div>`
} else {
html += `<div id="content" class="error">Record not found</div>`
}
html += `<nav class="back-nav"><a href="/@${handle}/at/collection/${route.collection}">${route.collection}</a></nav>`
} else if (route.type === 'collection' && route.collection) {
// AT-Browser: Collection records list
const records = await listRecords(did, route.collection)
html += `<div id="content">${renderRecordList(records, handle, route.collection)}</div>`
const parts = route.collection.split('.')
const service = parts.length >= 2 ? `${parts[1]}.${parts[0]}` : ''
html += `<nav class="back-nav"><a href="/@${handle}/at/service/${encodeURIComponent(service)}">${service}</a></nav>`
} else if (route.type === 'service' && route.service) {
// AT-Browser: Service collections list
const collections = await describeRepo(did)
const filtered = filterCollectionsByService(collections, route.service)
html += `<div id="content">${renderCollectionList(filtered, handle, route.service)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at">at</a></nav>`
} else if (route.type === 'atbrowser') {
// AT-Browser: Main view with server info + service list
const pds = await getPds(did)
const collections = await describeRepo(did)
html += `<div id="browser">`
html += renderServerInfo(did, pds)
html += renderServiceList(collections, handle)
html += `</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'post' && route.rkey) {
// Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst)
if (post) {
html += `<div id="content">${renderPostDetail(post, handle, config.collection)}</div>`
} else {
html += `<div id="content" class="error">Post not found</div>`
}
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else {
// User page: compact collection buttons + posts
const collections = await describeRepo(did)
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
const posts = await getPosts(did, config.collection, localFirst)
html += `<div id="content">${renderPostList(posts, handle)}</div>`
}
html += renderFooter(handle)
app.innerHTML = html
setupEventHandlers()
} catch (error) {
console.error('Render error:', error)
app.innerHTML = `
${renderHeader(currentHandle)}
<div class="error">Error: ${error}</div>
${renderFooter(currentHandle)}
`
setupEventHandlers()
}
}
function setupEventHandlers(): void {
// Header form
const form = document.getElementById('header-form') as HTMLFormElement
const input = document.getElementById('header-input') as HTMLInputElement
form?.addEventListener('submit', (e) => {
e.preventDefault()
const handle = input.value.trim()
if (handle) {
navigate({ type: 'user', handle })
}
})
// Login button
const loginBtn = document.getElementById('login-btn')
loginBtn?.addEventListener('click', async () => {
const handle = input.value.trim() || currentHandle
if (handle) {
try {
await login(handle)
} catch (e) {
console.error('Login failed:', e)
alert('Login failed. Please check your handle.')
}
} else {
alert('Please enter a handle first.')
}
})
// Logout button
const logoutBtn = document.getElementById('logout-btn')
logoutBtn?.addEventListener('click', async () => {
await logout()
})
}
// Initial render
render(parseRoute())
// Handle route changes
onRouteChange(render)

1812
src/web/styles/main.css Normal file

File diff suppressed because it is too large Load Diff

64
src/web/types.ts Normal file
View File

@@ -0,0 +1,64 @@
// Config types
export interface AppConfig {
title: string
handle: string
collection: string
network: string
color: string
siteUrl: string
}
export interface Networks {
[domain: string]: {
plc: string
bsky: string
web: string
}
}
// ATProto types
export interface DescribeRepo {
did: string
handle: string
collections: string[]
}
export interface Profile {
cid: string
uri: string
value: {
$type: string
avatar?: {
$type: string
mimeType: string
ref: { $link: string }
size: number
}
displayName?: string
description?: string
createdAt?: string
}
}
export interface Post {
cid: string
uri: string
value: {
$type: string
title: string
content: string
createdAt: string
lang?: string
translations?: {
[lang: string]: {
title: string
content: string
}
}
}
}
export interface ListRecordsResponse<T> {
records: T[]
cursor?: string
}

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/web/*"]
}
},
"include": ["src/web/**/*.ts"]
}

20
vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
root: 'src/web',
publicDir: '../../public',
build: {
outDir: '../../dist',
emptyOutDir: true,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src/web'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
},
})