diff --git a/public/config.json b/public/config.json index 7d4291b..08ee20d 100644 --- a/public/config.json +++ b/public/config.json @@ -1,5 +1,6 @@ { "title": "syui.ai", + "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y", "handle": "syui.syui.ai", "collection": "ai.syui.log.post", "network": "syu.is", diff --git a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json index 3abbea1..a29045d 100644 --- a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json +++ b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json @@ -8,10 +8,9 @@ "title": "ailogを作り直した", "translations": { "en": { - "content": "## About ailog\n\nA site generator that integrates with atproto.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser architecture\n2. Uses atproto oAuth for login\n3. Allows posting articles through the logged-in account", + "content": "## What is ailog?\n\nA site generator that integrates with atproto.\n\n## How to use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's concept\n\n1. Based on at-browser as its foundation\n2. Logs in via atproto oauth\n3. Allows users to post articles using their logged-in account", "title": "recreated ailog" } - }, - "lang": "ja" + } } -} +} \ No newline at end of file diff --git a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/index.json b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/index.json index 506d423..ed7d5dd 100644 --- a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/index.json +++ b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/index.json @@ -1 +1,3 @@ -["3mchqlshygs2s"] +[ + "3mchqlshygs2s" +] \ No newline at end of file diff --git a/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/blob/bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/blob/bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u new file mode 100644 index 0000000..c04de9b Binary files /dev/null and b/public/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/blob/bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u differ diff --git a/src/commands/post.rs b/src/commands/post.rs index b8a2291..f3882e8 100644 --- a/src/commands/post.rs +++ b/src/commands/post.rs @@ -308,6 +308,27 @@ pub async fn sync_to_local(output: &str) -> Result<()> { let profile_path = format!("{}/self.json", profile_dir); fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?; println!("Saved: {}", profile_path); + + // Download avatar blob if present + if let Some(avatar_cid) = profile["value"]["avatar"]["ref"]["$link"].as_str() { + let blob_dir = format!("{}/blob", did_dir); + fs::create_dir_all(&blob_dir)?; + let blob_path = format!("{}/{}", blob_dir, avatar_cid); + + let blob_url = format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + pds, did, avatar_cid + ); + println!("Downloading avatar: {}", avatar_cid); + let blob_res = client.get(&blob_url).send().await?; + if blob_res.status().is_success() { + let blob_bytes = blob_res.bytes().await?; + fs::write(&blob_path, &blob_bytes)?; + println!("Saved: {}", blob_path); + } else { + println!("Failed to download avatar: {}", blob_res.status()); + } + } } // 3. Sync collection records diff --git a/src/web/components/profile.ts b/src/web/components/profile.ts index ca6c577..9cbdfcb 100644 --- a/src/web/components/profile.ts +++ b/src/web/components/profile.ts @@ -1,13 +1,17 @@ import type { Profile } from '../types' -import { getAvatarUrl } from '../lib/api' +import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api' export async function renderProfile( did: string, profile: Profile, handle: string, - webUrl?: string + webUrl?: string, + localOnly = false ): Promise { - const avatarUrl = await getAvatarUrl(did, profile) + // Local mode: sync, no API call. Remote mode: async with API call + const avatarUrl = localOnly + ? getAvatarUrl(did, profile, true) + : await getAvatarUrlRemote(did, profile) const displayName = profile.value.displayName || handle || 'Unknown' const description = profile.value.description || '' diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index d15c9ba..b4923dd 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -80,13 +80,16 @@ async function getLocalProfile(did: string): Promise { return null } -// Load profile (local first for admin, remote for others) -export async function getProfile(did: string, localFirst = true): Promise { - if (localFirst) { - const local = await getLocalProfile(did) - if (local) return local - } +// Load profile (local only for admin, remote for others) +export async function getProfile(did: string, localOnly = false): Promise { + // Try local first + const local = await getLocalProfile(did) + if (local) return local + // If local only mode, don't call API + if (localOnly) return null + + // Remote fallback const pds = await getPds(did) if (!pds) return null @@ -101,8 +104,23 @@ export async function getProfile(did: string, localFirst = true): Promise { +// Get avatar URL (local only for admin, remote for others) +export function getAvatarUrl(did: string, profile: Profile, localOnly = false): string | null { + if (!profile.value.avatar) return null + + const cid = profile.value.avatar.ref.$link + + // Local mode: use local blob path (sync command downloads this) + if (localOnly) { + return `/content/${did}/blob/${cid}` + } + + // Remote mode: use PDS blob URL (requires getPds call from caller if needed) + return null +} + +// Get avatar URL with PDS lookup (async, for remote users) +export async function getAvatarUrlRemote(did: string, profile: Profile): Promise { if (!profile.value.avatar) return null const pds = await getPds(did) @@ -132,13 +150,16 @@ async function getLocalPosts(did: string, collection: string): Promise { return [] } -// Load posts (local first for admin, remote for others) -export async function getPosts(did: string, collection: string, localFirst = true): Promise { - if (localFirst) { - const local = await getLocalPosts(did, collection) - if (local.length > 0) return local - } +// Load posts (local only for admin, remote for others) +export async function getPosts(did: string, collection: string, localOnly = false): Promise { + // Try local first + const local = await getLocalPosts(did, collection) + if (local.length > 0) return local + // If local only mode, don't call API + if (localOnly) return [] + + // Remote fallback const pds = await getPds(did) if (!pds) return [] @@ -158,17 +179,20 @@ export async function getPosts(did: string, collection: string, localFirst = tru return [] } -// Get single post -export async function getPost(did: string, collection: string, rkey: string, localFirst = true): Promise { - if (localFirst) { - try { - const res = await fetch(`/content/${did}/${collection}/${rkey}.json`) - if (res.ok && isJsonResponse(res)) return res.json() - } catch { - // Not found - } +// Get single post (local only for admin, remote for others) +export async function getPost(did: string, collection: string, rkey: string, localOnly = false): Promise { + // Try local first + try { + const res = await fetch(`/content/${did}/${collection}/${rkey}.json`) + if (res.ok && isJsonResponse(res)) return res.json() + } catch { + // Not found } + // If local only mode, don't call API + if (localOnly) return null + + // Remote fallback const pds = await getPds(did) if (!pds) return null diff --git a/src/web/main.ts b/src/web/main.ts index 6634d48..850d37f 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -14,6 +14,7 @@ import { showLoading, hideLoading } from './components/loading' const app = document.getElementById('app')! let currentHandle = '' +let isFirstRender = true // Filter collections by service domain function filterCollectionsByService(collections: string[], service: string): string[] { @@ -52,7 +53,10 @@ async function getWebUrl(handle: string): Promise { } async function render(route: Route): Promise { - showLoading(app) + // Skip loading indicator on first render for faster perceived performance + if (!isFirstRender) { + showLoading(app) + } try { const config = await getConfig() @@ -73,12 +77,14 @@ async function render(route: Route): Promise { // 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')) { + if (oauthEnabled && (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state'))) { await handleCallback() } - // Restore session from storage - await restoreSession() + // Restore session from storage (skip if oauth disabled) + if (oauthEnabled) { + await restoreSession() + } // Redirect logged-in user from root to their user page if (route.type === 'home' && isLoggedIn()) { @@ -89,25 +95,31 @@ async function render(route: Route): Promise { } } - // Determine handle and whether to use local data + // Determine handle and whether to use local data only (no API calls) let handle: string - let localFirst: boolean + let localOnly: boolean + let did: string | null if (route.type === 'home') { handle = config.handle - localFirst = true + localOnly = true + did = config.did || null } else if (route.handle) { handle = route.handle - localFirst = handle === config.handle + localOnly = handle === config.handle + did = localOnly ? (config.did || null) : null } else { handle = config.handle - localFirst = true + localOnly = true + did = config.did || null } currentHandle = handle - // Resolve handle to DID - const did = await resolveHandle(handle) + // Resolve handle to DID only for remote users + if (!did) { + did = await resolveHandle(handle) + } if (!did) { app.innerHTML = ` @@ -119,12 +131,12 @@ async function render(route: Route): Promise { return } - // Load profile - const profile = await getProfile(did, localFirst) + // Load profile (local only for admin, remote for others) + const profile = await getProfile(did, localOnly) const webUrl = await getWebUrl(handle) - // Load posts to check for translations - const posts = await getPosts(did, config.collection, localFirst) + // Load posts (local only for admin, remote for others) + const posts = await getPosts(did, config.collection, localOnly) // Collect available languages from posts const availableLangs = new Set() @@ -151,7 +163,7 @@ async function render(route: Route): Promise { // Profile section if (profile) { - html += await renderProfile(did, profile, handle, webUrl) + html += await renderProfile(did, profile, handle, webUrl, localOnly) } // Check if logged-in user owns this content @@ -197,7 +209,7 @@ async function render(route: Route): Promise { } else if (route.type === 'post' && route.rkey) { // Post detail (config.collection with markdown) - const post = await getPost(did, config.collection, route.rkey, localFirst) + const post = await getPost(did, config.collection, route.rkey, localOnly) html += renderLangSelector(langList) if (post) { html += `
${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}
` @@ -273,6 +285,8 @@ async function render(route: Route): Promise { ` hideLoading(app) setupEventHandlers() + } finally { + isFirstRender = false } } diff --git a/src/web/types.ts b/src/web/types.ts index 64cf791..808bd1c 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -1,6 +1,7 @@ // Config types export interface AppConfig { title: string + did?: string handle: string collection: string network: string