From a9042d889a6f9891ead1b47c4655d1192327d027 Mon Sep 17 00:00:00 2001 From: syui Date: Thu, 15 Jan 2026 19:46:01 +0900 Subject: [PATCH] fix post generate --- .github/workflows/cf-pages.yml | 9 +- .gitignore | 1 + package.json | 9 +- readme.md | 35 ++ scripts/generate.ts | 717 +++++++++++++++++++++++++++++++++ src/components/atbrowser.ts | 16 +- src/components/posts.ts | 7 +- src/components/services.ts | 2 +- src/lib/markdown.ts | 71 ++++ src/lib/router.ts | 95 +++++ src/main.ts | 603 ++++++++++++++++++++++----- src/styles/main.css | 350 +++++++++++++++- 12 files changed, 1797 insertions(+), 118 deletions(-) create mode 100644 readme.md create mode 100644 scripts/generate.ts create mode 100644 src/lib/markdown.ts create mode 100644 src/lib/router.ts diff --git a/.github/workflows/cf-pages.yml b/.github/workflows/cf-pages.yml index 03ec0b7..2e2661c 100644 --- a/.github/workflows/cf-pages.yml +++ b/.github/workflows/cf-pages.yml @@ -21,10 +21,13 @@ jobs: uses: actions/setup-node@v4 - name: Install dependencies - run: npm i + run: npm install - - name: Build - run: npm run build + - name: Fetch content from ATProto + run: npm run fetch + + - name: Generate static site + run: npm run generate - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 diff --git a/.gitignore b/.gitignore index 0062c02..e87a534 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist node_modules package-lock.json repos +content/ diff --git a/package.json b/package.json index f2af4a8..c7fda56 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,18 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "fetch": "tsx scripts/generate.ts fetch", + "generate": "npm run build && tsx scripts/generate.ts" }, "dependencies": { "@atproto/api": "^0.15.8", - "@atproto/oauth-client-browser": "^0.3.39" + "@atproto/oauth-client-browser": "^0.3.39", + "highlight.js": "^11.11.1", + "marked": "^17.0.1" }, "devDependencies": { + "tsx": "^4.21.0", "typescript": "^5.7.0", "vite": "^6.0.0" } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..27c62e8 --- /dev/null +++ b/readme.md @@ -0,0 +1,35 @@ +# ailog + +## config + +```sh +$ ls public/ +config.json +networks.json +client-metadata.json +``` + +## preview + +```sh +$ npm run dev +``` + +## content generate + +speed up by using local cache + +```sh +$ npm run fetch # API download -> content/${did}/... +$ npm run generate # content html -> dist/ +$ npm run preview # dist/ preview server +``` + +```sh +content/ +└── did:plc:uqzpqmrjnptsxezjx4xuh2mn/ + ├── profile.json + └── ai.syui.log.post/ + └── 3mch5zca4nj2h.json +``` + diff --git a/scripts/generate.ts b/scripts/generate.ts new file mode 100644 index 0000000..2154044 --- /dev/null +++ b/scripts/generate.ts @@ -0,0 +1,717 @@ +import * as fs from 'fs' +import * as path from 'path' +import { marked, Renderer } from 'marked' + +// Types +interface AppConfig { + title: string + handle: string + collection: string + network: string + color?: string +} + +interface Networks { + [key: string]: { + plc: string + bsky: string + } +} + +interface Profile { + did: string + handle: string + displayName?: string + description?: string + avatar?: string +} + +interface BlogPost { + uri: string + cid: string + title: string + content: string + createdAt: string +} + +// Highlight.js for syntax highlighting (core + common languages only) +let hljs: typeof import('highlight.js/lib/core').default + +async function loadHighlightJs() { + const core = await import('highlight.js/lib/core') + hljs = core.default + + const [js, ts, bash, json, yaml, md, css, xml, py, rust, go] = await Promise.all([ + import('highlight.js/lib/languages/javascript'), + import('highlight.js/lib/languages/typescript'), + import('highlight.js/lib/languages/bash'), + import('highlight.js/lib/languages/json'), + import('highlight.js/lib/languages/yaml'), + import('highlight.js/lib/languages/markdown'), + import('highlight.js/lib/languages/css'), + import('highlight.js/lib/languages/xml'), + import('highlight.js/lib/languages/python'), + import('highlight.js/lib/languages/rust'), + import('highlight.js/lib/languages/go'), + ]) + + hljs.registerLanguage('javascript', js.default) + hljs.registerLanguage('js', js.default) + hljs.registerLanguage('typescript', ts.default) + hljs.registerLanguage('ts', ts.default) + hljs.registerLanguage('bash', bash.default) + hljs.registerLanguage('sh', bash.default) + hljs.registerLanguage('json', json.default) + hljs.registerLanguage('yaml', yaml.default) + hljs.registerLanguage('yml', yaml.default) + hljs.registerLanguage('markdown', md.default) + hljs.registerLanguage('md', md.default) + hljs.registerLanguage('css', css.default) + hljs.registerLanguage('html', xml.default) + hljs.registerLanguage('xml', xml.default) + hljs.registerLanguage('python', py.default) + hljs.registerLanguage('py', py.default) + hljs.registerLanguage('rust', rust.default) + hljs.registerLanguage('go', go.default) +} + +// Markdown renderer +function setupMarked() { + const renderer = new Renderer() + + renderer.code = function({ text, lang }: { text: string; lang?: string }) { + let highlighted: string + if (lang && hljs.getLanguage(lang)) { + try { + highlighted = hljs.highlight(text, { language: lang }).value + } catch { + highlighted = escapeHtml(text) + } + } else { + highlighted = escapeHtml(text) + } + return `
${highlighted}
` + } + + marked.setOptions({ + breaks: true, + gfm: true, + renderer, + }) +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr) + return date.toLocaleDateString('ja-JP', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) +} + +// API functions +async function resolveHandle(handle: string, bskyUrl: string): Promise { + const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`) + if (!res.ok) throw new Error('Failed to resolve handle') + const data = await res.json() as { did: string } + return data.did +} + +async function getPdsEndpoint(did: string, plcUrl: string): Promise { + const res = await fetch(`${plcUrl}/${did}`) + if (!res.ok) throw new Error('Failed to resolve DID') + const doc = await res.json() as { service: Array<{ type: string; serviceEndpoint: string }> } + const pds = doc.service.find(s => s.type === 'AtprotoPersonalDataServer') + if (!pds) throw new Error('PDS not found in DID document') + return pds.serviceEndpoint +} + +async function getProfile(did: string, bskyUrl: string): Promise { + const res = await fetch(`${bskyUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`) + if (!res.ok) throw new Error('Failed to get profile') + return res.json() as Promise +} + +async function listRecordsFromApi(did: string, collection: string, pdsUrl: string): Promise { + const res = await fetch( + `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100` + ) + if (!res.ok) return [] + const data = await res.json() as { records: Array<{ uri: string; cid: string; value: Record }> } + return data.records.map(r => ({ + uri: r.uri, + cid: r.cid, + title: r.value.title as string || 'Untitled', + content: r.value.content as string || '', + createdAt: r.value.createdAt as string || new Date().toISOString(), + })).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) +} + +async function describeRepo(did: string, pdsUrl: string): Promise { + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.describeRepo?repo=${did}`) + if (!res.ok) return [] + const data = await res.json() as { collections: string[] } + return data.collections || [] +} + +// Content file functions +function getContentDir(): string { + return path.join(process.cwd(), 'content') +} + +function getCollectionDir(did: string, collection: string): string { + return path.join(getContentDir(), did, collection) +} + +function getProfilePath(did: string): string { + return path.join(getContentDir(), did, 'profile.json') +} + +function savePostToFile(post: BlogPost, did: string, collection: string): string { + const collectionDir = getCollectionDir(did, collection) + if (!fs.existsSync(collectionDir)) { + fs.mkdirSync(collectionDir, { recursive: true }) + } + + const rkey = post.uri.split('/').pop()! + const jsonPath = path.join(collectionDir, `${rkey}.json`) + fs.writeFileSync(jsonPath, JSON.stringify(post, null, 2)) + + return rkey +} + +function saveProfile(profile: Profile): void { + const profileDir = path.join(getContentDir(), profile.did) + if (!fs.existsSync(profileDir)) { + fs.mkdirSync(profileDir, { recursive: true }) + } + fs.writeFileSync(path.join(profileDir, 'profile.json'), JSON.stringify(profile, null, 2)) +} + +function loadPostsFromFiles(did: string, collection: string): BlogPost[] { + const collectionDir = getCollectionDir(did, collection) + if (!fs.existsSync(collectionDir)) { + return [] + } + + const files = fs.readdirSync(collectionDir) + const jsonFiles = files.filter(f => f.endsWith('.json')) + + const posts: BlogPost[] = [] + for (const jsonFile of jsonFiles) { + const jsonPath = path.join(collectionDir, jsonFile) + const post: BlogPost = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) + posts.push(post) + } + + return posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) +} + +function loadProfile(did: string): Profile | null { + const profilePath = getProfilePath(did) + if (!fs.existsSync(profilePath)) return null + return JSON.parse(fs.readFileSync(profilePath, 'utf-8')) +} + +function hasLocalContent(did: string, collection: string): boolean { + const collectionDir = getCollectionDir(did, collection) + if (!fs.existsSync(collectionDir)) return false + const files = fs.readdirSync(collectionDir) + return files.some(f => f.endsWith('.json')) +} + +function findLocalDid(): string | null { + const contentDir = getContentDir() + if (!fs.existsSync(contentDir)) return null + const dirs = fs.readdirSync(contentDir) + const didDir = dirs.find(d => d.startsWith('did:')) + return didDir || null +} + +// Favicon functions +function getFaviconDir(did: string): string { + return path.join(getContentDir(), did, 'favicons') +} + +async function downloadFavicon(url: string, filepath: string): Promise { + try { + const res = await fetch(url) + if (!res.ok) return false + const buffer = await res.arrayBuffer() + fs.writeFileSync(filepath, Buffer.from(buffer)) + return true + } catch { + return false + } +} + +// Service domain mapping +function getServiceDomain(collection: string): string | null { + const prefixMap: Record = { + 'app.bsky': 'bsky.app', + 'chat.bsky': 'bsky.app', + 'ai.syui': 'syui.ai', + } + for (const [prefix, domain] of Object.entries(prefixMap)) { + if (collection.startsWith(prefix)) { + return domain + } + } + return null +} + +function getServiceDomains(collections: string[]): string[] { + const domains = new Set() + for (const col of collections) { + const domain = getServiceDomain(col) + if (domain) domains.add(domain) + } + return Array.from(domains) +} + +async function downloadFavicons(did: string, domains: string[]): Promise { + const faviconDir = getFaviconDir(did) + if (!fs.existsSync(faviconDir)) { + fs.mkdirSync(faviconDir, { recursive: true }) + } + + const faviconUrls: Record = { + 'bsky.app': 'https://bsky.app/static/favicon-32x32.png', + 'syui.ai': 'https://syui.ai/favicon.png', + } + + for (const domain of domains) { + const url = faviconUrls[domain] + if (!url) continue + + const filepath = path.join(faviconDir, `${domain}.png`) + if (!fs.existsSync(filepath)) { + const ok = await downloadFavicon(url, filepath) + if (ok) { + console.log(`Downloaded: ${domain}.png`) + } + } + } +} + +function getLocalFaviconPath(did: string, domain: string): string | null { + const faviconDir = getFaviconDir(did) + const filename = `${domain}.png` + const filepath = path.join(faviconDir, filename) + if (fs.existsSync(filepath)) { + return `/favicons/${filename}` + } + return null +} + +// HTML Templates +function getAssetFilenames(): { css: string; js: string } { + const assetsDir = path.join(process.cwd(), 'dist/assets') + if (!fs.existsSync(assetsDir)) { + return { css: 'index.css', js: 'index.js' } + } + const files = fs.readdirSync(assetsDir) + const css = files.find(f => f.endsWith('.css')) || 'index.css' + const js = files.find(f => f.endsWith('.js')) || 'index.js' + return { css, js } +} + +function generateHtml(title: string, content: string, config: AppConfig, assets: { css: string; js: string }, isStatic: boolean = true): string { + const staticAttr = isStatic ? ' data-static="true"' : '' + return ` + + + + + ${escapeHtml(title)} - ${escapeHtml(config.title)} + + + ${config.color ? `` : ''} + + +
+ ${content} +
+ + +` +} + +function generateProfileHtml(profile: Profile): string { + const avatar = profile.avatar + ? `` + : '' + return ` +
+ ${avatar} +
+
${escapeHtml(profile.displayName || profile.handle)}
+
@${escapeHtml(profile.handle)}
+ ${profile.description ? `
${escapeHtml(profile.description)}
` : ''} +
+
+ ` +} + +function loadCollections(did: string): string[] { + const collectionsPath = path.join(getContentDir(), did, 'collections.json') + if (!fs.existsSync(collectionsPath)) return [] + return JSON.parse(fs.readFileSync(collectionsPath, 'utf-8')) +} + +function generateServicesHtml(did: string, handle: string, collections: string[]): string { + const serviceMap = new Map() + + for (const col of collections) { + const domain = getServiceDomain(col) + if (domain) { + if (!serviceMap.has(domain)) { + serviceMap.set(domain, []) + } + serviceMap.get(domain)!.push(col) + } + } + + if (serviceMap.size === 0) return '' + + const items = Array.from(serviceMap.entries()).map(([domain, cols]) => { + // Use local favicon if exists + const localFavicon = getLocalFaviconPath(did, domain) + const faviconSrc = localFavicon || `https://www.google.com/s2/favicons?domain=${domain}&sz=32` + + return ` + + + ${domain} + + ` + }).join('') + + return `
${items}
` +} + +function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string { + return ` +
+ Blog + +
+ ` +} + +function generatePostListHtml(posts: BlogPost[]): string { + if (posts.length === 0) { + return '

No posts yet

' + } + const items = posts.map(post => { + const rkey = post.uri.split('/').pop() + return ` +
  • + + ${escapeHtml(post.title)} + + +
  • + ` + }).join('') + return `
      ${items}
    ` +} + +function generatePostDetailHtml(post: BlogPost, handle: string, collection: string): string { + const rkey = post.uri.split('/').pop() || '' + const jsonUrl = `/at/${handle}/${collection}/${rkey}/` + const content = marked.parse(post.content) as string + + return ` +
    +
    +

    ${escapeHtml(post.title)}

    + +
    +
    ${content}
    +
    + ` +} + +function generateFooterHtml(handle: string): string { + const username = handle.split('.')[0] || handle + return ` +
    +

    © ${username}

    +
    + ` +} + +function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[]): string { + return ` + +
    +
    + ${generateTabsHtml('blog', config.handle)} + ${generateProfileHtml(profile)} + ${generateServicesHtml(profile.did, config.handle, collections)} +
    +
    + ${generatePostListHtml(posts)} +
    +
    + ${generateFooterHtml(config.handle)} + ` +} + +function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[]): string { + return ` + +
    +
    + ${generateTabsHtml('blog', config.handle)} + ${generateProfileHtml(profile)} + ${generateServicesHtml(profile.did, config.handle, collections)} +
    +
    + ${generatePostDetailHtml(post, config.handle, config.collection)} +
    +
    + ${generateFooterHtml(config.handle)} + ` +} + +// Fetch command - downloads posts from ATProto and saves to content/ +async function fetchPosts() { + console.log('Fetching posts from ATProto...') + + const configPath = path.join(process.cwd(), 'public/config.json') + const config: AppConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + + const networksPath = path.join(process.cwd(), 'public/networks.json') + const networks: Networks = JSON.parse(fs.readFileSync(networksPath, 'utf-8')) + const network = networks[config.network] + if (!network) { + throw new Error(`Network ${config.network} not found`) + } + + const did = await resolveHandle(config.handle, network.bsky) + console.log(`DID: ${did}`) + + const pdsUrl = await getPdsEndpoint(did, network.plc) + console.log(`PDS: ${pdsUrl}`) + + // Save profile + const profile = await getProfile(did, network.bsky) + saveProfile(profile) + console.log(`Saved: content/${did}/profile.json`) + + // Get and save collections + const collections = await describeRepo(did, pdsUrl) + const collectionsPath = path.join(getContentDir(), did, 'collections.json') + fs.writeFileSync(collectionsPath, JSON.stringify(collections, null, 2)) + console.log(`Saved: content/${did}/collections.json`) + + // Download favicons for each service domain + const domains = getServiceDomains(collections) + await downloadFavicons(did, domains) + + // Save posts + const posts = await listRecordsFromApi(did, config.collection, pdsUrl) + console.log(`Found ${posts.length} posts`) + + for (const post of posts) { + const rkey = savePostToFile(post, did, config.collection) + console.log(`Saved: content/${did}/${config.collection}/${rkey}.json`) + } + + console.log('\nFetch complete!') +} + +// Generate command - builds HTML from content/ files (or API if no local files) +async function generate() { + console.log('Starting static generation...') + + // Load config + const configPath = path.join(process.cwd(), 'public/config.json') + const config: AppConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + console.log(`Config loaded: ${config.handle}`) + + // Load networks + const networksPath = path.join(process.cwd(), 'public/networks.json') + const networks: Networks = JSON.parse(fs.readFileSync(networksPath, 'utf-8')) + const network = networks[config.network] + if (!network) { + throw new Error(`Network ${config.network} not found in networks.json`) + } + + // Setup markdown + await loadHighlightJs() + setupMarked() + + // Always fetch from API to get latest posts, merge with local + const localDid = findLocalDid() + let did: string + let profile: Profile + let posts: BlogPost[] + + // Resolve DID and get profile + did = await resolveHandle(config.handle, network.bsky) + profile = await getProfile(did, network.bsky) + const pdsUrl = await getPdsEndpoint(did, network.plc) + console.log(`Profile: ${profile.displayName || profile.handle}`) + + // Fetch posts from API + const apiPosts = await listRecordsFromApi(did, config.collection, pdsUrl) + console.log(`Found ${apiPosts.length} posts from API`) + + // Load local posts if they exist + const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : [] + console.log(`Found ${localPosts.length} posts from local`) + + // Merge: API is the source of truth for what exists + // - If post exists in API and local: use local (may have edits) + // - If post exists in API only: use API + // - If post exists in local only: skip (was deleted from API) + const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop())) + const localRkeys = new Set(localPosts.map(p => p.uri.split('/').pop())) + + // Local posts that still exist in API + const validLocalPosts = localPosts.filter(p => apiRkeys.has(p.uri.split('/').pop())) + // API posts that don't exist locally + const newApiPosts = apiPosts.filter(p => !localRkeys.has(p.uri.split('/').pop())) + + posts = [...validLocalPosts, ...newApiPosts].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + + const deletedCount = localPosts.length - validLocalPosts.length + if (deletedCount > 0) { + console.log(`Skipped ${deletedCount} deleted posts (exist locally but not in API)`) + } + console.log(`Total ${posts.length} posts (${validLocalPosts.length} local + ${newApiPosts.length} new from API)`) + + // Create output directory + const distDir = path.join(process.cwd(), 'dist') + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }) + } + + // Get asset filenames (with hashes) + const assets = getAssetFilenames() + + // Load collections for services display + const collections = loadCollections(did) + + // Generate index page + const indexContent = generateIndexPageContent(profile, posts, config, collections) + const indexHtml = generateHtml(config.title, indexContent, config, assets) + fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml) + console.log('Generated: /index.html') + + // Generate post pages + const postsDir = path.join(distDir, 'post') + if (!fs.existsSync(postsDir)) { + fs.mkdirSync(postsDir, { recursive: true }) + } + + // Generate /post/index.html as SPA for new post form + const postFormContent = ` + +
    +
    +
    +
    +
    + ` + const postFormHtml = generateHtml('New Post', postFormContent, config, assets, false) + fs.writeFileSync(path.join(postsDir, 'index.html'), postFormHtml) + console.log('Generated: /post/index.html') + + for (const post of posts) { + const rkey = post.uri.split('/').pop() + if (!rkey) continue + + const postDir = path.join(postsDir, rkey) + if (!fs.existsSync(postDir)) { + fs.mkdirSync(postDir, { recursive: true }) + } + + const postContent = generatePostPageContent(profile, post, config, collections) + const postHtml = generateHtml(post.title, postContent, config, assets) + fs.writeFileSync(path.join(postDir, 'index.html'), postHtml) + console.log(`Generated: /post/${rkey}/index.html`) + } + + // Generate SPA fallback page for dynamic routes (no data-static attribute) + const spaContent = ` + +
    +
    + +
    +
    +
    + ` + const spaHtml = generateHtml('App', spaContent, config, assets, false) + fs.writeFileSync(path.join(distDir, 'app.html'), spaHtml) + console.log('Generated: /app.html') + + // Generate _redirects for Cloudflare Pages (SPA routes) + const redirects = `/app / 301 +/oauth/* /app.html 200 +` + fs.writeFileSync(path.join(distDir, '_redirects'), redirects) + console.log('Generated: /_redirects') + + // Copy static files + const filesToCopy = ['favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json'] + for (const file of filesToCopy) { + const src = path.join(process.cwd(), 'public', file) + const dest = path.join(distDir, file) + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest) + } + } + + // Copy .well-known directory + const wellKnownSrc = path.join(process.cwd(), 'public/.well-known') + const wellKnownDest = path.join(distDir, '.well-known') + if (fs.existsSync(wellKnownSrc)) { + fs.cpSync(wellKnownSrc, wellKnownDest, { recursive: true }) + } + + // Copy favicons from content + const faviconSrc = getFaviconDir(did) + const faviconDest = path.join(distDir, 'favicons') + if (fs.existsSync(faviconSrc)) { + if (!fs.existsSync(faviconDest)) { + fs.mkdirSync(faviconDest, { recursive: true }) + } + fs.cpSync(faviconSrc, faviconDest, { recursive: true }) + } + + console.log('\nStatic generation complete!') + console.log(`Output: ${distDir}`) +} + +// CLI +const command = process.argv[2] + +if (command === 'fetch') { + fetchPosts().catch(err => { + console.error('Fetch failed:', err) + process.exit(1) + }) +} else { + generate().catch(err => { + console.error('Generation failed:', err) + process.exit(1) + }) +} diff --git a/src/components/atbrowser.ts b/src/components/atbrowser.ts index 1502140..4176093 100644 --- a/src/components/atbrowser.ts +++ b/src/components/atbrowser.ts @@ -47,7 +47,7 @@ async function renderServices(did: string, handle: string): Promise { const items = Array.from(serviceMap.entries()).map(([domain, info]) => { return `
  • - + ${info.name} ${info.count} @@ -84,7 +84,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str const items = filtered.map(col => { return `
  • - + ${col}
  • @@ -111,7 +111,7 @@ async function renderRecordList(did: string, handle: string, collection: string) const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey return `
  • - + ${rkey} ${preview} @@ -168,7 +168,7 @@ export async function mountAtBrowser( service: string | null = null, loginDid: string | null = null ): Promise { - container.innerHTML = '

    Loading...

    ' + container.innerHTML = '
    ' try { const did = handle.startsWith('did:') ? handle : await resolveHandle(handle) @@ -178,16 +178,16 @@ export async function mountAtBrowser( let nav = '' if (collection && rkey) { - nav = `← Back` + nav = `← Back` content = await renderRecordDetail(did, handle, collection, rkey, canDelete) } else if (collection) { // Get service from collection for back link const info = getServiceInfo(collection) const backService = info ? info.domain : '' - nav = `← ${info?.name || 'Back'}` + nav = `← ${info?.name || 'Back'}` content = await renderRecordList(did, handle, collection) } else if (service) { - nav = `← Services` + nav = `← Services` content = await renderCollections(did, handle, service) } else { content = await renderServices(did, handle) @@ -213,7 +213,7 @@ export async function mountAtBrowser( btn.textContent = 'Deleting...' await deleteRecord(col, rk) // Go back to collection - window.location.href = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}` + window.location.href = `/at/${handle}/${col}` } catch (err) { alert('Delete failed: ' + err) btn.disabled = false diff --git a/src/components/posts.ts b/src/components/posts.ts index b1f4c11..0ec8aa2 100644 --- a/src/components/posts.ts +++ b/src/components/posts.ts @@ -1,5 +1,6 @@ import type { BlogPost } from '../types.js' import { putRecord } from '../lib/auth.js' +import { renderMarkdown } from '../lib/markdown.js' function formatDate(dateStr: string): string { const date = new Date(dateStr) @@ -28,7 +29,7 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void { const rkey = post.uri.split('/').pop() return `
  • - + ${escapeHtml(post.title)} @@ -41,7 +42,7 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void { export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false): void { const rkey = post.uri.split('/').pop() || '' - const jsonUrl = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}&rkey=${rkey}` + const jsonUrl = `/at/${handle}/${collection}/${rkey}` const editBtn = canEdit ? `` : '' @@ -55,7 +56,7 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: ${editBtn} -
    ${escapeHtml(post.content)}
    +
    ${renderMarkdown(post.content)}
  • + + ${escapeHtml(post.title)} + + +
  • + ` + }).join('') + postList.insertAdjacentHTML('afterbegin', newPostsHtml) + } + } + } catch (err) { + console.error('Failed to refresh posts from API:', err) + } +} + +function setupEventHandlers(): void { + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement + + // Blog tab click - close browser and go back + if (target.id === 'blog-tab' || target.closest('#blog-tab')) { + if (browserMode) { + e.preventDefault() + closeBrowser() + return + } + } + + // Browser tab button + if (target.id === 'browser-tab' || target.closest('#browser-tab')) { + e.preventDefault() + const btn = (target.closest('#browser-tab') || target) as HTMLElement + const handle = btn.dataset.handle || config.handle + openBrowser(handle) + return + } + + // JSON button click (on post detail page) + const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement + if (jsonBtn) { + const href = jsonBtn.getAttribute('href') + if (href?.startsWith('/at/')) { + e.preventDefault() + const parts = href.split('/').filter(Boolean) + // /at/handle/collection/rkey + if (parts.length >= 4) { + openBrowser(parts[1], null, parts[2], parts[3]) + } + return + } + } + + // Service item click (on static page or in browser view) + if (target.closest('.service-item')) { + e.preventDefault() + const link = target.closest('.service-item') as HTMLAnchorElement + const href = link.getAttribute('href') + if (href) { + // Parse /at/handle/service from href + const parts = href.split('/').filter(Boolean) + if (parts[0] === 'at' && parts[1] && parts[2]) { + openBrowser(parts[1], parts[2]) + } + } + return + } + + // Links inside browser content + const contentEl = document.getElementById('content') + if (browserMode && contentEl?.contains(target)) { + const link = target.closest('a') + if (link) { + const href = link.getAttribute('href') + if (href?.startsWith('/at/')) { + e.preventDefault() + const parts = href.split('/').filter(Boolean) + // /at/handle, /at/handle/service, /at/handle/collection, /at/handle/collection/rkey + if (parts.length >= 2) { + const handle = parts[1] + let service = null + let collection = null + let rkey = null + + if (parts.length === 3) { + // Could be service or collection + const segment = parts[2] + if (segment.split('.').length <= 2) { + service = segment + } else { + collection = segment + } + } else if (parts.length >= 4) { + collection = parts[2] + rkey = parts[3] + } + + openBrowser(handle, service, collection, rkey) + } + } + } + } + }) +} + +async function render(): Promise { + const route = parseRoute(window.location.pathname) + const appEl = document.getElementById('app') + const isStatic = appEl?.dataset.static === 'true' + + const profileEl = document.getElementById('profile') + const contentEl = document.getElementById('content') + const headerEl = document.getElementById('header') + const footerEl = document.getElementById('footer') + + if (!profileEl || !contentEl || !headerEl) return + + const isLoggedIn = !!authSession + const handle = route.handle || config.handle + + // Skip re-rendering for static blog/post pages (but still mount header for login) + const isStaticRoute = route.type === 'blog' || route.type === 'post' + if (isStatic && isStaticRoute) { + // Only mount header for login functionality + mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, { + onBrowse: (newHandle) => { + openBrowser(newHandle) + }, + onLogin: async () => { + const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle + try { + await login(inputHandle) + } catch (err) { + console.error('Login error:', err) + alert('Login failed: ' + err) + } + }, + onLogout: async () => { + await logout() + window.location.reload() + } + }) + + // Update tabs to show Post tab if logged in + if (isLoggedIn) { + const tabsEl = document.querySelector('.mode-tabs') + if (tabsEl && !tabsEl.querySelector('a[href="/post"]')) { + tabsEl.insertAdjacentHTML('beforeend', 'Post') + } + } + + // For post pages, add edit button if logged in and can edit + if (route.type === 'post' && isLoggedIn && route.rkey) { + addEditButtonToStaticPost(config.collection, route.rkey, authSession!) + } + + // For blog top page, check for new posts from API and merge + if (route.type === 'blog') { + refreshPostListFromAPI() + } + + return // Skip content re-rendering + } + + // Footer + if (footerEl) { + footerEl.innerHTML = renderFooter(config.handle) + } + + // Header with login + mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, { + onBrowse: (newHandle) => { + openBrowser(newHandle) + }, + onLogin: async () => { + const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle + try { + await login(inputHandle) + } catch (err) { + console.error('Login error:', err) + alert('Login failed: ' + err) + } + }, + onLogout: async () => { + await logout() + window.location.reload() + } + }) + + // Route handling + switch (route.type) { + case 'new': + if (isLoggedIn) { + profileEl.innerHTML = renderTabs('new', isLoggedIn) + mountPostForm(contentEl, config.collection, () => { + window.location.href = '/' + }) + } else { + window.location.href = '/' + } + break + + case 'post': + try { + const profile = await getProfile(config.handle) + profileEl.innerHTML = renderTabs('blog', isLoggedIn) + const profileContentEl = document.createElement('div') + profileEl.appendChild(profileContentEl) + mountProfile(profileContentEl, profile) + + const servicesHtml = await renderServices(config.handle) + profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) + + const post = await getRecord(profile.did, config.collection, route.rkey!) + if (post) { + const canEdit = isLoggedIn && authSession?.did === profile.did + mountPostDetail(contentEl, post, config.handle, config.collection, canEdit) + } else { + contentEl.innerHTML = '

    Post not found

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

    Failed to load: ${err}

    ` + } + break + + case 'blog': + default: + try { + const profile = await getProfile(config.handle) + profileEl.innerHTML = renderTabs('blog', isLoggedIn) + const profileContentEl = document.createElement('div') + profileEl.appendChild(profileContentEl) + mountProfile(profileContentEl, profile) + + const servicesHtml = await renderServices(config.handle) + profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) + + const posts = await listRecords(profile.did, config.collection) + mountPostList(contentEl, posts) + } catch (err) { + console.error(err) + contentEl.innerHTML = `

    Failed to load: ${err}

    ` + } + break + } +} + async function init(): Promise { - const [config, networks] = await Promise.all([loadConfig(), loadNetworks()]) + const [configData, networks] = await Promise.all([loadConfig(), loadNetworks()]) + config = configData // Set page title document.title = config.title || 'ailog' @@ -70,102 +558,17 @@ async function init(): Promise { if (callbackSession) { authSession = callbackSession } else { - // Try to restore existing session authSession = await restoreSession() } - const params = new URLSearchParams(window.location.search) - const mode = params.get('mode') - const rkey = params.get('rkey') - const collection = params.get('collection') - const service = params.get('service') - const handle = params.get('handle') || config.handle + // Setup event handlers + setupEventHandlers() - const profileEl = document.getElementById('profile') - const contentEl = document.getElementById('content') - const headerEl = document.getElementById('header') - const footerEl = document.getElementById('footer') + // Initial render + await render() - if (!profileEl || !contentEl || !headerEl) return - - // Footer - if (footerEl) { - footerEl.innerHTML = renderFooter(config.handle) - } - - const isLoggedIn = !!authSession - - // Header with login - mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, { - onBrowse: (newHandle) => { - const currentMode = params.get('mode') - if (currentMode === 'browser') { - window.location.href = `?mode=browser&handle=${newHandle}` - } else { - window.location.href = `?handle=${newHandle}` - } - }, - onLogin: async () => { - const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle - try { - await login(inputHandle) - } catch (err) { - console.error('Login error:', err) - alert('Login failed: ' + err) - } - }, - onLogout: async () => { - await logout() - window.location.reload() - } - }) - - // Post mode (requires login) - if (mode === 'post' && isLoggedIn) { - profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn) - mountPostForm(contentEl, config.collection, () => { - window.location.href = `?handle=${handle}` - }) - return - } - - // AT Browser mode - if (mode === 'browser') { - profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn) - const loginDid = authSession?.did || null - await mountAtBrowser(contentEl, handle, collection, rkey, service, loginDid) - return - } - - // Blog mode (default) - try { - const profile = await getProfile(handle) - - profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn) - const profileContentEl = document.createElement('div') - profileEl.appendChild(profileContentEl) - mountProfile(profileContentEl, profile) - - // Add services - const servicesHtml = await renderServices(handle) - profileContentEl.insertAdjacentHTML('beforeend', servicesHtml) - - if (rkey) { - const post = await getRecord(profile.did, config.collection, rkey) - if (post) { - const canEdit = isLoggedIn && authSession?.did === profile.did - mountPostDetail(contentEl, post, handle, config.collection, canEdit) - } else { - contentEl.innerHTML = '

    Post not found

    ' - } - } else { - const posts = await listRecords(profile.did, config.collection) - mountPostList(contentEl, posts) - } - } catch (err) { - console.error(err) - contentEl.innerHTML = `

    Failed to load: ${err}

    ` - } + // Handle browser navigation + window.addEventListener('popstate', () => render()) } init() diff --git a/src/styles/main.css b/src/styles/main.css index 93e0790..822fc1a 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -289,6 +289,16 @@ body { color: #888; } +/* New post from API (not in static) */ +.post-item-new { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + /* Post Detail */ .post-detail { padding: 20px 0; @@ -429,9 +439,139 @@ body { .post-content { font-size: 16px; line-height: 1.8; - white-space: pre-wrap; } +/* Markdown Styles */ +.post-content h1, +.post-content h2, +.post-content h3, +.post-content h4, +.post-content h5, +.post-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.3; +} + +.post-content h1 { font-size: 1.75em; } +.post-content h2 { font-size: 1.5em; } +.post-content h3 { font-size: 1.25em; } +.post-content h4 { font-size: 1.1em; } + +.post-content p { + margin-bottom: 1em; +} + +.post-content ul, +.post-content ol { + margin-bottom: 1em; + padding-left: 1.5em; +} + +.post-content li { + margin-bottom: 0.25em; +} + +.post-content a { + color: var(--btn-color); + text-decoration: none; +} + +.post-content a:hover { + text-decoration: underline; +} + +.post-content blockquote { + margin: 1em 0; + padding: 0.5em 1em; + border-left: 4px solid #ddd; + background: #f9f9f9; + color: #666; +} + +.post-content code { + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-size: 0.9em; + padding: 0.15em 0.4em; + background: #f0f0f0; + border-radius: 4px; +} + +.post-content pre { + margin: 1em 0; + padding: 1em; + background: #1e1e1e; + border-radius: 8px; + overflow-x: auto; +} + +.post-content pre code { + display: block; + padding: 0; + background: transparent; + color: #d4d4d4; + font-size: 14px; + line-height: 1.5; +} + +.post-content img { + max-width: 100%; + height: auto; + border-radius: 8px; +} + +.post-content hr { + margin: 2em 0; + border: none; + border-top: 1px solid #eee; +} + +.post-content table { + width: 100%; + margin: 1em 0; + border-collapse: collapse; +} + +.post-content th, +.post-content td { + padding: 0.5em; + border: 1px solid #ddd; + text-align: left; +} + +.post-content th { + background: #f5f5f5; + font-weight: 600; +} + +/* Highlight.js Theme Overrides */ +.hljs-keyword, +.hljs-selector-tag, +.hljs-built_in, +.hljs-name, +.hljs-tag { color: #569cd6; } +.hljs-string, +.hljs-title, +.hljs-section, +.hljs-attribute, +.hljs-literal, +.hljs-template-tag, +.hljs-template-variable, +.hljs-type, +.hljs-addition { color: #ce9178; } +.hljs-comment, +.hljs-quote, +.hljs-deletion, +.hljs-meta { color: #6a9955; } +.hljs-number, +.hljs-regexp, +.hljs-symbol, +.hljs-variable, +.hljs-link { color: #b5cea8; } +.hljs-function { color: #dcdcaa; } +.hljs-attr { color: #9cdcfe; } + .post-footer { margin-top: 32px; padding-top: 16px; @@ -460,6 +600,96 @@ body { padding: 40px; text-align: center; color: #666; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.loading-spinner { + width: 24px; + height: 24px; + border: 3px solid #e0e0e0; + border-top-color: var(--btn-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Skeleton UI */ +.browser-skeleton { + padding: 16px 0; +} + +.skeleton-header { + margin-bottom: 16px; +} + +.skeleton-title { + width: 120px; + height: 20px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 4px; +} + +.skeleton-list { + list-style: none; +} + +.skeleton-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 8px; + border-bottom: 1px solid #eee; +} + +.skeleton-icon { + width: 24px; + height: 24px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 4px; +} + +.skeleton-text { + flex: 1; + height: 16px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 4px; + max-width: 200px; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@media (prefers-color-scheme: dark) { + .skeleton-title, + .skeleton-icon, + .skeleton-text { + background: linear-gradient(90deg, #2a2a2a 25%, #333 50%, #2a2a2a 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + } + .skeleton-item { + border-color: #333; + } } /* Footer */ @@ -776,4 +1006,122 @@ body { .delete-btn:hover { background: #c82333; } + /* Dark mode markdown */ + .post-content blockquote { + border-color: #444; + background: #1a1a1a; + color: #aaa; + } + .post-content code { + background: #2a2a2a; + } + .post-content th { + background: #2a2a2a; + } + .post-content th, + .post-content td { + border-color: #444; + } + .post-content hr { + border-color: #333; + } +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal[hidden] { + display: none; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.modal-container { + position: relative; + width: 90%; + max-width: 800px; + max-height: 85vh; + background: #fff; + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #eee; +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + font-size: 24px; + color: #666; + cursor: pointer; + border-radius: 6px; +} + +.modal-close:hover { + background: #f0f0f0; + color: #333; +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +/* Browser tab button styling */ +button.tab { + background: none; + border: none; + cursor: pointer; + font-family: inherit; +} + +/* Dark mode modal */ +@media (prefers-color-scheme: dark) { + .modal-container { + background: #1a1a1a; + } + .modal-header { + border-color: #333; + } + .modal-header h2 { + color: #e0e0e0; + } + .modal-close { + color: #888; + } + .modal-close:hover { + background: #333; + color: #e0e0e0; + } }