this repo has no description

Initial implementation: stdeditor for standard.site

Features:
- ATProto OAuth authentication with Bluesky
- Publication management (create, edit)
- Document management (create, edit, publish, unpublish, delete)
- Draft/published filtering
- Responsive UI with dark mode support

Tech stack:
- Bun runtime
- Hono web framework
- @atproto/oauth-client-node for OAuth
- @atproto/api for PDS interaction
- Server-side rendered HTML templates

Co-authored-by: Shelley <shelley@exe.dev>

exe.dev user 3ed8f824

+1876
+34
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store
+56
CLAUDE.md
··· 1 + # stdeditor 2 + 3 + A minimal web UI for managing standard.site publications and documents in a Bluesky PDS. 4 + 5 + ## Project Structure 6 + 7 + ``` 8 + src/ 9 + server.ts - Main Hono server entry point 10 + lib/ 11 + oauth.ts - ATProto OAuth client setup 12 + session.ts - Session management 13 + routes/ 14 + auth.ts - Authentication routes (login, callback, logout) 15 + publication.ts - Publication CRUD routes 16 + documents.ts - Document CRUD routes 17 + views/ 18 + home.ts - Home page view 19 + layouts/ 20 + main.ts - Main layout template 21 + public/ 22 + styles.css - Application styles 23 + ``` 24 + 25 + ## Running 26 + 27 + ```bash 28 + # Development with hot reload 29 + bun run dev 30 + 31 + # Production 32 + bun run start 33 + ``` 34 + 35 + ## Environment Variables 36 + 37 + - `PORT` - Server port (default: 8000) 38 + - `PUBLIC_URL` - Public URL for OAuth callbacks (e.g., https://stdeditor.exe.xyz:8000) 39 + 40 + ## ATProto Collections 41 + 42 + - `site.standard.publication` - Blog/publication metadata 43 + - `site.standard.document` - Individual documents/posts 44 + 45 + ## Key Dependencies 46 + 47 + - `hono` - Web framework 48 + - `@atproto/oauth-client-node` - ATProto OAuth 49 + - `@atproto/api` - ATProto API client 50 + - `@atproto/jwk-jose` - DPOP key generation 51 + 52 + ## Notes 53 + 54 + - Sessions are stored in-memory (not persisted across restarts) 55 + - OAuth uses loopback client for development 56 + - Documents use `draft` tag for unpublished state
+15
README.md
··· 1 + # stdeditor 2 + 3 + To install dependencies: 4 + 5 + ```bash 6 + bun install 7 + ``` 8 + 9 + To run: 10 + 11 + ```bash 12 + bun run index.ts 13 + ``` 14 + 15 + This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+106
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "stdeditor", 7 + "dependencies": { 8 + "@atproto/api": "^0.18.13", 9 + "@atproto/jwk-jose": "^0.1.11", 10 + "@atproto/oauth-client-node": "^0.3.15", 11 + "hono": "^4.11.3", 12 + }, 13 + "devDependencies": { 14 + "@types/bun": "latest", 15 + }, 16 + "peerDependencies": { 17 + "typescript": "^5", 18 + }, 19 + }, 20 + }, 21 + "packages": { 22 + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.5", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ=="], 23 + 24 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 25 + 26 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 27 + 28 + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-r3b+plCh/0arN535Aool9gL6yTSbAPDOyReURbA2TWAaeW4vrSJPwR6yYUx0k0vmVPjkZPIdUVd63bG/+VG5MA=="], 29 + 30 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.24", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.5", "@atproto/did": "0.2.4" } }, "sha512-w/zvktigmRQpOLQQclp48tbb2K/2XW8j1szoIpT8T8v6P5dZ8GGVDIEF142xQMX9vWToFqMTu1P2yOuz8e3Ilg=="], 31 + 32 + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver": "0.3.5" } }, "sha512-kSxnreUSPhKL77doUbSl/9I6Y9qpkpD7MMJoYFQVU/WG0PB90tzfIb6DNuWsjbU2I5Q91Nzc4Tm4VJMV+OPKGQ=="], 33 + 34 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 35 + 36 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 37 + 38 + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 39 + 40 + "@atproto/api": ["@atproto/api@0.18.13", "", { "dependencies": { "@atproto/common-web": "^0.4.11", "@atproto/lexicon": "^0.6.0", "@atproto/syntax": "^0.4.2", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q=="], 41 + 42 + "@atproto/common-web": ["@atproto/common-web@0.4.11", "", { "dependencies": { "@atproto/lex-data": "0.0.7", "@atproto/lex-json": "0.0.7", "zod": "^3.23.8" } }, "sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ=="], 43 + 44 + "@atproto/did": ["@atproto/did@0.2.4", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g=="], 45 + 46 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 47 + 48 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 49 + 50 + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 51 + 52 + "@atproto/lex-data": ["@atproto/lex-data@0.0.7", "", { "dependencies": { "@atproto/syntax": "0.4.2", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw=="], 53 + 54 + "@atproto/lex-json": ["@atproto/lex-json@0.0.7", "", { "dependencies": { "@atproto/lex-data": "0.0.7", "tslib": "^2.8.1" } }, "sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g=="], 55 + 56 + "@atproto/lexicon": ["@atproto/lexicon@0.6.0", "", { "dependencies": { "@atproto/common-web": "^0.4.7", "@atproto/syntax": "^0.4.2", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ=="], 57 + 58 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.13", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.5", "@atproto-labs/identity-resolver": "0.3.5", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.1", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-FLbqHkC7BAVZ90LHVzSxQf+s8ZNIQI4TsDuhYDyzi7lYtktFHDbgd88KuM2ClJFOtGCsSS17yR1Joy925tDSaA=="], 59 + 60 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.15", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver-node": "0.1.24", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.13", "@atproto/oauth-types": "0.6.1" } }, "sha512-iuT7QrLli7IyB4px1+lHvm/YoIRfNRpbNG9seJRtu5eX4N5aLsBP6vpXs9rCygd1+/15LcLRAAGKVEcrLT9tXA=="], 61 + 62 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.1", "", { "dependencies": { "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-3z92GN/6zCq9E2GTTfZM27tWEbvi1qwFSA7KoS5+wqBC4kSsLvnLxmbKH402Z40DfWS4YWqw0DkHsgP0LNFDEA=="], 63 + 64 + "@atproto/syntax": ["@atproto/syntax@0.4.2", "", {}, "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA=="], 65 + 66 + "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], 67 + 68 + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], 69 + 70 + "@types/node": ["@types/node@25.0.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q=="], 71 + 72 + "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 73 + 74 + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], 75 + 76 + "core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="], 77 + 78 + "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="], 79 + 80 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 81 + 82 + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 83 + 84 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 85 + 86 + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 87 + 88 + "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 89 + 90 + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 91 + 92 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 93 + 94 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 95 + 96 + "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 97 + 98 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 99 + 100 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 101 + 102 + "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 103 + 104 + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 105 + } 106 + }
+22
package.json
··· 1 + { 2 + "name": "stdeditor", 3 + "module": "src/server.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "bun --hot run src/server.ts", 8 + "start": "bun run src/server.ts" 9 + }, 10 + "devDependencies": { 11 + "@types/bun": "latest" 12 + }, 13 + "peerDependencies": { 14 + "typescript": "^5" 15 + }, 16 + "dependencies": { 17 + "@atproto/api": "^0.18.13", 18 + "@atproto/jwk-jose": "^0.1.11", 19 + "@atproto/oauth-client-node": "^0.3.15", 20 + "hono": "^4.11.3" 21 + } 22 + }
+452
public/styles.css
··· 1 + :root { 2 + --bg: #fafafa; 3 + --bg-secondary: #f0f0f0; 4 + --text: #1a1a1a; 5 + --text-muted: #666; 6 + --border: #ddd; 7 + --primary: #0066cc; 8 + --primary-hover: #0052a3; 9 + --success: #22c55e; 10 + --danger: #ef4444; 11 + --draft: #f59e0b; 12 + } 13 + 14 + @media (prefers-color-scheme: dark) { 15 + :root { 16 + --bg: #1a1a1a; 17 + --bg-secondary: #2a2a2a; 18 + --text: #f0f0f0; 19 + --text-muted: #999; 20 + --border: #333; 21 + --primary: #3b82f6; 22 + --primary-hover: #2563eb; 23 + } 24 + } 25 + 26 + * { 27 + box-sizing: border-box; 28 + margin: 0; 29 + padding: 0; 30 + } 31 + 32 + body { 33 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 34 + background: var(--bg); 35 + color: var(--text); 36 + line-height: 1.6; 37 + min-height: 100vh; 38 + display: flex; 39 + flex-direction: column; 40 + } 41 + 42 + a { 43 + color: var(--primary); 44 + text-decoration: none; 45 + } 46 + 47 + a:hover { 48 + text-decoration: underline; 49 + } 50 + 51 + /* Header */ 52 + .header { 53 + background: var(--bg-secondary); 54 + border-bottom: 1px solid var(--border); 55 + padding: 1rem; 56 + } 57 + 58 + .nav { 59 + max-width: 1200px; 60 + margin: 0 auto; 61 + display: flex; 62 + justify-content: space-between; 63 + align-items: center; 64 + } 65 + 66 + .logo { 67 + font-size: 1.25rem; 68 + font-weight: 600; 69 + color: var(--text); 70 + } 71 + 72 + .logo:hover { 73 + text-decoration: none; 74 + } 75 + 76 + .nav-links { 77 + display: flex; 78 + gap: 1.5rem; 79 + align-items: center; 80 + } 81 + 82 + .nav-links a { 83 + color: var(--text-muted); 84 + } 85 + 86 + .nav-links a:hover { 87 + color: var(--text); 88 + } 89 + 90 + .handle { 91 + color: var(--text-muted); 92 + font-size: 0.9rem; 93 + } 94 + 95 + /* Main content */ 96 + .main { 97 + flex: 1; 98 + max-width: 1200px; 99 + margin: 0 auto; 100 + padding: 2rem 1rem; 101 + width: 100%; 102 + } 103 + 104 + /* Footer */ 105 + .footer { 106 + background: var(--bg-secondary); 107 + border-top: 1px solid var(--border); 108 + padding: 1rem; 109 + text-align: center; 110 + color: var(--text-muted); 111 + font-size: 0.9rem; 112 + } 113 + 114 + /* Buttons */ 115 + .btn { 116 + display: inline-block; 117 + padding: 0.5rem 1rem; 118 + border: none; 119 + border-radius: 4px; 120 + font-size: 1rem; 121 + cursor: pointer; 122 + text-decoration: none; 123 + } 124 + 125 + .btn:hover { 126 + text-decoration: none; 127 + } 128 + 129 + .btn-primary { 130 + background: var(--primary); 131 + color: white; 132 + } 133 + 134 + .btn-primary:hover { 135 + background: var(--primary-hover); 136 + } 137 + 138 + .btn-secondary { 139 + background: var(--bg-secondary); 140 + color: var(--text); 141 + border: 1px solid var(--border); 142 + } 143 + 144 + .btn-secondary:hover { 145 + background: var(--border); 146 + } 147 + 148 + .btn-success { 149 + background: var(--success); 150 + color: white; 151 + } 152 + 153 + .btn-danger { 154 + background: var(--danger); 155 + color: white; 156 + } 157 + 158 + .btn-large { 159 + padding: 0.75rem 2rem; 160 + font-size: 1.125rem; 161 + } 162 + 163 + /* Forms */ 164 + .form-group { 165 + margin-bottom: 1.5rem; 166 + } 167 + 168 + .form-group label { 169 + display: block; 170 + margin-bottom: 0.5rem; 171 + font-weight: 500; 172 + } 173 + 174 + .form-group input, 175 + .form-group textarea { 176 + width: 100%; 177 + padding: 0.75rem; 178 + border: 1px solid var(--border); 179 + border-radius: 4px; 180 + font-size: 1rem; 181 + background: var(--bg); 182 + color: var(--text); 183 + } 184 + 185 + .form-group input:focus, 186 + .form-group textarea:focus { 187 + outline: none; 188 + border-color: var(--primary); 189 + } 190 + 191 + .form-group small { 192 + display: block; 193 + margin-top: 0.25rem; 194 + color: var(--text-muted); 195 + font-size: 0.875rem; 196 + } 197 + 198 + .form-actions { 199 + display: flex; 200 + gap: 1rem; 201 + margin-top: 2rem; 202 + } 203 + 204 + .form-page { 205 + max-width: 800px; 206 + } 207 + 208 + .content-editor { 209 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace; 210 + min-height: 400px; 211 + resize: vertical; 212 + } 213 + 214 + /* Auth form */ 215 + .auth-form { 216 + max-width: 400px; 217 + margin: 2rem auto; 218 + } 219 + 220 + .auth-form h1 { 221 + margin-bottom: 1.5rem; 222 + } 223 + 224 + /* Home page */ 225 + .hero { 226 + text-align: center; 227 + padding: 4rem 1rem; 228 + } 229 + 230 + .hero h1 { 231 + font-size: 3rem; 232 + margin-bottom: 1rem; 233 + } 234 + 235 + .hero p { 236 + font-size: 1.25rem; 237 + color: var(--text-muted); 238 + max-width: 600px; 239 + margin: 0 auto 3rem; 240 + } 241 + 242 + .features { 243 + display: grid; 244 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 245 + gap: 2rem; 246 + margin-bottom: 3rem; 247 + text-align: left; 248 + } 249 + 250 + .feature { 251 + background: var(--bg-secondary); 252 + padding: 1.5rem; 253 + border-radius: 8px; 254 + border: 1px solid var(--border); 255 + } 256 + 257 + .feature h3 { 258 + margin-bottom: 0.5rem; 259 + } 260 + 261 + .feature p { 262 + font-size: 1rem; 263 + margin: 0; 264 + } 265 + 266 + /* Dashboard */ 267 + .dashboard { 268 + max-width: 800px; 269 + } 270 + 271 + .dashboard h1 { 272 + margin-bottom: 0.5rem; 273 + } 274 + 275 + .dashboard p { 276 + color: var(--text-muted); 277 + margin-bottom: 2rem; 278 + } 279 + 280 + .quick-actions { 281 + display: flex; 282 + gap: 1rem; 283 + flex-wrap: wrap; 284 + } 285 + 286 + /* Publication */ 287 + .publication { 288 + max-width: 800px; 289 + } 290 + 291 + .pub-details { 292 + background: var(--bg-secondary); 293 + padding: 1.5rem; 294 + border-radius: 8px; 295 + margin-bottom: 1.5rem; 296 + } 297 + 298 + .pub-details h2 { 299 + margin-bottom: 0.5rem; 300 + } 301 + 302 + .pub-details .url { 303 + color: var(--text-muted); 304 + margin-bottom: 0.5rem; 305 + } 306 + 307 + .pub-details .description { 308 + margin: 0; 309 + } 310 + 311 + /* Documents */ 312 + .documents { 313 + max-width: 800px; 314 + } 315 + 316 + .documents-header { 317 + display: flex; 318 + justify-content: space-between; 319 + align-items: center; 320 + margin-bottom: 1.5rem; 321 + } 322 + 323 + .filters { 324 + display: flex; 325 + gap: 1rem; 326 + margin-bottom: 1.5rem; 327 + border-bottom: 1px solid var(--border); 328 + padding-bottom: 1rem; 329 + } 330 + 331 + .filter { 332 + color: var(--text-muted); 333 + padding: 0.25rem 0; 334 + } 335 + 336 + .filter.active { 337 + color: var(--primary); 338 + border-bottom: 2px solid var(--primary); 339 + } 340 + 341 + .document-list { 342 + list-style: none; 343 + } 344 + 345 + .document-item { 346 + border: 1px solid var(--border); 347 + border-radius: 4px; 348 + margin-bottom: 0.5rem; 349 + } 350 + 351 + .document-item a { 352 + display: flex; 353 + justify-content: space-between; 354 + align-items: center; 355 + padding: 1rem; 356 + color: var(--text); 357 + } 358 + 359 + .document-item a:hover { 360 + background: var(--bg-secondary); 361 + text-decoration: none; 362 + } 363 + 364 + .document-item .title { 365 + font-weight: 500; 366 + } 367 + 368 + .document-item .meta { 369 + display: flex; 370 + gap: 1rem; 371 + align-items: center; 372 + } 373 + 374 + .document-item .date { 375 + color: var(--text-muted); 376 + font-size: 0.9rem; 377 + } 378 + 379 + /* Badges */ 380 + .badge { 381 + display: inline-block; 382 + padding: 0.25rem 0.5rem; 383 + border-radius: 4px; 384 + font-size: 0.75rem; 385 + font-weight: 600; 386 + text-transform: uppercase; 387 + } 388 + 389 + .badge-draft { 390 + background: var(--draft); 391 + color: white; 392 + } 393 + 394 + .badge-published { 395 + background: var(--success); 396 + color: white; 397 + } 398 + 399 + /* Document view */ 400 + .document-view { 401 + max-width: 800px; 402 + } 403 + 404 + .document-header { 405 + margin-bottom: 1.5rem; 406 + } 407 + 408 + .document-header h1 { 409 + margin-bottom: 0.5rem; 410 + } 411 + 412 + .document-meta { 413 + display: flex; 414 + gap: 1rem; 415 + align-items: center; 416 + flex-wrap: wrap; 417 + color: var(--text-muted); 418 + font-size: 0.9rem; 419 + } 420 + 421 + .document-content { 422 + background: var(--bg-secondary); 423 + padding: 1.5rem; 424 + border-radius: 8px; 425 + margin-bottom: 1.5rem; 426 + } 427 + 428 + .document-content pre { 429 + white-space: pre-wrap; 430 + word-break: break-word; 431 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace; 432 + font-size: 0.9rem; 433 + line-height: 1.7; 434 + } 435 + 436 + .actions { 437 + display: flex; 438 + gap: 1rem; 439 + flex-wrap: wrap; 440 + } 441 + 442 + .empty { 443 + color: var(--text-muted); 444 + text-align: center; 445 + padding: 3rem; 446 + } 447 + 448 + .error { 449 + color: var(--danger); 450 + text-align: center; 451 + padding: 2rem; 452 + }
+100
src/lib/oauth.ts
··· 1 + import { NodeOAuthClient, NodeSavedSessionStore, NodeSavedStateStore } from '@atproto/oauth-client-node'; 2 + import { Agent } from '@atproto/api'; 3 + import { JoseKey } from '@atproto/jwk-jose'; 4 + 5 + // In-memory stores - in production you'd want to persist these 6 + const sessionStore = new Map<string, any>(); 7 + const stateStore = new Map<string, any>(); 8 + 9 + const savedSessionStore: NodeSavedSessionStore = { 10 + async get(key: string) { 11 + return sessionStore.get(key); 12 + }, 13 + async set(key: string, value: any) { 14 + sessionStore.set(key, value); 15 + }, 16 + async del(key: string) { 17 + sessionStore.delete(key); 18 + }, 19 + }; 20 + 21 + const savedStateStore: NodeSavedStateStore = { 22 + async get(key: string) { 23 + return stateStore.get(key); 24 + }, 25 + async set(key: string, value: any) { 26 + stateStore.set(key, value); 27 + }, 28 + async del(key: string) { 29 + stateStore.delete(key); 30 + }, 31 + }; 32 + 33 + const clientId = process.env.PUBLIC_URL 34 + ? `${process.env.PUBLIC_URL}/client-metadata.json` 35 + : 'http://localhost'; 36 + 37 + const baseUrl = process.env.PUBLIC_URL || 'http://localhost:8000'; 38 + 39 + let oauthClientInstance: NodeOAuthClient | null = null; 40 + let initPromise: Promise<NodeOAuthClient> | null = null; 41 + 42 + async function initOAuthClient(): Promise<NodeOAuthClient> { 43 + if (oauthClientInstance) return oauthClientInstance; 44 + if (initPromise) return initPromise; 45 + 46 + initPromise = (async () => { 47 + // Generate a keypair for DPOP 48 + const keyset = await Promise.all([ 49 + JoseKey.generate(['ES256']) 50 + ]); 51 + 52 + oauthClientInstance = new NodeOAuthClient({ 53 + clientMetadata: { 54 + client_id: clientId.startsWith('http://localhost') ? clientId : `${baseUrl}/client-metadata.json`, 55 + client_name: 'stdeditor', 56 + client_uri: baseUrl, 57 + redirect_uris: [`${baseUrl}/auth/callback`], 58 + scope: 'atproto transition:generic', 59 + grant_types: ['authorization_code', 'refresh_token'], 60 + response_types: ['code'], 61 + application_type: 'web', 62 + token_endpoint_auth_method: 'none', 63 + dpop_bound_access_tokens: true, 64 + }, 65 + stateStore: savedStateStore, 66 + sessionStore: savedSessionStore, 67 + keyset, 68 + }); 69 + 70 + return oauthClientInstance; 71 + })(); 72 + 73 + return initPromise; 74 + } 75 + 76 + export async function getOAuthClient(): Promise<NodeOAuthClient> { 77 + return initOAuthClient(); 78 + } 79 + 80 + export { oauthClientInstance as oauthClient }; 81 + 82 + export async function getAgentForSession(sessionId: string): Promise<{ agent: Agent; did: string; handle: string }> { 83 + const client = await getOAuthClient(); 84 + const oauthSession = await client.restore(sessionId); 85 + 86 + if (!oauthSession) { 87 + throw new Error('Session not found'); 88 + } 89 + 90 + const agent = new Agent(oauthSession); 91 + 92 + // Fetch profile to get handle 93 + const profile = await agent.getProfile({ actor: oauthSession.did }); 94 + 95 + return { 96 + agent, 97 + did: oauthSession.did, 98 + handle: profile.data.handle, 99 + }; 100 + }
+34
src/lib/session.ts
··· 1 + import type { Context } from 'hono'; 2 + import { getCookie } from 'hono/cookie'; 3 + import { oauthClient, getAgentForSession } from './oauth'; 4 + import type { Agent } from '@atproto/api'; 5 + 6 + export interface Session { 7 + did: string | null; 8 + handle: string | null; 9 + agent: Agent | null; 10 + } 11 + 12 + export async function getSession(c: Context): Promise<Session> { 13 + const sessionId = getCookie(c, 'session'); 14 + 15 + if (!sessionId) { 16 + return { did: null, handle: null, agent: null }; 17 + } 18 + 19 + try { 20 + const { agent, did, handle } = await getAgentForSession(sessionId); 21 + return { did, handle, agent }; 22 + } catch (error) { 23 + console.error('Session error:', error); 24 + return { did: null, handle: null, agent: null }; 25 + } 26 + } 27 + 28 + export function requireAuth(c: Context): Session { 29 + const session = c.get('session') as Session; 30 + if (!session.did || !session.agent) { 31 + throw new Error('Not authenticated'); 32 + } 33 + return session; 34 + }
+102
src/routes/auth.ts
··· 1 + import { Hono } from 'hono'; 2 + import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; 3 + import { html } from 'hono/html'; 4 + import { getOAuthClient } from '../lib/oauth'; 5 + import { layout } from '../views/layouts/main'; 6 + 7 + export const authRoutes = new Hono(); 8 + 9 + // Login page 10 + authRoutes.get('/login', async (c) => { 11 + const content = html` 12 + <div class="auth-form"> 13 + <h1>Login with Bluesky</h1> 14 + <form action="/auth/login" method="POST"> 15 + <div class="form-group"> 16 + <label for="handle">Handle or DID</label> 17 + <input 18 + type="text" 19 + id="handle" 20 + name="handle" 21 + placeholder="yourname.bsky.social" 22 + required 23 + /> 24 + </div> 25 + <button type="submit" class="btn btn-primary">Login</button> 26 + </form> 27 + </div> 28 + `; 29 + 30 + return c.html(layout(content, { title: 'Login - stdeditor' })); 31 + }); 32 + 33 + // Handle login form submission 34 + authRoutes.post('/login', async (c) => { 35 + const body = await c.req.parseBody(); 36 + const handle = body.handle as string; 37 + 38 + if (!handle) { 39 + return c.redirect('/auth/login?error=handle_required'); 40 + } 41 + 42 + try { 43 + const client = await getOAuthClient(); 44 + const url = await client.authorize(handle, { 45 + scope: 'atproto transition:generic', 46 + }); 47 + 48 + return c.redirect(url.toString()); 49 + } catch (error) { 50 + console.error('Login error:', error); 51 + return c.redirect('/auth/login?error=authorization_failed'); 52 + } 53 + }); 54 + 55 + // OAuth callback 56 + authRoutes.get('/callback', async (c) => { 57 + const params = new URL(c.req.url).searchParams; 58 + 59 + try { 60 + const client = await getOAuthClient(); 61 + const { session } = await client.callback(params); 62 + 63 + // Store session ID in cookie 64 + setCookie(c, 'session', session.did, { 65 + httpOnly: true, 66 + secure: process.env.NODE_ENV === 'production', 67 + sameSite: 'Lax', 68 + maxAge: 60 * 60 * 24 * 7, // 7 days 69 + path: '/', 70 + }); 71 + 72 + return c.redirect('/'); 73 + } catch (error) { 74 + console.error('Callback error:', error); 75 + return c.redirect('/auth/login?error=callback_failed'); 76 + } 77 + }); 78 + 79 + // Logout 80 + authRoutes.get('/logout', async (c) => { 81 + deleteCookie(c, 'session', { path: '/' }); 82 + return c.redirect('/'); 83 + }); 84 + 85 + // Client metadata endpoint for OAuth 86 + authRoutes.get('/client-metadata.json', async (c) => { 87 + const baseUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 8000}`; 88 + 89 + return c.json({ 90 + client_id: `${baseUrl}/auth/client-metadata.json`, 91 + client_name: 'stdeditor', 92 + client_uri: baseUrl, 93 + logo_uri: `${baseUrl}/public/logo.png`, 94 + redirect_uris: [`${baseUrl}/auth/callback`], 95 + scope: 'atproto transition:generic', 96 + grant_types: ['authorization_code', 'refresh_token'], 97 + response_types: ['code'], 98 + application_type: 'web', 99 + token_endpoint_auth_method: 'none', 100 + dpop_bound_access_tokens: true, 101 + }); 102 + });
+548
src/routes/documents.ts
··· 1 + import { Hono } from 'hono'; 2 + import { html, raw } from 'hono/html'; 3 + import { layout } from '../views/layouts/main'; 4 + import { requireAuth, type Session } from '../lib/session'; 5 + 6 + export const documentRoutes = new Hono(); 7 + 8 + const DOCUMENT_COLLECTION = 'site.standard.document'; 9 + const PUBLICATION_COLLECTION = 'site.standard.publication'; 10 + 11 + // List all documents 12 + documentRoutes.get('/', async (c) => { 13 + let session: Session; 14 + try { 15 + session = requireAuth(c); 16 + } catch { 17 + return c.redirect('/auth/login'); 18 + } 19 + 20 + const filter = c.req.query('filter') || 'all'; 21 + 22 + try { 23 + const response = await session.agent!.com.atproto.repo.listRecords({ 24 + repo: session.did!, 25 + collection: DOCUMENT_COLLECTION, 26 + limit: 100, 27 + }); 28 + 29 + let documents = response.data.records; 30 + 31 + // Filter by draft/published status 32 + if (filter === 'drafts') { 33 + documents = documents.filter((doc: any) => { 34 + const tags = doc.value.tags || []; 35 + return tags.includes('draft'); 36 + }); 37 + } else if (filter === 'published') { 38 + documents = documents.filter((doc: any) => { 39 + const tags = doc.value.tags || []; 40 + return !tags.includes('draft'); 41 + }); 42 + } 43 + 44 + // Sort by publishedAt or updatedAt 45 + documents.sort((a: any, b: any) => { 46 + const dateA = new Date(a.value.updatedAt || a.value.publishedAt).getTime(); 47 + const dateB = new Date(b.value.updatedAt || b.value.publishedAt).getTime(); 48 + return dateB - dateA; 49 + }); 50 + 51 + const content = html` 52 + <div class="documents"> 53 + <div class="documents-header"> 54 + <h1>Documents</h1> 55 + <a href="/documents/new" class="btn btn-primary">New Document</a> 56 + </div> 57 + 58 + <div class="filters"> 59 + <a href="/documents" class="filter ${filter === 'all' ? 'active' : ''}">All</a> 60 + <a href="/documents?filter=drafts" class="filter ${filter === 'drafts' ? 'active' : ''}">Drafts</a> 61 + <a href="/documents?filter=published" class="filter ${filter === 'published' ? 'active' : ''}">Published</a> 62 + </div> 63 + 64 + ${documents.length === 0 ? html` 65 + <p class="empty">No documents yet. <a href="/documents/new">Create your first document</a>.</p> 66 + ` : html` 67 + <ul class="document-list"> 68 + ${documents.map((doc: any) => { 69 + const rkey = doc.uri.split('/').pop(); 70 + const value = doc.value; 71 + const isDraft = (value.tags || []).includes('draft'); 72 + const date = value.publishedAt ? new Date(value.publishedAt).toLocaleDateString() : ''; 73 + 74 + return html` 75 + <li class="document-item ${isDraft ? 'draft' : 'published'}"> 76 + <a href="/documents/${rkey}"> 77 + <span class="title">${value.title}</span> 78 + <span class="meta"> 79 + ${isDraft ? html`<span class="badge badge-draft">Draft</span>` : ''} 80 + <span class="date">${date}</span> 81 + </span> 82 + </a> 83 + </li> 84 + `; 85 + })} 86 + </ul> 87 + `} 88 + </div> 89 + `; 90 + 91 + return c.html(layout(content, { title: 'Documents - stdeditor', session })); 92 + } catch (error) { 93 + console.error('Error fetching documents:', error); 94 + const content = html`<p class="error">Error loading documents. Please try again.</p>`; 95 + return c.html(layout(content, { title: 'Documents - stdeditor', session })); 96 + } 97 + }); 98 + 99 + // New document form 100 + documentRoutes.get('/new', async (c) => { 101 + let session: Session; 102 + try { 103 + session = requireAuth(c); 104 + } catch { 105 + return c.redirect('/auth/login'); 106 + } 107 + 108 + // Get publication to use as site reference 109 + let publicationUri = ''; 110 + try { 111 + const response = await session.agent!.com.atproto.repo.listRecords({ 112 + repo: session.did!, 113 + collection: PUBLICATION_COLLECTION, 114 + limit: 1, 115 + }); 116 + if (response.data.records[0]) { 117 + publicationUri = response.data.records[0].uri; 118 + } 119 + } catch (e) { 120 + // No publication yet, will need URL 121 + } 122 + 123 + const content = html` 124 + <div class="form-page"> 125 + <h1>New Document</h1> 126 + 127 + <form action="/documents/new" method="POST" class="document-form"> 128 + <input type="hidden" name="publicationUri" value="${publicationUri}" /> 129 + 130 + <div class="form-group"> 131 + <label for="title">Title *</label> 132 + <input type="text" id="title" name="title" required maxlength="128" /> 133 + </div> 134 + 135 + <div class="form-group"> 136 + <label for="path">Path</label> 137 + <input type="text" id="path" name="path" placeholder="/my-post" /> 138 + <small>The URL path for this document (e.g., /my-post)</small> 139 + </div> 140 + 141 + <div class="form-group"> 142 + <label for="description">Description</label> 143 + <textarea id="description" name="description" rows="2" maxlength="300"></textarea> 144 + </div> 145 + 146 + <div class="form-group"> 147 + <label for="content">Content (Markdown)</label> 148 + <textarea id="content" name="content" rows="20" class="content-editor"></textarea> 149 + </div> 150 + 151 + <div class="form-group"> 152 + <label for="tags">Tags (comma-separated)</label> 153 + <input type="text" id="tags" name="tags" placeholder="draft, tutorial" value="draft" /> 154 + </div> 155 + 156 + <div class="form-actions"> 157 + <button type="submit" name="action" value="save" class="btn btn-secondary">Save Draft</button> 158 + <button type="submit" name="action" value="publish" class="btn btn-primary">Publish</button> 159 + </div> 160 + </form> 161 + </div> 162 + 163 + <script> 164 + // Auto-save functionality 165 + const form = document.querySelector('.document-form'); 166 + const contentField = document.getElementById('content'); 167 + let saveTimeout; 168 + 169 + contentField.addEventListener('input', () => { 170 + clearTimeout(saveTimeout); 171 + saveTimeout = setTimeout(() => { 172 + // Could implement auto-save here 173 + console.log('Would auto-save...'); 174 + }, 2000); 175 + }); 176 + </script> 177 + `; 178 + 179 + return c.html(layout(content, { title: 'New Document - stdeditor', session })); 180 + }); 181 + 182 + // Handle document creation 183 + documentRoutes.post('/new', async (c) => { 184 + let session: Session; 185 + try { 186 + session = requireAuth(c); 187 + } catch { 188 + return c.redirect('/auth/login'); 189 + } 190 + 191 + const body = await c.req.parseBody(); 192 + const title = body.title as string; 193 + const path = body.path as string || undefined; 194 + const description = body.description as string || undefined; 195 + const content = body.content as string || undefined; 196 + const tagsStr = body.tags as string || ''; 197 + const action = body.action as string; 198 + const publicationUri = body.publicationUri as string; 199 + 200 + // Parse tags 201 + let tags = tagsStr.split(',').map(t => t.trim()).filter(t => t); 202 + 203 + // If publishing, remove draft tag 204 + if (action === 'publish') { 205 + tags = tags.filter(t => t !== 'draft'); 206 + } else if (!tags.includes('draft')) { 207 + tags.push('draft'); 208 + } 209 + 210 + const now = new Date().toISOString(); 211 + 212 + try { 213 + const rkey = generateTID(); 214 + 215 + // Determine site reference 216 + let site = publicationUri; 217 + if (!site) { 218 + // Fall back to a URL if no publication 219 + site = `https://${session.handle}.bsky.social`; 220 + } 221 + 222 + const record: Record<string, any> = { 223 + $type: DOCUMENT_COLLECTION, 224 + title, 225 + site, 226 + publishedAt: action === 'publish' ? now : now, 227 + updatedAt: now, 228 + }; 229 + 230 + if (path) record.path = path.startsWith('/') ? path : `/${path}`; 231 + if (description) record.description = description; 232 + if (content) record.textContent = content; 233 + if (tags.length > 0) record.tags = tags; 234 + 235 + await session.agent!.com.atproto.repo.createRecord({ 236 + repo: session.did!, 237 + collection: DOCUMENT_COLLECTION, 238 + rkey, 239 + record, 240 + }); 241 + 242 + return c.redirect(`/documents/${rkey}`); 243 + } catch (error) { 244 + console.error('Error creating document:', error); 245 + return c.redirect('/documents/new?error=create_failed'); 246 + } 247 + }); 248 + 249 + // View single document 250 + documentRoutes.get('/:rkey', async (c) => { 251 + let session: Session; 252 + try { 253 + session = requireAuth(c); 254 + } catch { 255 + return c.redirect('/auth/login'); 256 + } 257 + 258 + const rkey = c.req.param('rkey'); 259 + 260 + try { 261 + const response = await session.agent!.com.atproto.repo.getRecord({ 262 + repo: session.did!, 263 + collection: DOCUMENT_COLLECTION, 264 + rkey, 265 + }); 266 + 267 + const doc = response.data.value as any; 268 + const isDraft = (doc.tags || []).includes('draft'); 269 + 270 + const content = html` 271 + <div class="document-view"> 272 + <div class="document-header"> 273 + <h1>${doc.title}</h1> 274 + <div class="document-meta"> 275 + ${isDraft ? html`<span class="badge badge-draft">Draft</span>` : html`<span class="badge badge-published">Published</span>`} 276 + ${doc.publishedAt ? html`<span class="date">Published: ${new Date(doc.publishedAt).toLocaleDateString()}</span>` : ''} 277 + ${doc.path ? html`<span class="path">Path: ${doc.path}</span>` : ''} 278 + </div> 279 + </div> 280 + 281 + ${doc.description ? html`<p class="description">${doc.description}</p>` : ''} 282 + 283 + <div class="document-content"> 284 + <pre>${doc.textContent || '(No content)'}</pre> 285 + </div> 286 + 287 + <div class="actions"> 288 + <a href="/documents/${rkey}/edit" class="btn btn-primary">Edit</a> 289 + ${isDraft ? html` 290 + <form action="/documents/${rkey}/publish" method="POST" style="display:inline"> 291 + <button type="submit" class="btn btn-success">Publish</button> 292 + </form> 293 + ` : html` 294 + <form action="/documents/${rkey}/unpublish" method="POST" style="display:inline"> 295 + <button type="submit" class="btn btn-secondary">Unpublish</button> 296 + </form> 297 + `} 298 + <form action="/documents/${rkey}/delete" method="POST" style="display:inline" onsubmit="return confirm('Are you sure you want to delete this document?')"> 299 + <button type="submit" class="btn btn-danger">Delete</button> 300 + </form> 301 + <a href="/documents" class="btn btn-secondary">Back to List</a> 302 + </div> 303 + </div> 304 + `; 305 + 306 + return c.html(layout(content, { title: `${doc.title} - stdeditor`, session })); 307 + } catch (error) { 308 + console.error('Error fetching document:', error); 309 + return c.redirect('/documents'); 310 + } 311 + }); 312 + 313 + // Edit document form 314 + documentRoutes.get('/:rkey/edit', async (c) => { 315 + let session: Session; 316 + try { 317 + session = requireAuth(c); 318 + } catch { 319 + return c.redirect('/auth/login'); 320 + } 321 + 322 + const rkey = c.req.param('rkey'); 323 + 324 + try { 325 + const response = await session.agent!.com.atproto.repo.getRecord({ 326 + repo: session.did!, 327 + collection: DOCUMENT_COLLECTION, 328 + rkey, 329 + }); 330 + 331 + const doc = response.data.value as any; 332 + 333 + const content = html` 334 + <div class="form-page"> 335 + <h1>Edit Document</h1> 336 + 337 + <form action="/documents/${rkey}/edit" method="POST" class="document-form"> 338 + <div class="form-group"> 339 + <label for="title">Title *</label> 340 + <input type="text" id="title" name="title" value="${doc.title}" required maxlength="128" /> 341 + </div> 342 + 343 + <div class="form-group"> 344 + <label for="path">Path</label> 345 + <input type="text" id="path" name="path" value="${doc.path || ''}" /> 346 + </div> 347 + 348 + <div class="form-group"> 349 + <label for="description">Description</label> 350 + <textarea id="description" name="description" rows="2" maxlength="300">${doc.description || ''}</textarea> 351 + </div> 352 + 353 + <div class="form-group"> 354 + <label for="content">Content (Markdown)</label> 355 + <textarea id="content" name="content" rows="20" class="content-editor">${doc.textContent || ''}</textarea> 356 + </div> 357 + 358 + <div class="form-group"> 359 + <label for="tags">Tags (comma-separated)</label> 360 + <input type="text" id="tags" name="tags" value="${(doc.tags || []).join(', ')}" /> 361 + </div> 362 + 363 + <div class="form-actions"> 364 + <button type="submit" class="btn btn-primary">Save Changes</button> 365 + <a href="/documents/${rkey}" class="btn btn-secondary">Cancel</a> 366 + </div> 367 + </form> 368 + </div> 369 + `; 370 + 371 + return c.html(layout(content, { title: `Edit: ${doc.title} - stdeditor`, session })); 372 + } catch (error) { 373 + console.error('Error fetching document:', error); 374 + return c.redirect('/documents'); 375 + } 376 + }); 377 + 378 + // Handle document update 379 + documentRoutes.post('/:rkey/edit', async (c) => { 380 + let session: Session; 381 + try { 382 + session = requireAuth(c); 383 + } catch { 384 + return c.redirect('/auth/login'); 385 + } 386 + 387 + const rkey = c.req.param('rkey'); 388 + const body = await c.req.parseBody(); 389 + 390 + try { 391 + // Get existing record 392 + const existing = await session.agent!.com.atproto.repo.getRecord({ 393 + repo: session.did!, 394 + collection: DOCUMENT_COLLECTION, 395 + rkey, 396 + }); 397 + 398 + const oldDoc = existing.data.value as any; 399 + 400 + const title = body.title as string; 401 + const path = body.path as string || undefined; 402 + const description = body.description as string || undefined; 403 + const content = body.content as string || undefined; 404 + const tagsStr = body.tags as string || ''; 405 + const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t); 406 + 407 + const record: Record<string, any> = { 408 + $type: DOCUMENT_COLLECTION, 409 + title, 410 + site: oldDoc.site, 411 + publishedAt: oldDoc.publishedAt, 412 + updatedAt: new Date().toISOString(), 413 + }; 414 + 415 + if (path) record.path = path.startsWith('/') ? path : `/${path}`; 416 + if (description) record.description = description; 417 + if (content) record.textContent = content; 418 + if (tags.length > 0) record.tags = tags; 419 + 420 + await session.agent!.com.atproto.repo.putRecord({ 421 + repo: session.did!, 422 + collection: DOCUMENT_COLLECTION, 423 + rkey, 424 + record, 425 + }); 426 + 427 + return c.redirect(`/documents/${rkey}`); 428 + } catch (error) { 429 + console.error('Error updating document:', error); 430 + return c.redirect(`/documents/${rkey}/edit?error=update_failed`); 431 + } 432 + }); 433 + 434 + // Publish document 435 + documentRoutes.post('/:rkey/publish', async (c) => { 436 + let session: Session; 437 + try { 438 + session = requireAuth(c); 439 + } catch { 440 + return c.redirect('/auth/login'); 441 + } 442 + 443 + const rkey = c.req.param('rkey'); 444 + 445 + try { 446 + const existing = await session.agent!.com.atproto.repo.getRecord({ 447 + repo: session.did!, 448 + collection: DOCUMENT_COLLECTION, 449 + rkey, 450 + }); 451 + 452 + const doc = existing.data.value as any; 453 + const tags = (doc.tags || []).filter((t: string) => t !== 'draft'); 454 + 455 + const record = { 456 + ...doc, 457 + tags: tags.length > 0 ? tags : undefined, 458 + publishedAt: doc.publishedAt || new Date().toISOString(), 459 + updatedAt: new Date().toISOString(), 460 + }; 461 + 462 + await session.agent!.com.atproto.repo.putRecord({ 463 + repo: session.did!, 464 + collection: DOCUMENT_COLLECTION, 465 + rkey, 466 + record, 467 + }); 468 + 469 + return c.redirect(`/documents/${rkey}`); 470 + } catch (error) { 471 + console.error('Error publishing document:', error); 472 + return c.redirect(`/documents/${rkey}?error=publish_failed`); 473 + } 474 + }); 475 + 476 + // Unpublish document (add draft tag) 477 + documentRoutes.post('/:rkey/unpublish', async (c) => { 478 + let session: Session; 479 + try { 480 + session = requireAuth(c); 481 + } catch { 482 + return c.redirect('/auth/login'); 483 + } 484 + 485 + const rkey = c.req.param('rkey'); 486 + 487 + try { 488 + const existing = await session.agent!.com.atproto.repo.getRecord({ 489 + repo: session.did!, 490 + collection: DOCUMENT_COLLECTION, 491 + rkey, 492 + }); 493 + 494 + const doc = existing.data.value as any; 495 + const tags = [...(doc.tags || []), 'draft']; 496 + 497 + const record = { 498 + ...doc, 499 + tags, 500 + updatedAt: new Date().toISOString(), 501 + }; 502 + 503 + await session.agent!.com.atproto.repo.putRecord({ 504 + repo: session.did!, 505 + collection: DOCUMENT_COLLECTION, 506 + rkey, 507 + record, 508 + }); 509 + 510 + return c.redirect(`/documents/${rkey}`); 511 + } catch (error) { 512 + console.error('Error unpublishing document:', error); 513 + return c.redirect(`/documents/${rkey}?error=unpublish_failed`); 514 + } 515 + }); 516 + 517 + // Delete document 518 + documentRoutes.post('/:rkey/delete', async (c) => { 519 + let session: Session; 520 + try { 521 + session = requireAuth(c); 522 + } catch { 523 + return c.redirect('/auth/login'); 524 + } 525 + 526 + const rkey = c.req.param('rkey'); 527 + 528 + try { 529 + await session.agent!.com.atproto.repo.deleteRecord({ 530 + repo: session.did!, 531 + collection: DOCUMENT_COLLECTION, 532 + rkey, 533 + }); 534 + 535 + return c.redirect('/documents'); 536 + } catch (error) { 537 + console.error('Error deleting document:', error); 538 + return c.redirect(`/documents/${rkey}?error=delete_failed`); 539 + } 540 + }); 541 + 542 + // Generate a TID (timestamp-based ID) 543 + function generateTID(): string { 544 + const now = Date.now() * 1000; 545 + const clockId = Math.floor(Math.random() * 1024); 546 + const tid = (BigInt(now) << 10n) | BigInt(clockId); 547 + return tid.toString(36).padStart(13, '0'); 548 + }
+232
src/routes/publication.ts
··· 1 + import { Hono } from 'hono'; 2 + import { html } from 'hono/html'; 3 + import { layout } from '../views/layouts/main'; 4 + import { requireAuth, type Session } from '../lib/session'; 5 + 6 + export const publicationRoutes = new Hono(); 7 + 8 + const PUBLICATION_COLLECTION = 'site.standard.publication'; 9 + 10 + // View/manage publication 11 + publicationRoutes.get('/', async (c) => { 12 + let session: Session; 13 + try { 14 + session = requireAuth(c); 15 + } catch { 16 + return c.redirect('/auth/login'); 17 + } 18 + 19 + try { 20 + // Fetch existing publication 21 + const response = await session.agent!.com.atproto.repo.listRecords({ 22 + repo: session.did!, 23 + collection: PUBLICATION_COLLECTION, 24 + limit: 1, 25 + }); 26 + 27 + const publication = response.data.records[0]; 28 + 29 + if (publication) { 30 + const pub = publication.value as any; 31 + const content = html` 32 + <div class="publication"> 33 + <h1>Your Publication</h1> 34 + 35 + <div class="pub-details"> 36 + <h2>${pub.name}</h2> 37 + <p class="url"><a href="${pub.url}" target="_blank">${pub.url}</a></p> 38 + ${pub.description ? html`<p class="description">${pub.description}</p>` : ''} 39 + </div> 40 + 41 + <div class="actions"> 42 + <a href="/publication/edit" class="btn btn-primary">Edit Publication</a> 43 + </div> 44 + </div> 45 + `; 46 + return c.html(layout(content, { title: 'Publication - stdeditor', session })); 47 + } 48 + 49 + // No publication exists, show create form 50 + return c.redirect('/publication/new'); 51 + } catch (error) { 52 + console.error('Error fetching publication:', error); 53 + return c.redirect('/publication/new'); 54 + } 55 + }); 56 + 57 + // New publication form 58 + publicationRoutes.get('/new', async (c) => { 59 + let session: Session; 60 + try { 61 + session = requireAuth(c); 62 + } catch { 63 + return c.redirect('/auth/login'); 64 + } 65 + 66 + const content = html` 67 + <div class="form-page"> 68 + <h1>Create Publication</h1> 69 + 70 + <form action="/publication/new" method="POST"> 71 + <div class="form-group"> 72 + <label for="name">Name *</label> 73 + <input type="text" id="name" name="name" required maxlength="128" /> 74 + </div> 75 + 76 + <div class="form-group"> 77 + <label for="url">URL *</label> 78 + <input type="url" id="url" name="url" placeholder="https://yourblog.com" required /> 79 + <small>The base URL of your publication (without trailing slash)</small> 80 + </div> 81 + 82 + <div class="form-group"> 83 + <label for="description">Description</label> 84 + <textarea id="description" name="description" rows="3" maxlength="300"></textarea> 85 + </div> 86 + 87 + <button type="submit" class="btn btn-primary">Create Publication</button> 88 + </form> 89 + </div> 90 + `; 91 + 92 + return c.html(layout(content, { title: 'New Publication - stdeditor', session })); 93 + }); 94 + 95 + // Handle publication creation 96 + publicationRoutes.post('/new', async (c) => { 97 + let session: Session; 98 + try { 99 + session = requireAuth(c); 100 + } catch { 101 + return c.redirect('/auth/login'); 102 + } 103 + 104 + const body = await c.req.parseBody(); 105 + const name = body.name as string; 106 + const url = (body.url as string).replace(/\/$/, ''); // Remove trailing slash 107 + const description = body.description as string || undefined; 108 + 109 + try { 110 + // Generate a TID for the record key 111 + const rkey = generateTID(); 112 + 113 + await session.agent!.com.atproto.repo.createRecord({ 114 + repo: session.did!, 115 + collection: PUBLICATION_COLLECTION, 116 + rkey, 117 + record: { 118 + $type: PUBLICATION_COLLECTION, 119 + name, 120 + url, 121 + ...(description && { description }), 122 + }, 123 + }); 124 + 125 + return c.redirect('/publication'); 126 + } catch (error) { 127 + console.error('Error creating publication:', error); 128 + return c.redirect('/publication/new?error=create_failed'); 129 + } 130 + }); 131 + 132 + // Edit publication form 133 + publicationRoutes.get('/edit', async (c) => { 134 + let session: Session; 135 + try { 136 + session = requireAuth(c); 137 + } catch { 138 + return c.redirect('/auth/login'); 139 + } 140 + 141 + try { 142 + const response = await session.agent!.com.atproto.repo.listRecords({ 143 + repo: session.did!, 144 + collection: PUBLICATION_COLLECTION, 145 + limit: 1, 146 + }); 147 + 148 + const publication = response.data.records[0]; 149 + if (!publication) { 150 + return c.redirect('/publication/new'); 151 + } 152 + 153 + const pub = publication.value as any; 154 + const rkey = publication.uri.split('/').pop(); 155 + 156 + const content = html` 157 + <div class="form-page"> 158 + <h1>Edit Publication</h1> 159 + 160 + <form action="/publication/edit" method="POST"> 161 + <input type="hidden" name="rkey" value="${rkey}" /> 162 + 163 + <div class="form-group"> 164 + <label for="name">Name *</label> 165 + <input type="text" id="name" name="name" value="${pub.name}" required maxlength="128" /> 166 + </div> 167 + 168 + <div class="form-group"> 169 + <label for="url">URL *</label> 170 + <input type="url" id="url" name="url" value="${pub.url}" required /> 171 + </div> 172 + 173 + <div class="form-group"> 174 + <label for="description">Description</label> 175 + <textarea id="description" name="description" rows="3" maxlength="300">${pub.description || ''}</textarea> 176 + </div> 177 + 178 + <button type="submit" class="btn btn-primary">Save Changes</button> 179 + <a href="/publication" class="btn btn-secondary">Cancel</a> 180 + </form> 181 + </div> 182 + `; 183 + 184 + return c.html(layout(content, { title: 'Edit Publication - stdeditor', session })); 185 + } catch (error) { 186 + console.error('Error fetching publication:', error); 187 + return c.redirect('/publication'); 188 + } 189 + }); 190 + 191 + // Handle publication update 192 + publicationRoutes.post('/edit', async (c) => { 193 + let session: Session; 194 + try { 195 + session = requireAuth(c); 196 + } catch { 197 + return c.redirect('/auth/login'); 198 + } 199 + 200 + const body = await c.req.parseBody(); 201 + const rkey = body.rkey as string; 202 + const name = body.name as string; 203 + const url = (body.url as string).replace(/\/$/, ''); 204 + const description = body.description as string || undefined; 205 + 206 + try { 207 + await session.agent!.com.atproto.repo.putRecord({ 208 + repo: session.did!, 209 + collection: PUBLICATION_COLLECTION, 210 + rkey, 211 + record: { 212 + $type: PUBLICATION_COLLECTION, 213 + name, 214 + url, 215 + ...(description && { description }), 216 + }, 217 + }); 218 + 219 + return c.redirect('/publication'); 220 + } catch (error) { 221 + console.error('Error updating publication:', error); 222 + return c.redirect('/publication/edit?error=update_failed'); 223 + } 224 + }); 225 + 226 + // Generate a TID (timestamp-based ID) 227 + function generateTID(): string { 228 + const now = Date.now() * 1000; // microseconds 229 + const clockId = Math.floor(Math.random() * 1024); 230 + const tid = (BigInt(now) << 10n) | BigInt(clockId); 231 + return tid.toString(36).padStart(13, '0'); 232 + }
+40
src/server.ts
··· 1 + import { Hono } from 'hono'; 2 + import { serveStatic } from 'hono/bun'; 3 + import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; 4 + import { authRoutes } from './routes/auth'; 5 + import { publicationRoutes } from './routes/publication'; 6 + import { documentRoutes } from './routes/documents'; 7 + import { layout } from './views/layouts/main'; 8 + import { homePage } from './views/home'; 9 + import { getSession } from './lib/session'; 10 + 11 + export const app = new Hono(); 12 + 13 + // Static files 14 + app.use('/public/*', serveStatic({ root: './' })); 15 + 16 + // Session middleware - adds session to context 17 + app.use('*', async (c, next) => { 18 + const session = await getSession(c); 19 + c.set('session', session); 20 + await next(); 21 + }); 22 + 23 + // Home page 24 + app.get('/', async (c) => { 25 + const session = c.get('session'); 26 + return c.html(layout(homePage(session), { session })); 27 + }); 28 + 29 + // Mount routes 30 + app.route('/auth', authRoutes); 31 + app.route('/publication', publicationRoutes); 32 + app.route('/documents', documentRoutes); 33 + 34 + const port = parseInt(process.env.PORT || '8000'); 35 + console.log(`Starting server on http://localhost:${port}`); 36 + 37 + export default { 38 + port, 39 + fetch: app.fetch, 40 + };
+43
src/views/home.ts
··· 1 + import { html } from 'hono/html'; 2 + import type { Session } from '../lib/session'; 3 + 4 + export function homePage(session: Session) { 5 + if (session.did) { 6 + return html` 7 + <div class="dashboard"> 8 + <h1>Welcome, @${session.handle}</h1> 9 + <p>Manage your standard.site publication and documents.</p> 10 + 11 + <div class="quick-actions"> 12 + <a href="/publication" class="btn btn-primary">Manage Publication</a> 13 + <a href="/documents" class="btn btn-secondary">View Documents</a> 14 + <a href="/documents/new" class="btn btn-secondary">New Document</a> 15 + </div> 16 + </div> 17 + `; 18 + } 19 + 20 + return html` 21 + <div class="hero"> 22 + <h1>stdeditor</h1> 23 + <p>A minimal web UI for managing standard.site publications and documents in your Bluesky PDS.</p> 24 + 25 + <div class="features"> 26 + <div class="feature"> 27 + <h3>📝 Publish</h3> 28 + <p>Write and publish blog posts as ATProto records</p> 29 + </div> 30 + <div class="feature"> 31 + <h3>🔄 Portable</h3> 32 + <p>Your content lives in your PDS, not on our servers</p> 33 + </div> 34 + <div class="feature"> 35 + <h3>🌐 Standard</h3> 36 + <p>Uses the standard.site lexicon for interoperability</p> 37 + </div> 38 + </div> 39 + 40 + <a href="/auth/login" class="btn btn-primary btn-large">Login with Bluesky</a> 41 + </div> 42 + `; 43 + }
+46
src/views/layouts/main.ts
··· 1 + import { html } from 'hono/html'; 2 + import type { Session } from '../../lib/session'; 3 + 4 + interface LayoutOptions { 5 + title?: string; 6 + session?: Session; 7 + } 8 + 9 + export function layout(content: string, options: LayoutOptions = {}) { 10 + const { title = 'stdeditor', session } = options; 11 + 12 + return html` 13 + <!DOCTYPE html> 14 + <html lang="en"> 15 + <head> 16 + <meta charset="UTF-8"> 17 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 18 + <title>${title}</title> 19 + <link rel="stylesheet" href="/public/styles.css"> 20 + </head> 21 + <body> 22 + <header class="header"> 23 + <nav class="nav"> 24 + <a href="/" class="logo">stdeditor</a> 25 + <div class="nav-links"> 26 + ${session?.did ? html` 27 + <a href="/publication">Publication</a> 28 + <a href="/documents">Documents</a> 29 + <span class="handle">@${session.handle}</span> 30 + <a href="/auth/logout">Logout</a> 31 + ` : html` 32 + <a href="/auth/login">Login with Bluesky</a> 33 + `} 34 + </div> 35 + </nav> 36 + </header> 37 + <main class="main"> 38 + ${content} 39 + </main> 40 + <footer class="footer"> 41 + <p>stdeditor - Manage your <a href="https://standard.site">standard.site</a> content</p> 42 + </footer> 43 + </body> 44 + </html> 45 + `; 46 + }
+17
stdeditor.service
··· 1 + [Unit] 2 + Description=stdeditor - standard.site editor 3 + After=network.target 4 + 5 + [Service] 6 + Type=simple 7 + User=exedev 8 + WorkingDirectory=/home/exedev/stdeditor 9 + Environment=PATH=/home/exedev/.bun/bin:/usr/local/bin:/usr/bin:/bin 10 + Environment=PUBLIC_URL=https://stdeditor.exe.xyz:8000 11 + Environment=PORT=8000 12 + ExecStart=/home/exedev/.bun/bin/bun run src/server.ts 13 + Restart=on-failure 14 + RestartSec=5 15 + 16 + [Install] 17 + WantedBy=multi-user.target
+29
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + // Some stricter flags (disabled by default) 25 + "noUnusedLocals": false, 26 + "noUnusedParameters": false, 27 + "noPropertyAccessFromIndexSignature": false 28 + } 29 + }