fix post generate
This commit is contained in:
9
.github/workflows/cf-pages.yml
vendored
9
.github/workflows/cf-pages.yml
vendored
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
repos
|
||||
content/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
35
readme.md
Normal file
35
readme.md
Normal file
@@ -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
|
||||
```
|
||||
|
||||
717
scripts/generate.ts
Normal file
717
scripts/generate.ts
Normal file
@@ -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 `<pre><code class="hljs">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer,
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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<string> {
|
||||
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<string> {
|
||||
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<Profile> {
|
||||
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<Profile>
|
||||
}
|
||||
|
||||
async function listRecordsFromApi(did: string, collection: string, pdsUrl: string): Promise<BlogPost[]> {
|
||||
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<string, unknown> }> }
|
||||
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<string[]> {
|
||||
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<boolean> {
|
||||
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<string, string> = {
|
||||
'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<string>()
|
||||
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<void> {
|
||||
const faviconDir = getFaviconDir(did)
|
||||
if (!fs.existsSync(faviconDir)) {
|
||||
fs.mkdirSync(faviconDir, { recursive: true })
|
||||
}
|
||||
|
||||
const faviconUrls: Record<string, string> = {
|
||||
'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 `<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)} - ${escapeHtml(config.title)}</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<link rel="stylesheet" href="/assets/${assets.css}">
|
||||
${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"${staticAttr}>
|
||||
${content}
|
||||
</div>
|
||||
<script type="module" src="/assets/${assets.js}"></script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function generateProfileHtml(profile: Profile): string {
|
||||
const avatar = profile.avatar
|
||||
? `<img src="${profile.avatar}" class="profile-avatar" alt="">`
|
||||
: ''
|
||||
return `
|
||||
<div class="profile">
|
||||
${avatar}
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</div>
|
||||
<div class="profile-handle">@${escapeHtml(profile.handle)}</div>
|
||||
${profile.description ? `<div class="profile-desc">${escapeHtml(profile.description)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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<string, string[]>()
|
||||
|
||||
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 `
|
||||
<a href="/at/${handle}/${domain}" class="service-item" title="${cols.join(', ')}">
|
||||
<img src="${faviconSrc}" class="service-favicon" alt="" onerror="this.style.display='none'">
|
||||
<span class="service-name">${domain}</span>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="services">${items}</div>`
|
||||
}
|
||||
|
||||
function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string {
|
||||
return `
|
||||
<div class="mode-tabs">
|
||||
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}">Blog</a>
|
||||
<button type="button" class="tab" id="browser-tab" data-handle="${handle}">Browser</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function generatePostListHtml(posts: BlogPost[]): 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()
|
||||
return `
|
||||
<li class="post-item">
|
||||
<a href="/post/${rkey}/" class="post-link">
|
||||
<span class="post-title">${escapeHtml(post.title)}</span>
|
||||
<span class="post-date">${formatDate(post.createdAt)}</span>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
return `<ul class="post-list">${items}</ul>`
|
||||
}
|
||||
|
||||
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 `
|
||||
<article class="post-detail">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">${escapeHtml(post.title)}</h1>
|
||||
<div class="post-meta">
|
||||
<time class="post-date">${formatDate(post.createdAt)}</time>
|
||||
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="post-content">${content}</div>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
function generateFooterHtml(handle: string): string {
|
||||
const username = handle.split('.')[0] || handle
|
||||
return `
|
||||
<footer class="site-footer">
|
||||
<p>© ${username}</p>
|
||||
</footer>
|
||||
`
|
||||
}
|
||||
|
||||
function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[]): string {
|
||||
return `
|
||||
<header id="header"></header>
|
||||
<main>
|
||||
<section id="profile">
|
||||
${generateTabsHtml('blog', config.handle)}
|
||||
${generateProfileHtml(profile)}
|
||||
${generateServicesHtml(profile.did, config.handle, collections)}
|
||||
</section>
|
||||
<section id="content">
|
||||
${generatePostListHtml(posts)}
|
||||
</section>
|
||||
</main>
|
||||
${generateFooterHtml(config.handle)}
|
||||
`
|
||||
}
|
||||
|
||||
function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[]): string {
|
||||
return `
|
||||
<header id="header"></header>
|
||||
<main>
|
||||
<section id="profile">
|
||||
${generateTabsHtml('blog', config.handle)}
|
||||
${generateProfileHtml(profile)}
|
||||
${generateServicesHtml(profile.did, config.handle, collections)}
|
||||
</section>
|
||||
<section id="content">
|
||||
${generatePostDetailHtml(post, config.handle, config.collection)}
|
||||
</section>
|
||||
</main>
|
||||
${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 = `
|
||||
<header id="header"></header>
|
||||
<main>
|
||||
<section id="profile"></section>
|
||||
<section id="content"></section>
|
||||
</main>
|
||||
<footer id="footer"></footer>
|
||||
`
|
||||
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 = `
|
||||
<header id="header"></header>
|
||||
<main>
|
||||
<section id="profile"></section>
|
||||
<nav id="tabs"></nav>
|
||||
<section id="content"></section>
|
||||
</main>
|
||||
<footer id="footer"></footer>
|
||||
`
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
|
||||
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
|
||||
return `
|
||||
<li class="service-list-item">
|
||||
<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}" class="service-list-link">
|
||||
<a href="/at/${handle}/${domain}" class="service-list-link">
|
||||
<img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.style.display='none'">
|
||||
<span class="service-list-name">${info.name}</span>
|
||||
<span class="service-list-count">${info.count}</span>
|
||||
@@ -84,7 +84,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str
|
||||
const items = filtered.map(col => {
|
||||
return `
|
||||
<li class="collection-item">
|
||||
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link">
|
||||
<a href="/at/${handle}/${col}" class="collection-link">
|
||||
<span class="collection-nsid">${col}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -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 `
|
||||
<li class="record-item">
|
||||
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}&rkey=${rkey}" class="record-link">
|
||||
<a href="/at/${handle}/${collection}/${rkey}" class="record-link">
|
||||
<span class="record-rkey">${rkey}</span>
|
||||
<span class="record-preview">${preview}</span>
|
||||
</a>
|
||||
@@ -168,7 +168,7 @@ export async function mountAtBrowser(
|
||||
service: string | null = null,
|
||||
loginDid: string | null = null
|
||||
): Promise<void> {
|
||||
container.innerHTML = '<p class="loading">Loading...</p>'
|
||||
container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>'
|
||||
|
||||
try {
|
||||
const did = handle.startsWith('did:') ? handle : await resolveHandle(handle)
|
||||
@@ -178,16 +178,16 @@ export async function mountAtBrowser(
|
||||
let nav = ''
|
||||
|
||||
if (collection && rkey) {
|
||||
nav = `<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}" class="back-link">← Back</a>`
|
||||
nav = `<a href="/at/${handle}/${collection}" class="back-link">← Back</a>`
|
||||
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 = `<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(backService)}" class="back-link">← ${info?.name || 'Back'}</a>`
|
||||
nav = `<a href="/at/${handle}/${backService}" class="back-link">← ${info?.name || 'Back'}</a>`
|
||||
content = await renderRecordList(did, handle, collection)
|
||||
} else if (service) {
|
||||
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Services</a>`
|
||||
nav = `<a href="/at/${handle}" class="back-link">← Services</a>`
|
||||
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
|
||||
|
||||
@@ -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 `
|
||||
<li class="post-item">
|
||||
<a href="?rkey=${rkey}" class="post-link">
|
||||
<a href="/post/${rkey}" class="post-link">
|
||||
<span class="post-title">${escapeHtml(post.title)}</span>
|
||||
<span class="post-date">${formatDate(post.createdAt)}</span>
|
||||
</a>
|
||||
@@ -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 ? `<button class="edit-btn" id="edit-btn">edit</button>` : ''
|
||||
|
||||
@@ -55,7 +56,7 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
|
||||
${editBtn}
|
||||
</div>
|
||||
</header>
|
||||
<div class="post-content" id="post-content">${escapeHtml(post.content)}</div>
|
||||
<div class="post-content" id="post-content">${renderMarkdown(post.content)}</div>
|
||||
</article>
|
||||
|
||||
<div class="edit-form-container" id="edit-form-container" style="display: none;">
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function renderServices(handle: string): Promise<string> {
|
||||
}
|
||||
|
||||
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
|
||||
const url = `?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}`
|
||||
const url = `/at/${handle}/${domain}`
|
||||
|
||||
return `
|
||||
<a href="${url}" class="service-item" title="${info.collections.join(', ')}">
|
||||
|
||||
71
src/lib/markdown.ts
Normal file
71
src/lib/markdown.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { marked, Renderer } from 'marked'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
|
||||
// Import only common languages
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import rust from 'highlight.js/lib/languages/rust'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('js', javascript)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('ts', typescript)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('sh', bash)
|
||||
hljs.registerLanguage('shell', bash)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('yml', yaml)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
hljs.registerLanguage('md', markdown)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('html', xml)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('py', python)
|
||||
hljs.registerLanguage('rust', rust)
|
||||
hljs.registerLanguage('rs', rust)
|
||||
hljs.registerLanguage('go', go)
|
||||
|
||||
// Custom renderer with syntax highlighting
|
||||
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 {
|
||||
// No auto-detect, just escape
|
||||
highlighted = escapeHtml(text)
|
||||
}
|
||||
return `<pre><code class="hljs">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer,
|
||||
})
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return marked.parse(content) as string
|
||||
}
|
||||
95
src/lib/router.ts
Normal file
95
src/lib/router.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface Route {
|
||||
type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new'
|
||||
handle?: string
|
||||
collection?: string
|
||||
rkey?: string
|
||||
service?: string
|
||||
}
|
||||
|
||||
export function parseRoute(pathname: string): Route {
|
||||
const parts = pathname.split('/').filter(Boolean)
|
||||
|
||||
// / - Blog top
|
||||
if (parts.length === 0) {
|
||||
return { type: 'blog' }
|
||||
}
|
||||
|
||||
// /new - New post form
|
||||
if (parts[0] === 'new') {
|
||||
return { type: 'new' }
|
||||
}
|
||||
|
||||
// /app - SPA entry point (same as blog)
|
||||
if (parts[0] === 'app') {
|
||||
return { type: 'blog' }
|
||||
}
|
||||
|
||||
// /post - New post form (no rkey)
|
||||
// /post/${rkey} - Post detail
|
||||
if (parts[0] === 'post') {
|
||||
if (parts[1]) {
|
||||
return { type: 'post', rkey: parts[1] }
|
||||
}
|
||||
return { type: 'new' }
|
||||
}
|
||||
|
||||
// /at/${handle} - Browser services
|
||||
// /at/${handle}/${service-or-collection} - Browser collections or records
|
||||
// /at/${handle}/${collection}/${rkey} - Browser record detail
|
||||
if (parts[0] === 'at' && parts[1]) {
|
||||
const handle = parts[1]
|
||||
|
||||
if (!parts[2]) {
|
||||
// /at/${handle}
|
||||
return { type: 'browser-services', handle }
|
||||
}
|
||||
|
||||
if (!parts[3]) {
|
||||
// /at/${handle}/${service-or-collection}
|
||||
// If it looks like a domain (2 parts), treat as service
|
||||
// Otherwise treat as collection NSID (3+ parts)
|
||||
const segment = parts[2]
|
||||
if (segment.split('.').length <= 2) {
|
||||
// Likely a service domain like "bsky.app"
|
||||
return { type: 'browser-collections', handle, service: segment }
|
||||
} else {
|
||||
// Likely a collection NSID like "app.bsky.feed.post"
|
||||
// Show record list for this collection
|
||||
return { type: 'browser-record', handle, collection: segment }
|
||||
}
|
||||
}
|
||||
|
||||
// /at/${handle}/${collection}/${rkey}
|
||||
return { type: 'browser-record', handle, collection: parts[2], rkey: parts[3] }
|
||||
}
|
||||
|
||||
// Fallback to blog
|
||||
return { type: 'blog' }
|
||||
}
|
||||
|
||||
export function buildPath(route: Route): string {
|
||||
switch (route.type) {
|
||||
case 'blog':
|
||||
return '/'
|
||||
case 'new':
|
||||
return '/new'
|
||||
case 'post':
|
||||
return `/post/${route.rkey}`
|
||||
case 'browser-services':
|
||||
return `/at/${route.handle}`
|
||||
case 'browser-collections':
|
||||
return `/at/${route.handle}/${route.service}`
|
||||
case 'browser-record':
|
||||
if (route.rkey) {
|
||||
return `/at/${route.handle}/${route.collection}/${route.rkey}`
|
||||
}
|
||||
return `/at/${route.handle}/${route.collection}`
|
||||
default:
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
|
||||
export function navigate(path: string): void {
|
||||
window.history.pushState({}, '', path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
521
src/main.ts
521
src/main.ts
@@ -6,9 +6,21 @@ import { mountPostList, mountPostDetail } from './components/posts.js'
|
||||
import { mountHeader } from './components/browser.js'
|
||||
import { mountAtBrowser } from './components/atbrowser.js'
|
||||
import { mountPostForm } from './components/postform.js'
|
||||
import { parseRoute, type Route } from './lib/router.js'
|
||||
import type { AppConfig, Networks } from './types.js'
|
||||
|
||||
let authSession: AuthSession | null = null
|
||||
let config: AppConfig
|
||||
|
||||
// Browser state
|
||||
let browserMode = false
|
||||
let browserState = {
|
||||
handle: '',
|
||||
collection: null as string | null,
|
||||
rkey: null as string | null,
|
||||
service: null as string | null
|
||||
}
|
||||
let savedContent: { profile: string; content: string } | null = null
|
||||
|
||||
async function loadConfig(): Promise<AppConfig> {
|
||||
const res = await fetch('/config.json')
|
||||
@@ -30,25 +42,419 @@ function renderFooter(handle: string): string {
|
||||
`
|
||||
}
|
||||
|
||||
function renderTabs(handle: string, mode: string | null, isLoggedIn: boolean): string {
|
||||
const blogActive = !mode || mode === 'blog' ? 'active' : ''
|
||||
const browserActive = mode === 'browser' ? 'active' : ''
|
||||
const postActive = mode === 'post' ? 'active' : ''
|
||||
|
||||
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
|
||||
let tabs = `
|
||||
<a href="?handle=${handle}" class="tab ${blogActive}">Blog</a>
|
||||
<a href="?mode=browser&handle=${handle}" class="tab ${browserActive}">Browser</a>
|
||||
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
||||
<button type="button" class="tab ${activeTab === 'browser' ? 'active' : ''}" id="browser-tab" data-handle="${config.handle}">Browser</button>
|
||||
`
|
||||
|
||||
if (isLoggedIn) {
|
||||
tabs += `<a href="?mode=post&handle=${handle}" class="tab ${postActive}">Post</a>`
|
||||
tabs += `<a href="/post" class="tab ${activeTab === 'new' ? 'active' : ''}">Post</a>`
|
||||
}
|
||||
|
||||
return `<div class="mode-tabs">${tabs}</div>`
|
||||
}
|
||||
|
||||
// Browser functions (page-based, not modal)
|
||||
function openBrowser(handle: string, service: string | null = null, collection: string | null = null, rkey: string | null = null): void {
|
||||
const contentEl = document.getElementById('content')
|
||||
const tabsEl = document.querySelector('.mode-tabs')
|
||||
|
||||
if (!contentEl || !tabsEl) return
|
||||
|
||||
// Save current content if not already in browser mode
|
||||
if (!browserMode) {
|
||||
savedContent = {
|
||||
profile: '', // Not used anymore
|
||||
content: contentEl.innerHTML
|
||||
}
|
||||
}
|
||||
|
||||
browserMode = true
|
||||
browserState = { handle, service, collection, rkey }
|
||||
|
||||
// Update tabs to show browser as active
|
||||
const blogTab = tabsEl.querySelector('#blog-tab, a[href="/"]')
|
||||
const browserTab = tabsEl.querySelector('#browser-tab')
|
||||
if (blogTab) blogTab.classList.remove('active')
|
||||
if (browserTab) browserTab.classList.add('active')
|
||||
|
||||
// Show skeleton UI immediately
|
||||
contentEl.innerHTML = `
|
||||
<div class="browser-skeleton">
|
||||
<div class="skeleton-header">
|
||||
<div class="skeleton-title"></div>
|
||||
</div>
|
||||
<ul class="skeleton-list">
|
||||
<li class="skeleton-item"><div class="skeleton-icon"></div><div class="skeleton-text"></div></li>
|
||||
<li class="skeleton-item"><div class="skeleton-icon"></div><div class="skeleton-text"></div></li>
|
||||
<li class="skeleton-item"><div class="skeleton-icon"></div><div class="skeleton-text"></div></li>
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Load browser content
|
||||
loadBrowserContent()
|
||||
}
|
||||
|
||||
function closeBrowser(): void {
|
||||
if (!browserMode || !savedContent) return
|
||||
|
||||
const contentEl = document.getElementById('content')
|
||||
const tabsEl = document.querySelector('.mode-tabs')
|
||||
|
||||
if (!contentEl || !tabsEl) return
|
||||
|
||||
// Restore saved content
|
||||
contentEl.innerHTML = savedContent.content
|
||||
|
||||
// Update tabs to show blog as active
|
||||
const blogTab = tabsEl.querySelector('#blog-tab, a[href="/"]')
|
||||
const browserTab = tabsEl.querySelector('#browser-tab')
|
||||
if (blogTab) blogTab.classList.add('active')
|
||||
if (browserTab) browserTab.classList.remove('active')
|
||||
|
||||
browserMode = false
|
||||
browserState = { handle: '', service: null, collection: null, rkey: null }
|
||||
savedContent = null
|
||||
}
|
||||
|
||||
async function loadBrowserContent(): Promise<void> {
|
||||
const contentEl = document.getElementById('content')
|
||||
if (!contentEl) return
|
||||
|
||||
const loginDid = authSession?.did || null
|
||||
await mountAtBrowser(
|
||||
contentEl,
|
||||
browserState.handle,
|
||||
browserState.collection,
|
||||
browserState.rkey,
|
||||
browserState.service,
|
||||
loginDid
|
||||
)
|
||||
}
|
||||
|
||||
// Add edit button to static post page
|
||||
async function addEditButtonToStaticPost(collection: string, rkey: string, session: AuthSession): Promise<void> {
|
||||
const postMeta = document.querySelector('.post-meta')
|
||||
if (!postMeta) return
|
||||
|
||||
// Check if user owns this post
|
||||
const profile = await getProfile(config.handle)
|
||||
if (session.did !== profile.did) return
|
||||
|
||||
// Add edit button
|
||||
const editBtn = document.createElement('button')
|
||||
editBtn.className = 'edit-btn'
|
||||
editBtn.id = 'edit-btn'
|
||||
editBtn.textContent = 'edit'
|
||||
postMeta.appendChild(editBtn)
|
||||
|
||||
// Get current post content
|
||||
const titleEl = document.querySelector('.post-title') as HTMLElement
|
||||
const contentEl = document.querySelector('.post-content') as HTMLElement
|
||||
const postArticle = document.querySelector('.post-detail') as HTMLElement
|
||||
|
||||
if (!titleEl || !contentEl || !postArticle) return
|
||||
|
||||
const originalTitle = titleEl.textContent || ''
|
||||
// Get original markdown from the record
|
||||
const record = await getRecord(profile.did, collection, rkey)
|
||||
if (!record) return
|
||||
|
||||
// Create edit form
|
||||
const editFormContainer = document.createElement('div')
|
||||
editFormContainer.className = 'edit-form-container'
|
||||
editFormContainer.id = 'edit-form-container'
|
||||
editFormContainer.style.display = 'none'
|
||||
editFormContainer.innerHTML = `
|
||||
<h3>Edit Post</h3>
|
||||
<form class="edit-form" id="edit-form">
|
||||
<input type="text" id="edit-title" class="edit-form-title" value="${escapeHtml(originalTitle)}" placeholder="Title" required>
|
||||
<textarea id="edit-content" class="edit-form-body" placeholder="Content" required>${escapeHtml(record.content)}</textarea>
|
||||
<div class="edit-form-footer">
|
||||
<button type="button" id="edit-cancel" class="edit-cancel-btn">Cancel</button>
|
||||
<button type="submit" id="edit-submit" class="edit-submit-btn">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
`
|
||||
postArticle.parentNode?.insertBefore(editFormContainer, postArticle.nextSibling)
|
||||
|
||||
// Event listeners
|
||||
editBtn.addEventListener('click', () => {
|
||||
postArticle.style.display = 'none'
|
||||
editFormContainer.style.display = 'block'
|
||||
})
|
||||
|
||||
document.getElementById('edit-cancel')?.addEventListener('click', () => {
|
||||
postArticle.style.display = 'block'
|
||||
editFormContainer.style.display = 'none'
|
||||
})
|
||||
|
||||
document.getElementById('edit-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const title = (document.getElementById('edit-title') as HTMLInputElement).value
|
||||
const content = (document.getElementById('edit-content') as HTMLTextAreaElement).value
|
||||
const submitBtn = document.getElementById('edit-submit') as HTMLButtonElement
|
||||
|
||||
try {
|
||||
submitBtn.disabled = true
|
||||
submitBtn.textContent = 'Saving...'
|
||||
|
||||
const { putRecord } = await import('./lib/auth.js')
|
||||
await putRecord(collection, rkey, {
|
||||
title,
|
||||
content,
|
||||
createdAt: record.createdAt,
|
||||
})
|
||||
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
alert('Save failed: ' + err)
|
||||
submitBtn.disabled = false
|
||||
submitBtn.textContent = 'Save'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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', '<a href="/post" class="tab">Post</a>')
|
||||
}
|
||||
}
|
||||
|
||||
// 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!)
|
||||
}
|
||||
|
||||
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 = '<p>Post not found</p>'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||
}
|
||||
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 = `<p class="error">Failed to load: ${err}</p>`
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
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 +476,17 @@ async function init(): Promise<void> {
|
||||
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 = '<p>Post not found</p>'
|
||||
}
|
||||
} else {
|
||||
const posts = await listRecords(profile.did, config.collection)
|
||||
mountPostList(contentEl, posts)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||
}
|
||||
// Handle browser navigation
|
||||
window.addEventListener('popstate', () => render())
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
@@ -429,9 +429,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 +590,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 +996,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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user