fix oauth
This commit is contained in:
18
package.json
18
package.json
@@ -1,4 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ailog",
|
"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
3
public/_redirects
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/app/* /index.html 200
|
||||||
|
/oauth/* /index.html 200
|
||||||
|
/* /index.html 200
|
||||||
16
public/ai.svg
Normal file
16
public/ai.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 32 KiB |
12
public/client-metadata.json
Normal file
12
public/client-metadata.json
Normal 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
8
public/config.json
Normal 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
12
public/networks.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
readme.md
29
readme.md
@@ -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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct EndpointInfo {
|
|||||||
method: String, // GET or POST
|
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<()> {
|
pub fn generate(input: &str, output: &str) -> Result<()> {
|
||||||
let input_path = Path::new(input);
|
let input_path = Path::new(input);
|
||||||
|
|
||||||
@@ -47,14 +47,20 @@ pub fn generate(input: &str, output: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate Rust code
|
// Generate Rust code
|
||||||
let code = generate_rust_code(&namespaces);
|
let rust_code = generate_rust_code(&namespaces);
|
||||||
|
let rust_output_path = Path::new(output).join("mod.rs");
|
||||||
// Write output
|
|
||||||
let output_path = Path::new(output).join("mod.rs");
|
|
||||||
fs::create_dir_all(output)?;
|
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());
|
println!("Total namespaces: {}", namespaces.len());
|
||||||
let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum();
|
let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum();
|
||||||
println!("Total endpoints: {}", total_endpoints);
|
println!("Total endpoints: {}", total_endpoints);
|
||||||
@@ -178,6 +184,52 @@ fn generate_rust_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> Strin
|
|||||||
code
|
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 {
|
fn to_screaming_snake_case(s: &str) -> String {
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
|
|
||||||
@@ -190,3 +242,23 @@ fn to_screaming_snake_case(s: &str) -> String {
|
|||||||
|
|
||||||
result
|
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
|
||||||
|
}
|
||||||
|
|||||||
205
src/web/components/browser.ts
Normal file
205
src/web/components/browser.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
12
src/web/components/footer.ts
Normal file
12
src/web/components/footer.ts
Normal 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>
|
||||||
|
`
|
||||||
|
}
|
||||||
45
src/web/components/header.ts
Normal file
45
src/web/components/header.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
63
src/web/components/posts.ts
Normal file
63
src/web/components/posts.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
55
src/web/components/profile.ts
Normal file
55
src/web/components/profile.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
13
src/web/index.html
Normal file
13
src/web/index.html
Normal 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
260
src/web/lexicons/index.ts
Normal 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
244
src/web/lib/api.ts
Normal 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
142
src/web/lib/auth.ts
Normal 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
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
|
||||||
|
}
|
||||||
95
src/web/lib/router.ts
Normal file
95
src/web/lib/router.ts
Normal 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
221
src/web/main.ts
Normal 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
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
64
src/web/types.ts
Normal 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
23
tsconfig.json
Normal 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
20
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user