init
This commit is contained in:
36
.github/workflows/cf-pages.yml
vendored
Normal file
36
.github/workflows/cf-pages.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: dist
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
.claude
|
||||
node_modules
|
||||
package-lock.json
|
||||
repos
|
||||
22
index.html
Normal file
22
index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>syui.ai</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/src/styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header"></header>
|
||||
<main>
|
||||
<section id="profile"></section>
|
||||
<nav id="tabs"></nav>
|
||||
<section id="content"></section>
|
||||
</main>
|
||||
<footer id="footer"></footer>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "ailog",
|
||||
"version": "0.1.0",
|
||||
"description": "AT Protocol site generator",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.8",
|
||||
"@atproto/oauth-client-browser": "^0.3.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
34
public/.well-known/lexicon/ai.syui.log.post.json
Normal file
34
public/.well-known/lexicon/ai.syui.log.post.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "ai.syui.log.post",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"description": "Record containing a blog post.",
|
||||
"key": "tid",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["title", "content", "createdAt"],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 3000,
|
||||
"maxGraphemes": 300,
|
||||
"description": "The title of the post."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"maxLength": 1000000,
|
||||
"maxGraphemes": 100000,
|
||||
"description": "The content of the post."
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "Client-declared timestamp when this post was originally created."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
public/_redirects
Normal file
1
public/_redirects
Normal file
@@ -0,0 +1 @@
|
||||
/* /index.html 200
|
||||
15
public/client-metadata.json
Normal file
15
public/client-metadata.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"client_id": "https://syui.ai/client-metadata.json",
|
||||
"client_name": "ailog",
|
||||
"client_uri": "https://syui.ai",
|
||||
"logo_uri": "https://syui.ai/favicon.ico",
|
||||
"tos_uri": "https://syui.ai/tos",
|
||||
"policy_uri": "https://syui.ai/policy",
|
||||
"redirect_uris": ["https://syui.ai/", "https://syui.ai/oauth/callback"],
|
||||
"scope": "atproto transition:generic",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"application_type": "web",
|
||||
"dpop_bound_access_tokens": true
|
||||
}
|
||||
7
public/config.json
Normal file
7
public/config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "syui.ai",
|
||||
"handle": "syui.ai",
|
||||
"collection": "ai.syui.log.post",
|
||||
"network": "bsky.social",
|
||||
"color": "#0066cc"
|
||||
}
|
||||
22
public/favicon.svg
Normal file
22
public/favicon.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
|
||||
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
|
||||
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
10
public/networks.json
Normal file
10
public/networks.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"bsky.social": {
|
||||
"plc": "https://plc.directory",
|
||||
"bsky": "https://public.api.bsky.app"
|
||||
},
|
||||
"syu.is": {
|
||||
"plc": "https://plc.syu.is",
|
||||
"bsky": "https://bsky.syu.is"
|
||||
}
|
||||
}
|
||||
227
src/components/atbrowser.ts
Normal file
227
src/components/atbrowser.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo } from '../lib/api.js'
|
||||
import { deleteRecord } from '../lib/auth.js'
|
||||
|
||||
function extractRkey(uri: string): string {
|
||||
const parts = uri.split('/')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('ja-JP', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
async function renderServices(did: string, handle: string): Promise<string> {
|
||||
const collections = await describeRepo(did)
|
||||
|
||||
if (collections.length === 0) {
|
||||
return '<p class="no-data">No collections found</p>'
|
||||
}
|
||||
|
||||
// Group by service domain
|
||||
const serviceMap = new Map<string, { name: string; favicon: string; count: number }>()
|
||||
|
||||
for (const col of collections) {
|
||||
const info = getServiceInfo(col)
|
||||
if (info) {
|
||||
const key = info.domain
|
||||
if (!serviceMap.has(key)) {
|
||||
serviceMap.set(key, { name: info.name, favicon: info.favicon, count: 0 })
|
||||
}
|
||||
serviceMap.get(key)!.count++
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="services-list">
|
||||
<h3>Services</h3>
|
||||
<ul class="service-list">${items}</ul>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function renderCollections(did: string, handle: string, serviceDomain: string): Promise<string> {
|
||||
const collections = await describeRepo(did)
|
||||
|
||||
// Filter by service domain
|
||||
const filtered = collections.filter(col => {
|
||||
const info = getServiceInfo(col)
|
||||
return info && info.domain === serviceDomain
|
||||
})
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return '<p class="no-data">No collections found</p>'
|
||||
}
|
||||
|
||||
// Get favicon from first collection
|
||||
const firstInfo = getServiceInfo(filtered[0])
|
||||
const favicon = firstInfo ? `<img src="${firstInfo.favicon}" class="collection-header-favicon" alt="" onerror="this.style.display='none'">` : ''
|
||||
|
||||
const items = filtered.map(col => {
|
||||
return `
|
||||
<li class="collection-item">
|
||||
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link">
|
||||
<span class="collection-nsid">${col}</span>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="collections">
|
||||
<h3 class="collection-header">${favicon}<span>${serviceDomain}</span></h3>
|
||||
<ul class="collection-list">${items}</ul>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function renderRecordList(did: string, handle: string, collection: string): Promise<string> {
|
||||
const records = await listRecordsRaw(did, collection)
|
||||
|
||||
if (records.length === 0) {
|
||||
return '<p class="no-data">No records found</p>'
|
||||
}
|
||||
|
||||
const items = records.map(rec => {
|
||||
const rkey = extractRkey(rec.uri)
|
||||
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">
|
||||
<span class="record-rkey">${rkey}</span>
|
||||
<span class="record-preview">${preview}</span>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="records">
|
||||
<h3>${collection}</h3>
|
||||
<p class="record-count">${records.length} records</p>
|
||||
<ul class="record-list">${items}</ul>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function renderRecordDetail(did: string, handle: string, collection: string, rkey: string, canDelete: boolean): Promise<string> {
|
||||
const record = await getRecordRaw(did, collection, rkey)
|
||||
|
||||
if (!record) {
|
||||
return '<p class="error">Record not found</p>'
|
||||
}
|
||||
|
||||
const lexicon = await fetchLexicon(collection)
|
||||
const schemaStatus = lexicon ? 'verified' : 'none'
|
||||
const schemaLabel = lexicon ? '✓ Schema' : '○ No schema'
|
||||
const json = JSON.stringify(record, null, 2)
|
||||
|
||||
const deleteBtn = canDelete
|
||||
? `<button class="delete-btn" data-collection="${collection}" data-rkey="${rkey}">Delete</button>`
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="record-detail">
|
||||
<div class="record-header">
|
||||
<h3>${collection}</h3>
|
||||
<p class="record-uri">${record.uri}</p>
|
||||
<p class="record-cid">CID: ${record.cid}</p>
|
||||
<span class="schema-status schema-${schemaStatus}">${schemaLabel}</span>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
<div class="json-view">
|
||||
<pre><code>${escapeHtml(json)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export async function mountAtBrowser(
|
||||
container: HTMLElement,
|
||||
handle: string,
|
||||
collection: string | null,
|
||||
rkey: string | null,
|
||||
service: string | null = null,
|
||||
loginDid: string | null = null
|
||||
): Promise<void> {
|
||||
container.innerHTML = '<p class="loading">Loading...</p>'
|
||||
|
||||
try {
|
||||
const did = handle.startsWith('did:') ? handle : await resolveHandle(handle)
|
||||
const canDelete = loginDid !== null && loginDid === did
|
||||
|
||||
let content: string
|
||||
let nav = ''
|
||||
|
||||
if (collection && rkey) {
|
||||
nav = `<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(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>`
|
||||
content = await renderRecordList(did, handle, collection)
|
||||
} else if (service) {
|
||||
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Services</a>`
|
||||
content = await renderCollections(did, handle, service)
|
||||
} else {
|
||||
content = await renderServices(did, handle)
|
||||
}
|
||||
|
||||
container.innerHTML = nav + content
|
||||
|
||||
// Add delete button handler
|
||||
const deleteBtn = container.querySelector('.delete-btn')
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault()
|
||||
const btn = e.target as HTMLButtonElement
|
||||
const col = btn.dataset.collection
|
||||
const rk = btn.dataset.rkey
|
||||
|
||||
if (!col || !rk) return
|
||||
|
||||
if (!confirm('Delete this record?')) return
|
||||
|
||||
try {
|
||||
btn.disabled = true
|
||||
btn.textContent = 'Deleting...'
|
||||
await deleteRecord(col, rk)
|
||||
// Go back to collection
|
||||
window.location.href = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}`
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err)
|
||||
btn.disabled = false
|
||||
btn.textContent = 'Delete'
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="error">Failed to load: ${err}</p>`
|
||||
}
|
||||
}
|
||||
89
src/components/browser.ts
Normal file
89
src/components/browser.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export function renderHeader(currentHandle: string, isLoggedIn: boolean, userHandle?: string): string {
|
||||
const loginBtn = isLoggedIn
|
||||
? `<button type="button" class="header-btn user-btn" id="user-btn" title="${userHandle}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/>
|
||||
</svg>
|
||||
</button>`
|
||||
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
</button>`
|
||||
|
||||
return `
|
||||
<div class="header">
|
||||
<form class="header-form" id="header-form">
|
||||
<input
|
||||
type="text"
|
||||
class="header-input"
|
||||
id="header-input"
|
||||
placeholder="handle (e.g., syui.ai)"
|
||||
value="${currentHandle}"
|
||||
>
|
||||
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
|
||||
${loginBtn}
|
||||
</form>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export interface HeaderCallbacks {
|
||||
onBrowse: (handle: string) => void
|
||||
onLogin: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function mountHeader(
|
||||
container: HTMLElement,
|
||||
currentHandle: string,
|
||||
isLoggedIn: boolean,
|
||||
userHandle: string | undefined,
|
||||
callbacks: HeaderCallbacks
|
||||
): void {
|
||||
container.innerHTML = renderHeader(currentHandle, isLoggedIn, userHandle)
|
||||
|
||||
const form = document.getElementById('header-form') as HTMLFormElement
|
||||
const input = document.getElementById('header-input') as HTMLInputElement
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault()
|
||||
const handle = input.value.trim()
|
||||
if (handle) {
|
||||
callbacks.onBrowse(handle)
|
||||
}
|
||||
})
|
||||
|
||||
if (isLoggedIn) {
|
||||
const userBtn = document.getElementById('user-btn')
|
||||
userBtn?.addEventListener('click', async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (confirm('Logout?')) {
|
||||
await callbacks.onLogout()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const loginBtn = document.getElementById('login-btn')
|
||||
loginBtn?.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
callbacks.onLogin()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep old function for compatibility
|
||||
export function mountBrowser(
|
||||
container: HTMLElement,
|
||||
currentHandle: string,
|
||||
onSubmit: (handle: string) => void
|
||||
): void {
|
||||
mountHeader(container, currentHandle, false, undefined, {
|
||||
onBrowse: onSubmit,
|
||||
onLogin: () => {},
|
||||
onLogout: () => {}
|
||||
})
|
||||
}
|
||||
74
src/components/postform.ts
Normal file
74
src/components/postform.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createPost } from '../lib/auth.js'
|
||||
|
||||
export function renderPostForm(collection: string): string {
|
||||
return `
|
||||
<div class="post-form-container">
|
||||
<h3>New Post</h3>
|
||||
<form class="post-form" id="post-form">
|
||||
<input
|
||||
type="text"
|
||||
class="post-form-title"
|
||||
id="post-title"
|
||||
placeholder="Title"
|
||||
required
|
||||
>
|
||||
<textarea
|
||||
class="post-form-body"
|
||||
id="post-body"
|
||||
placeholder="Content"
|
||||
rows="6"
|
||||
required
|
||||
></textarea>
|
||||
<div class="post-form-footer">
|
||||
<span class="post-form-collection">${collection}</span>
|
||||
<button type="submit" class="post-form-btn" id="post-submit">Post</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="post-status" class="post-status"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export function mountPostForm(
|
||||
container: HTMLElement,
|
||||
collection: string,
|
||||
onSuccess: () => void
|
||||
): void {
|
||||
container.innerHTML = renderPostForm(collection)
|
||||
|
||||
const form = document.getElementById('post-form') as HTMLFormElement
|
||||
const titleInput = document.getElementById('post-title') as HTMLInputElement
|
||||
const bodyInput = document.getElementById('post-body') as HTMLTextAreaElement
|
||||
const submitBtn = document.getElementById('post-submit') as HTMLButtonElement
|
||||
const statusEl = document.getElementById('post-status') as HTMLDivElement
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const title = titleInput.value.trim()
|
||||
const body = bodyInput.value.trim()
|
||||
|
||||
if (!title || !body) return
|
||||
|
||||
submitBtn.disabled = true
|
||||
submitBtn.textContent = 'Posting...'
|
||||
statusEl.innerHTML = ''
|
||||
|
||||
try {
|
||||
const result = await createPost(collection, title, body)
|
||||
if (result) {
|
||||
statusEl.innerHTML = `<span class="post-success">Posted successfully!</span>`
|
||||
titleInput.value = ''
|
||||
bodyInput.value = ''
|
||||
setTimeout(() => {
|
||||
onSuccess()
|
||||
}, 1000)
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = `<span class="post-error">Error: ${err}</span>`
|
||||
} finally {
|
||||
submitBtn.disabled = false
|
||||
submitBtn.textContent = 'Post'
|
||||
}
|
||||
})
|
||||
}
|
||||
115
src/components/posts.ts
Normal file
115
src/components/posts.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { BlogPost } from '../types.js'
|
||||
import { putRecord } from '../lib/auth.js'
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('ja-JP', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = '<p class="no-posts">No posts yet</p>'
|
||||
return
|
||||
}
|
||||
|
||||
const html = posts.map(post => {
|
||||
const rkey = post.uri.split('/').pop()
|
||||
return `
|
||||
<li class="post-item">
|
||||
<a href="?rkey=${rkey}" class="post-link">
|
||||
<span class="post-title">${escapeHtml(post.title)}</span>
|
||||
<span class="post-date">${formatDate(post.createdAt)}</span>
|
||||
</a>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
container.innerHTML = `<ul class="post-list">${html}</ul>`
|
||||
}
|
||||
|
||||
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 editBtn = canEdit ? `<button class="edit-btn" id="edit-btn">edit</button>` : ''
|
||||
|
||||
container.innerHTML = `
|
||||
<article class="post-detail">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title" id="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>
|
||||
${editBtn}
|
||||
</div>
|
||||
</header>
|
||||
<div class="post-content" id="post-content">${escapeHtml(post.content)}</div>
|
||||
</article>
|
||||
|
||||
<div class="edit-form-container" id="edit-form-container" style="display: none;">
|
||||
<h3>Edit Post</h3>
|
||||
<form class="edit-form" id="edit-form">
|
||||
<input type="text" id="edit-title" class="edit-form-title" value="${escapeHtml(post.title)}" placeholder="Title" required>
|
||||
<textarea id="edit-content" class="edit-form-body" placeholder="Content" required>${escapeHtml(post.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>
|
||||
</div>
|
||||
`
|
||||
|
||||
if (canEdit) {
|
||||
const editBtnEl = document.getElementById('edit-btn')
|
||||
const editFormContainer = document.getElementById('edit-form-container')
|
||||
const editForm = document.getElementById('edit-form') as HTMLFormElement
|
||||
const editCancel = document.getElementById('edit-cancel')
|
||||
const postArticle = container.querySelector('.post-detail') as HTMLElement
|
||||
|
||||
editBtnEl?.addEventListener('click', () => {
|
||||
postArticle.style.display = 'none'
|
||||
editFormContainer!.style.display = 'block'
|
||||
})
|
||||
|
||||
editCancel?.addEventListener('click', () => {
|
||||
postArticle.style.display = 'block'
|
||||
editFormContainer!.style.display = 'none'
|
||||
})
|
||||
|
||||
editForm?.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...'
|
||||
|
||||
await putRecord(collection, rkey, {
|
||||
title,
|
||||
content,
|
||||
createdAt: post.createdAt,
|
||||
})
|
||||
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
alert('Save failed: ' + err)
|
||||
submitBtn.disabled = false
|
||||
submitBtn.textContent = 'Save'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
18
src/components/profile.ts
Normal file
18
src/components/profile.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Profile } from '../types.js'
|
||||
|
||||
export function renderProfile(profile: Profile): string {
|
||||
return `
|
||||
<div class="profile">
|
||||
${profile.avatar ? `<img src="${profile.avatar}" alt="avatar" class="profile-avatar">` : ''}
|
||||
<div class="profile-info">
|
||||
<h1 class="profile-name">${profile.displayName || profile.handle}</h1>
|
||||
<p class="profile-handle">@${profile.handle}</p>
|
||||
${profile.description ? `<p class="profile-desc">${profile.description}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export function mountProfile(container: HTMLElement, profile: Profile): void {
|
||||
container.innerHTML = renderProfile(profile)
|
||||
}
|
||||
42
src/components/services.ts
Normal file
42
src/components/services.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describeRepo, getServiceInfo, resolveHandle } from '../lib/api.js'
|
||||
|
||||
export async function renderServices(handle: string): Promise<string> {
|
||||
const did = handle.startsWith('did:') ? handle : await resolveHandle(handle)
|
||||
const collections = await describeRepo(did)
|
||||
|
||||
if (collections.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Group by service
|
||||
const serviceMap = new Map<string, { name: string; favicon: string; collections: string[] }>()
|
||||
|
||||
for (const col of collections) {
|
||||
const info = getServiceInfo(col)
|
||||
if (info) {
|
||||
const key = info.domain
|
||||
if (!serviceMap.has(key)) {
|
||||
serviceMap.set(key, { name: info.name, favicon: info.favicon, collections: [] })
|
||||
}
|
||||
serviceMap.get(key)!.collections.push(col)
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
|
||||
const url = `?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}`
|
||||
|
||||
return `
|
||||
<a href="${url}" class="service-item" title="${info.collections.join(', ')}">
|
||||
<img src="${info.favicon}" class="service-favicon" alt="" onerror="this.style.display='none'">
|
||||
<span class="service-name">${info.name}</span>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="services">${items}</div>`
|
||||
}
|
||||
|
||||
export async function mountServices(container: HTMLElement, handle: string): Promise<void> {
|
||||
const html = await renderServices(handle)
|
||||
container.innerHTML = html
|
||||
}
|
||||
217
src/lib/api.ts
Normal file
217
src/lib/api.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { AtpAgent } from '@atproto/api'
|
||||
import type { Profile, BlogPost, NetworkConfig } from '../types.js'
|
||||
|
||||
const agents: Map<string, AtpAgent> = new Map()
|
||||
|
||||
let networkConfig: NetworkConfig | null = null
|
||||
|
||||
export function setNetworkConfig(config: NetworkConfig): void {
|
||||
networkConfig = config
|
||||
}
|
||||
|
||||
function getPlc(): string {
|
||||
return networkConfig?.plc || 'https://plc.directory'
|
||||
}
|
||||
|
||||
function getBsky(): string {
|
||||
return networkConfig?.bsky || 'https://public.api.bsky.app'
|
||||
}
|
||||
|
||||
export function getAgent(service: string): AtpAgent {
|
||||
if (!agents.has(service)) {
|
||||
agents.set(service, new AtpAgent({ service }))
|
||||
}
|
||||
return agents.get(service)!
|
||||
}
|
||||
|
||||
export async function resolvePds(did: string): Promise<string> {
|
||||
const res = await fetch(`${getPlc()}/${did}`)
|
||||
const doc = await res.json()
|
||||
const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
|
||||
return service?.serviceEndpoint || getBsky()
|
||||
}
|
||||
|
||||
export async function resolveHandle(handle: string): Promise<string> {
|
||||
const agent = getAgent(getBsky())
|
||||
const res = await agent.resolveHandle({ handle })
|
||||
return res.data.did
|
||||
}
|
||||
|
||||
export async function getProfile(actor: string): Promise<Profile> {
|
||||
const agent = getAgent(getBsky())
|
||||
const res = await agent.getProfile({ actor })
|
||||
return {
|
||||
did: res.data.did,
|
||||
handle: res.data.handle,
|
||||
displayName: res.data.displayName,
|
||||
description: res.data.description,
|
||||
avatar: res.data.avatar,
|
||||
banner: res.data.banner,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRecords(
|
||||
did: string,
|
||||
collection: string,
|
||||
limit = 50
|
||||
): Promise<BlogPost[]> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
const res = await agent.com.atproto.repo.listRecords({
|
||||
repo: did,
|
||||
collection,
|
||||
limit,
|
||||
})
|
||||
|
||||
return res.data.records.map((record: any) => ({
|
||||
uri: record.uri,
|
||||
cid: record.cid,
|
||||
title: record.value.title || '',
|
||||
content: record.value.content || '',
|
||||
createdAt: record.value.createdAt || '',
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getRecord(
|
||||
did: string,
|
||||
collection: string,
|
||||
rkey: string
|
||||
): Promise<BlogPost | null> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
try {
|
||||
const res = await agent.com.atproto.repo.getRecord({
|
||||
repo: did,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return {
|
||||
uri: res.data.uri,
|
||||
cid: res.data.cid || '',
|
||||
title: (res.data.value as any).title || '',
|
||||
content: (res.data.value as any).content || '',
|
||||
createdAt: (res.data.value as any).createdAt || '',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function describeRepo(did: string): Promise<string[]> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
const res = await agent.com.atproto.repo.describeRepo({ repo: did })
|
||||
return res.data.collections || []
|
||||
}
|
||||
|
||||
export async function listRecordsRaw(
|
||||
did: string,
|
||||
collection: string,
|
||||
limit = 100
|
||||
): Promise<any[]> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
const res = await agent.com.atproto.repo.listRecords({
|
||||
repo: did,
|
||||
collection,
|
||||
limit,
|
||||
})
|
||||
return res.data.records
|
||||
}
|
||||
|
||||
export async function getRecordRaw(
|
||||
did: string,
|
||||
collection: string,
|
||||
rkey: string
|
||||
): Promise<any | null> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
try {
|
||||
const res = await agent.com.atproto.repo.getRecord({
|
||||
repo: did,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Known lexicon prefixes that have schemas
|
||||
const KNOWN_LEXICON_PREFIXES = [
|
||||
'app.bsky.',
|
||||
'chat.bsky.',
|
||||
'com.atproto.',
|
||||
'sh.tangled.',
|
||||
'pub.leaflet.',
|
||||
'blue.linkat.',
|
||||
'fyi.unravel.frontpage.',
|
||||
'com.whtwnd.',
|
||||
'com.shinolabs.pinksea.',
|
||||
]
|
||||
|
||||
export function hasKnownSchema(nsid: string): boolean {
|
||||
return KNOWN_LEXICON_PREFIXES.some(prefix => nsid.startsWith(prefix))
|
||||
}
|
||||
|
||||
export async function fetchLexicon(nsid: string): Promise<any | null> {
|
||||
// Check if it's a known lexicon first
|
||||
if (hasKnownSchema(nsid)) {
|
||||
return { id: nsid, known: true }
|
||||
}
|
||||
|
||||
// Extract authority from NSID (e.g., "ai.syui.log.post" -> "syui.ai")
|
||||
const parts = nsid.split('.')
|
||||
if (parts.length < 3) return null
|
||||
|
||||
const authority = parts.slice(0, 2).reverse().join('.')
|
||||
const url = `https://${authority}/.well-known/lexicon/${nsid}.json`
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
return await res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Known service mappings for collections
|
||||
const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
|
||||
'app.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
|
||||
'chat.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
|
||||
'ai.syui': { domain: 'syui.ai' },
|
||||
'com.whtwnd': { domain: 'whtwnd.com' },
|
||||
'fyi.unravel.frontpage': { domain: 'frontpage.fyi' },
|
||||
'com.shinolabs.pinksea': { domain: 'pinksea.art' },
|
||||
'blue.linkat': { domain: 'linkat.blue' },
|
||||
'sh.tangled': { domain: 'tangled.sh' },
|
||||
'pub.leaflet': { domain: 'leaflet.pub' },
|
||||
}
|
||||
|
||||
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
|
||||
// Try to find matching service prefix
|
||||
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
|
||||
if (collection.startsWith(prefix)) {
|
||||
return {
|
||||
name: info.domain,
|
||||
domain: info.domain,
|
||||
favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract domain from first 2 parts of NSID
|
||||
const parts = collection.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const domain = parts.slice(0, 2).reverse().join('.')
|
||||
return {
|
||||
name: domain,
|
||||
domain: domain,
|
||||
favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
187
src/lib/auth.ts
Normal file
187
src/lib/auth.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
||||
import { Agent } from '@atproto/api'
|
||||
import type { NetworkConfig } from '../types.js'
|
||||
|
||||
let oauthClient: BrowserOAuthClient | null = null
|
||||
let agent: Agent | null = null
|
||||
let currentNetworkConfig: NetworkConfig | null = null
|
||||
|
||||
export interface AuthSession {
|
||||
did: string
|
||||
handle: string
|
||||
agent: Agent
|
||||
}
|
||||
|
||||
export function setAuthNetworkConfig(config: NetworkConfig): void {
|
||||
currentNetworkConfig = config
|
||||
// Reset client when network changes
|
||||
oauthClient = null
|
||||
}
|
||||
|
||||
export async function initOAuthClient(): Promise<BrowserOAuthClient> {
|
||||
if (oauthClient) return oauthClient
|
||||
|
||||
const handleResolver = currentNetworkConfig?.bsky || 'https://bsky.social'
|
||||
const plcDirectoryUrl = currentNetworkConfig?.plc || 'https://plc.directory'
|
||||
|
||||
oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: getClientId(),
|
||||
handleResolver,
|
||||
plcDirectoryUrl,
|
||||
})
|
||||
|
||||
return oauthClient
|
||||
}
|
||||
|
||||
function getClientId(): string {
|
||||
const host = window.location.host
|
||||
// For localhost development
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
// client_id must start with http://localhost, redirect_uri must use 127.0.0.1
|
||||
const port = window.location.port || '3000'
|
||||
const redirectUri = `http://127.0.0.1:${port}/`
|
||||
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
|
||||
}
|
||||
// For production, use the client-metadata.json
|
||||
return `${window.location.origin}/client-metadata.json`
|
||||
}
|
||||
|
||||
export async function login(handle: string): Promise<void> {
|
||||
const client = await initOAuthClient()
|
||||
await client.signIn(handle, {
|
||||
scope: 'atproto transition:generic',
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleOAuthCallback(): Promise<AuthSession | null> {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (!params.has('code') && !params.has('state')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await initOAuthClient()
|
||||
const result = await client.callback(params)
|
||||
|
||||
agent = new Agent(result.session)
|
||||
|
||||
// Get profile to get handle
|
||||
const profile = await agent.getProfile({ actor: result.session.did })
|
||||
|
||||
// Clear URL params
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
|
||||
return {
|
||||
did: result.session.did,
|
||||
handle: profile.data.handle,
|
||||
agent,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OAuth callback error:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreSession(): Promise<AuthSession | null> {
|
||||
try {
|
||||
const client = await initOAuthClient()
|
||||
const result = await client.init()
|
||||
|
||||
if (result?.session) {
|
||||
agent = new Agent(result.session)
|
||||
const profile = await agent.getProfile({ actor: result.session.did })
|
||||
|
||||
return {
|
||||
did: result.session.did,
|
||||
handle: profile.data.handle,
|
||||
agent,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Session restore error:', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// Clear all storage
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
|
||||
// Clear IndexedDB (used by OAuth client)
|
||||
const databases = await indexedDB.databases()
|
||||
for (const db of databases) {
|
||||
if (db.name) {
|
||||
indexedDB.deleteDatabase(db.name)
|
||||
}
|
||||
}
|
||||
|
||||
agent = null
|
||||
oauthClient = null
|
||||
}
|
||||
|
||||
export function getAgent(): Agent | null {
|
||||
return agent
|
||||
}
|
||||
|
||||
export async function createPost(collection: string, title: string, content: string): Promise<{ uri: string; cid: string } | null> {
|
||||
if (!agent) return null
|
||||
|
||||
try {
|
||||
const result = await agent.com.atproto.repo.createRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
record: {
|
||||
$type: collection,
|
||||
title,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
return { uri: result.data.uri, cid: result.data.cid }
|
||||
} catch (err) {
|
||||
console.error('Create post error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRecord(collection: string, rkey: string): Promise<boolean> {
|
||||
if (!agent) return false
|
||||
|
||||
try {
|
||||
await agent.com.atproto.repo.deleteRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Delete record error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function putRecord(
|
||||
collection: string,
|
||||
rkey: string,
|
||||
record: Record<string, unknown>
|
||||
): Promise<{ uri: string; cid: string } | null> {
|
||||
if (!agent) return null
|
||||
|
||||
try {
|
||||
const result = await agent.com.atproto.repo.putRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
record: {
|
||||
$type: collection,
|
||||
...record,
|
||||
},
|
||||
})
|
||||
return { uri: result.data.uri, cid: result.data.cid }
|
||||
} catch (err) {
|
||||
console.error('Put record error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
171
src/main.ts
Normal file
171
src/main.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { getProfile, listRecords, getRecord, setNetworkConfig } from './lib/api.js'
|
||||
import { renderServices } from './components/services.js'
|
||||
import { login, logout, restoreSession, handleOAuthCallback, setAuthNetworkConfig, type AuthSession } from './lib/auth.js'
|
||||
import { mountProfile } from './components/profile.js'
|
||||
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 type { AppConfig, Networks } from './types.js'
|
||||
|
||||
let authSession: AuthSession | null = null
|
||||
|
||||
async function loadConfig(): Promise<AppConfig> {
|
||||
const res = await fetch('/config.json')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function loadNetworks(): Promise<Networks> {
|
||||
const res = await fetch('/networks.json')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function renderFooter(handle: string): string {
|
||||
const parts = handle.split('.')
|
||||
const username = parts[0] || handle
|
||||
return `
|
||||
<footer class="site-footer">
|
||||
<p>© ${username}</p>
|
||||
</footer>
|
||||
`
|
||||
}
|
||||
|
||||
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' : ''
|
||||
|
||||
let tabs = `
|
||||
<a href="?handle=${handle}" class="tab ${blogActive}">Blog</a>
|
||||
<a href="?mode=browser&handle=${handle}" class="tab ${browserActive}">Browser</a>
|
||||
`
|
||||
|
||||
if (isLoggedIn) {
|
||||
tabs += `<a href="?mode=post&handle=${handle}" class="tab ${postActive}">Post</a>`
|
||||
}
|
||||
|
||||
return `<div class="mode-tabs">${tabs}</div>`
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
const [config, networks] = await Promise.all([loadConfig(), loadNetworks()])
|
||||
|
||||
// Set page title
|
||||
document.title = config.title || 'ailog'
|
||||
|
||||
// Set theme color
|
||||
if (config.color) {
|
||||
document.documentElement.style.setProperty('--btn-color', config.color)
|
||||
}
|
||||
|
||||
// Set network config
|
||||
const networkConfig = networks[config.network]
|
||||
if (networkConfig) {
|
||||
setNetworkConfig(networkConfig)
|
||||
setAuthNetworkConfig(networkConfig)
|
||||
}
|
||||
|
||||
// Handle OAuth callback
|
||||
const callbackSession = await handleOAuthCallback()
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
// 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>`
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
779
src/styles/main.css
Normal file
779
src/styles/main.css
Normal file
@@ -0,0 +1,779 @@
|
||||
:root {
|
||||
--btn-color: #0066cc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.profile {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.service-item {
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.service-item:hover {
|
||||
background: #333;
|
||||
}
|
||||
.post-item {
|
||||
border-color: #333;
|
||||
}
|
||||
.post-link:hover {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.browser-input {
|
||||
background: #1a1a1a;
|
||||
border-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
#header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.header-btn.at-btn {
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
border-color: var(--btn-color);
|
||||
}
|
||||
|
||||
.header-btn.at-btn:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
.header-btn.login-btn {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.header-btn.user-btn {
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
border-color: var(--btn-color);
|
||||
}
|
||||
|
||||
/* Post Form */
|
||||
.post-form-container {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.post-form-container h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.post-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.post-form-title {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.post-form-body {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.post-form-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.post-form-collection {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.post-form-btn {
|
||||
padding: 10px 24px;
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-form-btn:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
.post-form-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.post-status {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.post-success {
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.post-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* Profile */
|
||||
.profile {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-handle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-desc {
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* Services */
|
||||
.services {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.service-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.service-item:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.service-favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Post List */
|
||||
.post-list {
|
||||
list-style: none;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.post-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.post-link:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Post Detail */
|
||||
.post-detail {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.post-header .post-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.post-header .post-date {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.json-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.json-btn:hover {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: #28a745;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* Edit Form */
|
||||
.edit-form-container {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.edit-form-container h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit-form-title {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.edit-form-body {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.edit-form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-cancel-btn {
|
||||
padding: 10px 24px;
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-cancel-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.edit-submit-btn {
|
||||
padding: 10px 24px;
|
||||
background: #28a745;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-submit-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.edit-submit-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--btn-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.no-posts,
|
||||
.no-data,
|
||||
.error {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
margin-top: 60px;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Mode Tabs */
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--btn-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* AT Browser */
|
||||
.services-list,
|
||||
.collections,
|
||||
.records,
|
||||
.record-detail {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.services-list h3,
|
||||
.collections h3,
|
||||
.records h3,
|
||||
.record-detail h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.service-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.service-list-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.service-list-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.service-list-link:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.service-list-favicon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.service-list-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.service-list-count {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.collection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collection-header-favicon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.collection-list,
|
||||
.record-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.collection-item,
|
||||
.record-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.collection-link,
|
||||
.record-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.collection-link:hover,
|
||||
.record-link:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.collection-favicon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collection-nsid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collection-service {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.record-link {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.record-rkey {
|
||||
color: var(--btn-color);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.record-preview {
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Record Detail */
|
||||
.record-header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.record-uri,
|
||||
.record-cid {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 4px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.schema-status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.schema-verified {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.schema-none {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.delete-btn:disabled {
|
||||
background: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* JSON View */
|
||||
.json-view {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.json-view pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.json-view code {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Dark mode additions */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header-input {
|
||||
background: #1a1a1a;
|
||||
border-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.header-btn {
|
||||
background: #2a2a2a;
|
||||
border-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: #333;
|
||||
}
|
||||
.header-btn.at-btn,
|
||||
.header-btn.user-btn {
|
||||
background: var(--btn-color);
|
||||
border-color: var(--btn-color);
|
||||
color: #fff;
|
||||
}
|
||||
.post-form-title,
|
||||
.post-form-body {
|
||||
background: #1a1a1a;
|
||||
border-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.json-btn {
|
||||
background: #2a2a2a;
|
||||
color: #888;
|
||||
}
|
||||
.json-btn:hover {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.edit-form-title,
|
||||
.edit-form-body {
|
||||
background: #1a1a1a;
|
||||
border-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.tab:hover {
|
||||
background: #333;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--btn-color);
|
||||
}
|
||||
.service-list-link:hover,
|
||||
.collection-link:hover,
|
||||
.record-link:hover {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.service-list-item,
|
||||
.collection-item,
|
||||
.record-item,
|
||||
.record-header {
|
||||
border-color: #333;
|
||||
}
|
||||
.json-view {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.schema-verified {
|
||||
background: #1e3a29;
|
||||
color: #75b798;
|
||||
}
|
||||
.schema-none {
|
||||
background: #2a2a2a;
|
||||
color: #888;
|
||||
}
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
}
|
||||
31
src/types.ts
Normal file
31
src/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface Profile {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
banner?: string
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
uri: string
|
||||
cid: string
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NetworkConfig {
|
||||
plc: string
|
||||
bsky: string
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
title: string
|
||||
handle: string
|
||||
collection: string
|
||||
network: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type Networks = Record<string, NetworkConfig>
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
root: '.',
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyDirBeforeWrite: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user