diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl
index e40fea54..8eaf157b 100644
--- a/.beads/interactions.jsonl
+++ b/.beads/interactions.jsonl
@@ -9,3 +9,13 @@
{"id":"int-e921c04d","kind":"field_change","created_at":"2026-03-26T03:18:05.041487Z","actor":"plresearch.org","issue_id":"plresearch-c24.3","extra":{"field":"assignee","new_value":"plresearch.org","old_value":""}}
{"id":"int-9a3e4c1b","kind":"field_change","created_at":"2026-03-26T03:20:27.899943Z","actor":"plresearch.org","issue_id":"plresearch-c24.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"d44cb58 Add PageEditor component and wire into admin page"}}
{"id":"int-d4e87574","kind":"field_change","created_at":"2026-03-26T04:16:32.191509Z","actor":"plresearch.org","issue_id":"plresearch-wz0.1","extra":{"field":"assignee","new_value":"plresearch.org","old_value":""}}
+{"id":"int-34feff8e","kind":"field_change","created_at":"2026-03-26T04:16:42.563464Z","actor":"plresearch.org","issue_id":"plresearch-wz0.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"26edf19 feat: add Hyperindex client library"}}
+{"id":"int-2e994c9a","kind":"field_change","created_at":"2026-03-26T04:21:51.18019Z","actor":"plresearch.org","issue_id":"plresearch-wz0.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"567f6ad landing + about wired"}}
+{"id":"int-52d931c4","kind":"field_change","created_at":"2026-03-26T04:21:51.557355Z","actor":"plresearch.org","issue_id":"plresearch-wz0.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"567f6ad areas + EG + collaborate wired"}}
+{"id":"int-4ad7ed64","kind":"field_change","created_at":"2026-03-26T04:21:51.936682Z","actor":"plresearch.org","issue_id":"plresearch-wz0.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"567f6ad [slug] page + API routes wired"}}
+{"id":"int-6087ec73","kind":"field_change","created_at":"2026-03-27T15:17:49.038993Z","actor":"plresearch.org","issue_id":"plresearch-auc.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"beec5c4 InlineEdit infrastructure"}}
+{"id":"int-dc040bc7","kind":"field_change","created_at":"2026-03-27T15:17:49.406993Z","actor":"plresearch.org","issue_id":"plresearch-auc.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"beec5c4 about/edit + EditPageButton routes"}}
+{"id":"int-d5d24573","kind":"field_change","created_at":"2026-03-27T15:17:49.785997Z","actor":"plresearch.org","issue_id":"plresearch-auc.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"beec5c4 /edit + /areas/[slug]/edit"}}
+{"id":"int-4b62ca59","kind":"field_change","created_at":"2026-03-28T02:47:58.79708Z","actor":"plresearch.org","issue_id":"plresearch-zk9.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"be0361b lexicons + create-publication script"}}
+{"id":"int-4572652d","kind":"field_change","created_at":"2026-03-28T02:47:59.171246Z","actor":"plresearch.org","issue_id":"plresearch-zk9.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"be0361b .well-known + api/posts"}}
+{"id":"int-483dae92","kind":"field_change","created_at":"2026-03-28T02:47:59.542856Z","actor":"plresearch.org","issue_id":"plresearch-zk9.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"be0361b fetchAtproPosts + blog page"}}
diff --git a/.beads/last-touched b/.beads/last-touched
index c3b6b867..e578b1e9 100644
--- a/.beads/last-touched
+++ b/.beads/last-touched
@@ -1 +1 @@
-plresearch-wz0.1
+plresearch-zk9.3
diff --git a/.env b/.env
new file mode 100644
index 00000000..a92fc18a
--- /dev/null
+++ b/.env
@@ -0,0 +1,17 @@
+# Session encryption secret (must be at least 32 characters)
+COOKIE_SECRET=your-32-character-secret-here-change-me
+
+# Public URL for production (leave empty for localhost development)
+PUBLIC_URL=
+
+# ES256 Private Key in JWK format for confidential client authentication
+# Required in production. Generate with scripts/generate-jwk.js
+# In development, falls back to public client mode if not set
+ATPROTO_JWK_PRIVATE=
+
+# Admin DID — the ATProto identity that can manage the curated list
+# NEXT_PUBLIC_ prefix makes it available in both server and client code
+NEXT_PUBLIC_ADMIN_DID=did:plc:pgwr6hkosgznfl5nz7egajei
+
+ATPROTO_HANDLE=plresearch.org
+ATPROTO_PASSWORD=wqy0FVZ8nfq@gyz9gzj
diff --git a/.env.example b/.env.example
index 8c4bf989..0f3f8281 100644
--- a/.env.example
+++ b/.env.example
@@ -12,3 +12,7 @@ ATPROTO_JWK_PRIVATE=
# Admin DID — the ATProto identity that can manage the curated list
# NEXT_PUBLIC_ prefix makes it available in both server and client code
NEXT_PUBLIC_ADMIN_DID=did:plc:pgwr6hkosgznfl5nz7egajei
+
+# GraphQL indexer URL for org.plresearch.* records
+# Default: https://api.hi.gainforest.app/graphql
+# INDEXER_URL=http://localhost:4000/graphql
diff --git a/package-lock.json b/package-lock.json
index f641449c..48638cd3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
"envalid": "^8.1.1",
"fuse.js": "^7.0.0",
"iron-session": "^8.0.4",
- "next": "15.5.9",
+ "next": "^15.5.15",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-force-graph-2d": "^1.29.1"
@@ -791,17 +791,19 @@
}
},
"node_modules/@next/env": {
- "version": "15.5.9",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
- "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg=="
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz",
+ "integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==",
+ "license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
- "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz",
+ "integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==",
"cpu": [
"arm64"
],
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -811,12 +813,13 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
- "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz",
+ "integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==",
"cpu": [
"x64"
],
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -826,12 +829,16 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
- "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz",
+ "integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==",
"cpu": [
"arm64"
],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -841,12 +848,16 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
- "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz",
+ "integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==",
"cpu": [
"arm64"
],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -856,12 +867,16 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
- "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz",
+ "integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==",
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -871,12 +886,16 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
- "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz",
+ "integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==",
"cpu": [
"x64"
],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -886,12 +905,13 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
- "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz",
+ "integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==",
"cpu": [
"arm64"
],
+ "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -901,12 +921,13 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
- "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz",
+ "integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==",
"cpu": [
"x64"
],
+ "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -2304,9 +2325,9 @@
}
},
"node_modules/lodash-es": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
- "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/longest-streak": {
@@ -2909,11 +2930,12 @@
}
},
"node_modules/next": {
- "version": "15.5.9",
- "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
- "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
+ "version": "15.5.15",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
+ "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
+ "license": "MIT",
"dependencies": {
- "@next/env": "15.5.9",
+ "@next/env": "15.5.15",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -2926,14 +2948,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "15.5.7",
- "@next/swc-darwin-x64": "15.5.7",
- "@next/swc-linux-arm64-gnu": "15.5.7",
- "@next/swc-linux-arm64-musl": "15.5.7",
- "@next/swc-linux-x64-gnu": "15.5.7",
- "@next/swc-linux-x64-musl": "15.5.7",
- "@next/swc-win32-arm64-msvc": "15.5.7",
- "@next/swc-win32-x64-msvc": "15.5.7",
+ "@next/swc-darwin-arm64": "15.5.15",
+ "@next/swc-darwin-x64": "15.5.15",
+ "@next/swc-linux-arm64-gnu": "15.5.15",
+ "@next/swc-linux-arm64-musl": "15.5.15",
+ "@next/swc-linux-x64-gnu": "15.5.15",
+ "@next/swc-linux-x64-musl": "15.5.15",
+ "@next/swc-win32-arm64-msvc": "15.5.15",
+ "@next/swc-win32-x64-msvc": "15.5.15",
"sharp": "^0.34.3"
},
"peerDependencies": {
@@ -3408,9 +3430,10 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
},
"node_modules/undici": {
- "version": "6.23.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
- "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
+ "version": "6.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
+ "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
+ "license": "MIT",
"engines": {
"node": ">=18.17"
}
diff --git a/package.json b/package.json
index f36f9869..ceb2aed3 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"envalid": "^8.1.1",
"fuse.js": "^7.0.0",
"iron-session": "^8.0.4",
- "next": "15.5.9",
+ "next": "^15.5.15",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-force-graph-2d": "^1.29.1"
diff --git a/public/fonts/Aileron-Bold.woff2 b/public/fonts/Aileron-Bold.woff2
new file mode 100644
index 00000000..ec8e9dad
Binary files /dev/null and b/public/fonts/Aileron-Bold.woff2 differ
diff --git a/public/fonts/Aileron-BoldItalic.woff2 b/public/fonts/Aileron-BoldItalic.woff2
new file mode 100644
index 00000000..58079cfc
Binary files /dev/null and b/public/fonts/Aileron-BoldItalic.woff2 differ
diff --git a/public/fonts/Aileron-Italic.woff2 b/public/fonts/Aileron-Italic.woff2
new file mode 100644
index 00000000..479be190
Binary files /dev/null and b/public/fonts/Aileron-Italic.woff2 differ
diff --git a/public/fonts/Aileron-Light.woff2 b/public/fonts/Aileron-Light.woff2
new file mode 100644
index 00000000..db44934e
Binary files /dev/null and b/public/fonts/Aileron-Light.woff2 differ
diff --git a/public/fonts/Aileron-LightItalic.woff2 b/public/fonts/Aileron-LightItalic.woff2
new file mode 100644
index 00000000..b1e19140
Binary files /dev/null and b/public/fonts/Aileron-LightItalic.woff2 differ
diff --git a/public/fonts/Aileron-Regular.woff2 b/public/fonts/Aileron-Regular.woff2
new file mode 100644
index 00000000..78412d35
Binary files /dev/null and b/public/fonts/Aileron-Regular.woff2 differ
diff --git a/public/fonts/Aileron-SemiBold.woff2 b/public/fonts/Aileron-SemiBold.woff2
new file mode 100644
index 00000000..d6404419
Binary files /dev/null and b/public/fonts/Aileron-SemiBold.woff2 differ
diff --git a/public/fonts/Aileron-SemiBoldItalic.woff2 b/public/fonts/Aileron-SemiBoldItalic.woff2
new file mode 100644
index 00000000..045c7ddf
Binary files /dev/null and b/public/fonts/Aileron-SemiBoldItalic.woff2 differ
diff --git a/scripts/create-publication.mjs b/scripts/create-publication.mjs
new file mode 100644
index 00000000..5b835c82
--- /dev/null
+++ b/scripts/create-publication.mjs
@@ -0,0 +1,36 @@
+#!/usr/bin/env node
+// Run: node scripts/create-publication.mjs
+
+import { AtpAgent } from "@atproto/api"
+import { TID } from "@atproto/common-web"
+
+const agent = new AtpAgent({ service: "https://bsky.social" })
+
+await agent.login({
+ identifier: process.env.ATPROTO_HANDLE || "plresearch.org",
+ password: process.env.ATPROTO_PASSWORD,
+})
+
+const rkey = TID.nextStr()
+
+const record = {
+ $type: "site.standard.publication",
+ url: "https://www.plresearch.org",
+ name: "PL R&D",
+ description: "Protocol Labs R&D — driving research breakthroughs to push humanity forward.",
+}
+
+const result = await agent.com.atproto.repo.createRecord({
+ repo: agent.session.did,
+ collection: "site.standard.publication",
+ rkey,
+ validate: false,
+ record,
+})
+
+console.log("Created publication record:")
+console.log("URI:", result.data.uri)
+console.log("CID:", result.data.cid)
+console.log()
+console.log("Add this to your .env:")
+console.log(`NEXT_PUBLIC_PUBLICATION_URI=${result.data.uri}`)
diff --git a/src/app/.well-known/site.standard.publication/route.ts b/src/app/.well-known/site.standard.publication/route.ts
new file mode 100644
index 00000000..935b47c2
--- /dev/null
+++ b/src/app/.well-known/site.standard.publication/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server"
+
+export const dynamic = "force-dynamic"
+
+export async function GET() {
+ const uri = process.env.NEXT_PUBLIC_PUBLICATION_URI
+ if (!uri) {
+ return new NextResponse("Publication record not configured", { status: 404 })
+ }
+ // standard.site expects plain text AT-URI
+ return new NextResponse(uri, {
+ headers: { "Content-Type": "text/plain" },
+ })
+}
diff --git a/src/app/about/edit/page.tsx b/src/app/about/edit/page.tsx
new file mode 100644
index 00000000..1a4da45a
--- /dev/null
+++ b/src/app/about/edit/page.tsx
@@ -0,0 +1,237 @@
+'use client'
+
+import { usePageEdit, EditableField, EditBar, useRequireAdmin } from '@/components/InlineEdit'
+import AuthorCard from '@/components/AuthorCard'
+import Breadcrumb from '@/components/Breadcrumb'
+import Link from 'next/link'
+
+export default function AboutEditPage() {
+ const { ready } = useRequireAdmin()
+ const { get, set, isDirty, isLoading, isSaving, saveStatus, save, discard } = usePageEdit('about')
+
+ if (!ready || isLoading) return (
+
+ )
+
+ return (
+
+ {/* Hero */}
+
+
+
+ {/* Background image - rotated hexagon clip */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
set('hero', 'title', v)}
+ className="relative z-10 font-semibold text-[28px] md:text-[40px] lg:text-[48px] leading-[1.1] tracking-tight mb-6 max-w-xl"
+ />
+ set('hero', 'body', v)}
+ multiline
+ className="relative z-10 text-gray-600 text-lg md:text-xl lg:text-[22px] leading-relaxed max-w-2xl mb-6"
+ />
+
+
+ Focus areas
+
+
+
+
+
+ Meet the team
+
+
+
+
+
+
+
+
+ {/* Focus Areas - static non-editable navigation cards */}
+
+
Our Four Focus Areas
+
+
+
+
+
+
+
+
+ {/* History */}
+
set('history', 'title', v)}
+ >
+ set('history', 'body', v)}
+ multiline
+ className="text-base text-gray-700 leading-relaxed"
+ />
+ Paragraphs separated by blank lines
+
+
+ {/* Collaborations */}
+
set('collaborations', 'title', v)}
+ >
+ set('collaborations', 'body', v)}
+ multiline
+ className="text-base text-gray-700 leading-relaxed"
+ />
+ Paragraphs separated by blank lines
+
+
+ {/* Quote Juan */}
+
+
+
set('quote-juan', 'body', v)}
+ className="font-semibold text-xl lg:text-2xl leading-relaxed mb-8"
+ />
+
+
+
+ {/* The Future */}
+
set('future', 'title', v)}
+ >
+ set('future', 'body', v)}
+ multiline
+ className="text-base text-gray-700 leading-relaxed"
+ />
+ Paragraphs separated by blank lines
+
+
+ {/* Will Scott quote */}
+
+
+
set('quote-will', 'body', v)}
+ className="text-lg text-gray-700 leading-relaxed mb-5 italic"
+ />
+
+
+
+
+
+
+
+ )
+}
+
+function Section({
+ label,
+ title,
+ onTitleChange,
+ children,
+}: {
+ label: string
+ title: string
+ onTitleChange: (v: string) => void
+ children: React.ReactNode
+}) {
+ return (
+
+
{label}
+
+ {children}
+
+ )
+}
+
+function FocusCard({ slug, title, description }: { slug: string; title: string; description: string }) {
+ return (
+
+ {title}
+ {description}
+
+ )
+}
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index 58d04da0..a79f2b4d 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -1,18 +1,38 @@
import type { Metadata } from 'next'
+import EditPageButton from '@/components/EditPageButton'
+import { PageEditHistoryByline } from '@/components/EditHistoryByline'
import Link from 'next/link'
import AuthorCard from '@/components/AuthorCard'
import Breadcrumb from '@/components/Breadcrumb'
+import { fetchPage, getSection } from "@/lib/indexer"
export const metadata: Metadata = {
title: 'About',
}
-export default function AboutPage() {
+export default async function AboutPage() {
+ const page = await fetchPage("about")
+ const hero = getSection(page, "hero")
+ const history = getSection(page, "history")
+ const collabs = getSection(page, "collaborations")
+ const future = getSection(page, "future")
+ const quoteJuan = getSection(page, "quote-juan")
+ const quoteWill = getSection(page, "quote-will")
+
+ const historyFallback =
+ "Those efforts became IPFS, a free and open-source software project to allow users and applications to directly share information without needing a central server. Within IPFS, expertise in programming language theory led to Multiformats, an effort to make our technologies adaptable and upgradable in a future-proof way, and IPLD, our data model for content-addressed data.\n\nIn parallel with IPFS, where users voluntarily store data they find interesting, we designed and launched Filecoin, a protocol that allows users to pay others to store data they find interesting. Originally proposed in 2014, the Filecoin concept was further detailed in our 2017 whitepaper, with CryptoLab efforts supporting both the network launch and future improvements.\n\nProtocol Labs has evolved and expanded beyond internet infrastructure to address broader challenges in coordination, AI development, and human enhancement technologies. What was once the company behind IPFS and Filecoin is now an innovation network that supports field-building, grant-making, and investing across emerging technologies. PL's focus areas now span the full spectrum from securing digital rights to pioneering responsible advancement in AI, robotics, and neurotechnology. PL R&D is the corner of the network that supports the early engineering and research efforts that will drive these focus areas forward."
+
+ const futureFallback =
+ "This critical century demands both caution and ambition. With technologies capable of rewriting genetic codes and reshaping how billions coordinate, we're building robust foundations across our four focus areas. From securing digital human rights through Web3 infrastructure to advancing AI and neurotechnology responsibly, from pioneering public goods funding mechanisms to developing breakthrough coordination systems, our work aims to harness humanity's potential while navigating existential challenges.\n\nWe do these things in ways that make technology easy to upgrade and hard to turn against users. We work toward this ideal by building open-source software, with users and contributors as vital components in the development process, and a licensing stack that ensures these tools remain free to obtain and use. Through collaboration across our focus areas, we're accelerating the R&D pipeline to push humanity forward."
+
return (
{/* Hero */}
+
{/* Background image - rotated hexagon clip */}
- Our research is driven by beliefs about how technology should serve humanity.
+ {hero?.title || "Our research is driven by beliefs about how technology should serve humanity."}
- Substantial engineering efforts are necessary to turn ideas into real and useful tools that people can use. Our longest-term vision-driven innovation takes place in PL R&D.
+ {hero?.body || hero?.subtitle || "Substantial engineering efforts are necessary to turn ideas into real and useful tools that people can use. Our longest-term vision-driven innovation takes place in PL R&D."}
{/* History */}
-
+
-
- Those efforts became IPFS, a free and open-source software project to allow users and applications to directly share information without needing a central server. Within IPFS, expertise in programming language theory led to Multiformats, an effort to make our technologies adaptable and upgradable in a future-proof way, and IPLD, our data model for content-addressed data.
-
-
- In parallel with IPFS, where users voluntarily store data they find interesting, we designed and launched Filecoin, a protocol that allows users to pay others to store data they find interesting. Originally proposed in 2014, the Filecoin concept was further detailed in our 2017 whitepaper, with CryptoLab efforts supporting both the network launch and future improvements.
-
-
- Protocol Labs has evolved and expanded beyond internet infrastructure to address broader challenges in coordination, AI development, and human enhancement technologies. What was once the company behind IPFS and Filecoin is now an innovation network that supports field-building, grant-making, and investing across emerging technologies. PL's focus areas now span the full spectrum from securing digital rights to pioneering responsible advancement in AI, robotics, and neurotechnology. PL R&D is the corner of the network that supports the early engineering and research efforts that will drive these focus areas forward.
-
+ {(history?.body || historyFallback).split("\n\n").map((p, i) => (
+
{p}
+ ))}
{/* Collaborations */}
-
+
- Some of this support takes the form of our grant program, which supports academic research efforts related to the central mission and goals of Protocol Labs. Other support includes conference and event sponsorships, which usually involves representation from Protocol Labs researchers. If you're attending a conference we are sponsoring, you have a great chance of catching one of us in person. Alternatively, our conference sponsorships often take the form of sponsoring free, high-quality recordings of the talks to educate (or entertain) those unable to attend.
+ {collabs?.body || "Some of this support takes the form of our grant program, which supports academic research efforts related to the central mission and goals of Protocol Labs. Other support includes conference and event sponsorships, which usually involves representation from Protocol Labs researchers. If you're attending a conference we are sponsoring, you have a great chance of catching one of us in person. Alternatively, our conference sponsorships often take the form of sponsoring free, high-quality recordings of the talks to educate (or entertain) those unable to attend."}
@@ -137,20 +151,17 @@ export default function AboutPage() {
- More innovation faster
+ {quoteJuan?.body || quoteJuan?.title || "More innovation faster"}
{/* The Future */}
-
+
-
- This critical century demands both caution and ambition. With technologies capable of rewriting genetic codes and reshaping how billions coordinate, we're building robust foundations across our four focus areas. From securing digital human rights through Web3 infrastructure to advancing AI and neurotechnology responsibly, from pioneering public goods funding mechanisms to developing breakthrough coordination systems, our work aims to harness humanity's potential while navigating existential challenges.
-
-
- We do these things in ways that make technology easy to upgrade and hard to turn against users. We work toward this ideal by building open-source software, with users and contributors as vital components in the development process, and a licensing stack that ensures these tools remain free to obtain and use. Through collaboration across our focus areas, we're accelerating the R&D pipeline to push humanity forward.
-
+ {(future?.body || futureFallback).split("\n\n").map((p, i) => (
+
{p}
+ ))}
@@ -158,11 +169,12 @@ export default function AboutPage() {
- “We consistently bet, not only that the future could be a fantastic and wonderful place, but that it's worthwhile for us, as an organization, to work toward that future.”
+ {quoteWill?.body || "\u201cWe consistently bet, not only that the future could be a fantastic and wonderful place, but that it\u2019s worthwhile for us, as an organization, to work toward that future.\u201d"}
+
)
}
@@ -185,5 +197,3 @@ function FocusCard({ slug, title, description }: { slug: string; title: string;
)
}
-
-
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 6595d473..f087b2c3 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,10 +1,9 @@
'use client'
import { useAuth } from '@/lib/atproto'
-import { useState, useEffect, useCallback } from 'react'
+import { useState } from 'react'
import Link from 'next/link'
import Breadcrumb from '@/components/Breadcrumb'
-import type { CuratedListEntry } from '@/lib/lexicons'
import PageEditor from '@/components/PageEditor'
export default function AdminPage() {
@@ -13,79 +12,6 @@ export default function AdminPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
- // Curated accounts state
- const [users, setUsers] = useState
([])
- const [isLoadingUsers, setIsLoadingUsers] = useState(false)
- const [newHandle, setNewHandle] = useState('')
- const [isAdding, setIsAdding] = useState(false)
- const [removingDid, setRemovingDid] = useState(null)
- const [listError, setListError] = useState('')
-
- // Profile preview state
- const [profilePreview, setProfilePreview] = useState<{
- did: string
- handle: string
- displayName?: string
- avatar?: string
- } | null>(null)
- const [isLoadingPreview, setIsLoadingPreview] = useState(false)
- const [previewError, setPreviewError] = useState('')
-
- const fetchCuratedList = useCallback(async () => {
- setIsLoadingUsers(true)
- try {
- const response = await fetch('/api/curated-list')
- if (response.ok) {
- const data = await response.json()
- setUsers(data.users || [])
- }
- } catch {
- // Ignore errors on initial load
- } finally {
- setIsLoadingUsers(false)
- }
- }, [])
-
- useEffect(() => {
- if (isAuthenticated && isAdmin) {
- fetchCuratedList()
- }
- }, [isAuthenticated, isAdmin, fetchCuratedList])
-
- // Debounced profile preview lookup
- useEffect(() => {
- setProfilePreview(null)
- setPreviewError('')
-
- const trimmed = newHandle.trim()
- if (!trimmed || trimmed.length < 3) return
-
- setIsLoadingPreview(true)
- const timer = setTimeout(async () => {
- try {
- const response = await fetch(`/api/users/${encodeURIComponent(trimmed)}`)
- if (!response.ok) {
- setPreviewError('User not found')
- setProfilePreview(null)
- } else {
- const data = await response.json()
- setProfilePreview(data.profile)
- setPreviewError('')
- }
- } catch {
- setPreviewError('Lookup failed')
- setProfilePreview(null)
- } finally {
- setIsLoadingPreview(false)
- }
- }, 500)
-
- return () => {
- clearTimeout(timer)
- setIsLoadingPreview(false)
- }
- }, [newHandle])
-
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!handle.trim()) return
@@ -104,46 +30,6 @@ export default function AdminPage() {
window.location.href = '/'
}
- const handleAddUser = async (e: React.FormEvent) => {
- e.preventDefault()
- if (!newHandle.trim()) return
- setIsAdding(true)
- setListError('')
- try {
- const response = await fetch('/api/curated-list', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ handle: newHandle.trim() }),
- })
- const data = await response.json()
- if (!response.ok) throw new Error(data.error || 'Failed to add user')
- setUsers(data.users)
- setNewHandle('')
- setProfilePreview(null)
- } catch (err) {
- setListError(err instanceof Error ? err.message : 'Failed to add user')
- } finally {
- setIsAdding(false)
- }
- }
-
- const handleRemoveUser = async (did: string) => {
- setRemovingDid(did)
- setListError('')
- try {
- const response = await fetch(`/api/curated-list?did=${encodeURIComponent(did)}`, {
- method: 'DELETE',
- })
- const data = await response.json()
- if (!response.ok) throw new Error(data.error || 'Failed to remove user')
- setUsers(data.users)
- } catch (err) {
- setListError(err instanceof Error ? err.message : 'Failed to remove user')
- } finally {
- setRemovingDid(null)
- }
- }
-
if (isLoading) {
return (
@@ -213,15 +99,6 @@ export default function AdminPage() {
- {/* Quick Actions */}
-
-
Actions
-
- New entry →
- View blog →
-
-
-
{/* Identity */}
Identity
@@ -237,104 +114,6 @@ export default function AdminPage() {
- {/* Curated Accounts (admin only) */}
- {isAdmin && (
-
-
Curated Accounts
-
- Posts from these accounts will appear on the blog.
-
-
- {/* Add user */}
-
-
- {listError &&
{listError}
}
-
- {/* Users list */}
- {isLoadingUsers ? (
-
Loading...
- ) : users.length === 0 ? (
-
No curated accounts yet.
- ) : (
-
- {users.map((user) => (
-
-
-
handleRemoveUser(user.did)}
- disabled={removingDid === user.did}
- className="text-xs text-gray-400 hover:text-pink transition-colors disabled:opacity-40"
- >
- {removingDid === user.did ? 'Removing...' : 'Remove'}
-
-
- ))}
-
- )}
-
- )}
-
{/* Page Content (admin only) */}
{isAdmin && (
@@ -371,7 +150,7 @@ function AdminGeo() {
-
+
)
}
diff --git a/src/app/api/curated-list/route.ts b/src/app/api/curated-list/route.ts
deleted file mode 100644
index 5d58721c..00000000
--- a/src/app/api/curated-list/route.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server'
-import { getAuthenticatedAgent } from '@/lib/agent'
-import { getCuratedList, getCuratedListRecordCid, resolvePds, getPdsAgent, resolveHandle } from '@/lib/atproto-client'
-import { getSession } from '@/lib/session'
-import { CURATEDLIST_COLLECTION, ADMIN_DID } from '@/lib/lexicons'
-import type { CuratedListRecord, CuratedListEntry } from '@/lib/lexicons'
-import { CURATED_LIST_RKEY } from '@/lib/atproto-client'
-
-export const dynamic = 'force-dynamic'
-
-function isAdmin(did: string | undefined): boolean {
- return did === ADMIN_DID
-}
-
-/**
- * GET /api/curated-list - Get the curated list (public)
- */
-export async function GET() {
- try {
- const users = await getCuratedList()
- return NextResponse.json({ users })
- } catch (error) {
- console.error('Failed to fetch curated list:', error)
- return NextResponse.json({ users: [] })
- }
-}
-
-/**
- * POST /api/curated-list - Add a user to the curated list (admin only)
- */
-export async function POST(request: NextRequest) {
- try {
- const session = await getSession()
- if (!session.did || !isAdmin(session.did)) {
- return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
- }
-
- const agent = await getAuthenticatedAgent()
- if (!agent) {
- return NextResponse.json({ error: 'Failed to authenticate' }, { status: 401 })
- }
-
- const body = await request.json()
- const { handle } = body
-
- if (!handle || typeof handle !== 'string') {
- return NextResponse.json({ error: 'Handle is required' }, { status: 400 })
- }
-
- const did = await resolveHandle(handle.trim())
- if (!did) {
- return NextResponse.json({ error: 'User not found' }, { status: 404 })
- }
-
- const currentUsers = await getCuratedList()
-
- if (currentUsers.some(u => u.did === did)) {
- return NextResponse.json({ error: 'User already in curated list' }, { status: 400 })
- }
-
- const newEntry: CuratedListEntry = {
- did,
- handle: handle.trim(),
- addedAt: new Date().toISOString(),
- }
-
- const updatedUsers = [...currentUsers, newEntry]
-
- const record: CuratedListRecord = {
- $type: CURATEDLIST_COLLECTION,
- users: updatedUsers,
- createdAt: currentUsers.length === 0 ? new Date().toISOString() : updatedUsers[0]?.addedAt || new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- }
-
- const existingCid = await getCuratedListRecordCid()
-
- if (existingCid) {
- await agent.com.atproto.repo.putRecord({
- repo: session.did,
- collection: CURATEDLIST_COLLECTION,
- rkey: CURATED_LIST_RKEY,
- record,
- swapRecord: existingCid,
- })
- } else {
- await agent.com.atproto.repo.createRecord({
- repo: session.did,
- collection: CURATEDLIST_COLLECTION,
- rkey: CURATED_LIST_RKEY,
- record,
- })
- }
-
- return NextResponse.json({ success: true, users: updatedUsers })
- } catch (error) {
- console.error('Failed to add user to curated list:', error)
- return NextResponse.json({ error: 'Failed to add user' }, { status: 500 })
- }
-}
-
-/**
- * DELETE /api/curated-list - Remove a user from the curated list (admin only)
- */
-export async function DELETE(request: NextRequest) {
- try {
- const session = await getSession()
- if (!session.did || !isAdmin(session.did)) {
- return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
- }
-
- const agent = await getAuthenticatedAgent()
- if (!agent) {
- return NextResponse.json({ error: 'Failed to authenticate' }, { status: 401 })
- }
-
- const { searchParams } = new URL(request.url)
- const didToRemove = searchParams.get('did')
-
- if (!didToRemove) {
- return NextResponse.json({ error: 'DID is required' }, { status: 400 })
- }
-
- const currentUsers = await getCuratedList()
- const updatedUsers = currentUsers.filter(u => u.did !== didToRemove)
-
- if (updatedUsers.length === currentUsers.length) {
- return NextResponse.json({ error: 'User not found in curated list' }, { status: 404 })
- }
-
- const existingCid = await getCuratedListRecordCid()
- if (!existingCid) {
- return NextResponse.json({ error: 'Curated list not found' }, { status: 500 })
- }
-
- const record: CuratedListRecord = {
- $type: CURATEDLIST_COLLECTION,
- users: updatedUsers,
- createdAt: currentUsers[0]?.addedAt || new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- }
-
- await agent.com.atproto.repo.putRecord({
- repo: session.did,
- collection: CURATEDLIST_COLLECTION,
- rkey: CURATED_LIST_RKEY,
- record,
- swapRecord: existingCid,
- })
-
- return NextResponse.json({ success: true, users: updatedUsers })
- } catch (error) {
- console.error('Failed to remove user:', error)
- return NextResponse.json({ error: 'Failed to remove user' }, { status: 500 })
- }
-}
diff --git a/src/app/api/edit-history/route.ts b/src/app/api/edit-history/route.ts
new file mode 100644
index 00000000..6552e42b
--- /dev/null
+++ b/src/app/api/edit-history/route.ts
@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from "next/server"
+import { fetchEditEvents } from "@/lib/indexer"
+
+export const dynamic = "force-dynamic"
+
+/**
+ * Returns audit-log entries for a given AT-URI, newest first.
+ * Caller: `GET /api/edit-history?target=at://did/collection/rkey`
+ *
+ * No auth gate — edit history is public. The lexicon does not store diffs,
+ * only field names, so revealing it does not leak pre-publish content.
+ */
+export async function GET(req: NextRequest) {
+ const target = req.nextUrl.searchParams.get("target")
+ if (!target || !target.startsWith("at://")) {
+ return NextResponse.json(
+ { error: "Query param `target` must be a valid AT-URI" },
+ { status: 400 },
+ )
+ }
+ try {
+ const events = await fetchEditEvents(target)
+ return NextResponse.json({ target, count: events.length, events })
+ } catch (error) {
+ console.error("Failed to fetch edit history:", error)
+ return NextResponse.json({ error: "Failed to fetch edit history" }, { status: 500 })
+ }
+}
diff --git a/src/app/api/feed/route.ts b/src/app/api/feed/route.ts
deleted file mode 100644
index a0465615..00000000
--- a/src/app/api/feed/route.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { NextResponse } from 'next/server'
-import { getFeedPosts } from '@/lib/atproto-client'
-
-export const dynamic = 'force-dynamic'
-
-/**
- * GET /api/feed - Get all posts from curated accounts
- */
-export async function GET() {
- try {
- const posts = await getFeedPosts()
- return NextResponse.json({ posts })
- } catch (error) {
- console.error('Failed to fetch feed:', error)
- return NextResponse.json({ posts: [] })
- }
-}
diff --git a/src/app/api/opportunity-spaces/[rkey]/route.ts b/src/app/api/opportunity-spaces/[rkey]/route.ts
new file mode 100644
index 00000000..dc1faa61
--- /dev/null
+++ b/src/app/api/opportunity-spaces/[rkey]/route.ts
@@ -0,0 +1,114 @@
+import { NextRequest, NextResponse } from "next/server"
+import { AtpAgent } from "@atproto/api"
+import { revalidateTag } from "next/cache"
+import { fetchOpportunitySpace } from "@/lib/indexer"
+import { getSession } from "@/lib/session"
+import { ADMIN_DID, ADMIN_DIDS, OPPORTUNITY_COLLECTION } from "@/lib/lexicons"
+import { env } from "@/lib/env"
+import { diffFields, writeEditEvent } from "@/lib/audit"
+import type { OpportunitySpaceRecord } from "@/lib/lexicons"
+
+export const dynamic = "force-dynamic"
+
+type Props = { params: Promise<{ rkey: string }> }
+
+export async function GET(_req: NextRequest, { params }: Props) {
+ const { rkey } = await params
+ try {
+ const record = await fetchOpportunitySpace(rkey)
+ if (!record) {
+ return NextResponse.json({ error: "Opportunity space not found" }, { status: 404 })
+ }
+ return NextResponse.json({ rkey, record })
+ } catch {
+ return NextResponse.json({ error: "Failed to fetch opportunity space" }, { status: 500 })
+ }
+}
+
+/**
+ * Authenticate as plresearch.org via app password — required because
+ * opportunity-space records live on plresearch.org's repo even when an
+ * admin signed in as a different DID is performing the edit.
+ * Mirrors the getPlresearchAgent() in /api/pages/[rkey]/route.ts.
+ */
+async function getPlresearchAgent(): Promise
{
+ const handle = env.ATPROTO_HANDLE
+ const password = env.ATPROTO_PASSWORD
+ if (!handle || !password) {
+ throw new Error("ATPROTO_HANDLE and ATPROTO_PASSWORD must be set")
+ }
+ const agent = new AtpAgent({ service: "https://bsky.social" })
+ await agent.login({ identifier: handle, password })
+ return agent
+}
+
+export async function PUT(req: NextRequest, { params }: Props) {
+ const { rkey } = await params
+ const session = await getSession()
+
+ if (!session.did) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+ if (!ADMIN_DIDS.includes(session.did)) {
+ return NextResponse.json({ error: "Forbidden — admin only" }, { status: 403 })
+ }
+
+ try {
+ const body = (await req.json()) as OpportunitySpaceRecord
+
+ // Required fields per the lexicon
+ if (!body.areaSlug || !body.id || !body.title || !body.description) {
+ return NextResponse.json(
+ { error: "Invalid opportunity space record — areaSlug, id, title, description are required" },
+ { status: 400 },
+ )
+ }
+
+ // Snapshot the prior record so we can compute which fields changed.
+ // Best-effort: if the indexer is down we still save, just with an empty diff.
+ const prior = await fetchOpportunitySpace(rkey).catch(() => null)
+
+ const nextRecord = {
+ $type: OPPORTUNITY_COLLECTION,
+ areaSlug: body.areaSlug,
+ id: body.id,
+ title: body.title,
+ tagline: body.tagline,
+ image: body.image,
+ description: body.description,
+ inflectionPoint: body.inflectionPoint,
+ shift: body.shift,
+ theOpportunity: body.theOpportunity,
+ subfields: body.subfields ?? [],
+ tippingSignals: body.tippingSignals ?? [],
+ keyAssumptions: body.keyAssumptions ?? [],
+ observations: body.observations ?? [],
+ fieldSignals: body.fieldSignals ?? [],
+ updatedAt: new Date().toISOString(),
+ }
+
+ const agent = await getPlresearchAgent()
+ const response = await agent.com.atproto.repo.putRecord({
+ repo: ADMIN_DID,
+ collection: OPPORTUNITY_COLLECTION,
+ rkey,
+ record: nextRecord,
+ })
+
+ revalidateTag("indexer")
+
+ // Fire-and-forget audit log to the editor's own PDS. Failures here must not
+ // bubble up — the primary edit already succeeded.
+ await writeEditEvent({
+ targetUri: response.data.uri,
+ targetCid: response.data.cid,
+ collection: OPPORTUNITY_COLLECTION,
+ changedFields: diffFields(prior as Record | null, nextRecord),
+ })
+
+ return NextResponse.json({ uri: response.data.uri, cid: response.data.cid })
+ } catch (error) {
+ console.error("Failed to update opportunity space:", error)
+ return NextResponse.json({ error: "Failed to update opportunity space" }, { status: 500 })
+ }
+}
diff --git a/src/app/api/pages/[rkey]/route.ts b/src/app/api/pages/[rkey]/route.ts
index f7ee3d88..3cadd928 100644
--- a/src/app/api/pages/[rkey]/route.ts
+++ b/src/app/api/pages/[rkey]/route.ts
@@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from "next/server"
-import { getPageRecord } from "@/lib/atproto-client"
-import { getAuthenticatedAgent } from "@/lib/agent"
+import { AtpAgent } from "@atproto/api"
+import { revalidateTag } from "next/cache"
+import { fetchPage } from "@/lib/indexer"
import { getSession } from "@/lib/session"
-import { ADMIN_DID, PAGE_COLLECTION } from "@/lib/lexicons"
+import { ADMIN_DID, ADMIN_DIDS, PAGE_COLLECTION } from "@/lib/lexicons"
+import { env } from "@/lib/env"
+import { diffFields, writeEditEvent } from "@/lib/audit"
import type { PageRecord } from "@/lib/lexicons"
export const dynamic = "force-dynamic"
@@ -12,7 +15,7 @@ type Props = { params: Promise<{ rkey: string }> }
export async function GET(_req: NextRequest, { params }: Props) {
const { rkey } = await params
try {
- const record = await getPageRecord(rkey)
+ const record = await fetchPage(rkey)
if (!record) {
return NextResponse.json({ error: "Page not found" }, { status: 404 })
}
@@ -22,25 +25,36 @@ export async function GET(_req: NextRequest, { params }: Props) {
}
}
+/**
+ * Get an agent authenticated as plresearch.org via app password.
+ * This is needed because page records live on plresearch.org's repo,
+ * but the admin user may be logged in as a different DID (e.g. daviddao.org).
+ */
+async function getPlresearchAgent(): Promise {
+ const handle = env.ATPROTO_HANDLE
+ const password = env.ATPROTO_PASSWORD
+ if (!handle || !password) {
+ throw new Error("ATPROTO_HANDLE and ATPROTO_PASSWORD must be set")
+ }
+ const agent = new AtpAgent({ service: "https://bsky.social" })
+ await agent.login({ identifier: handle, password })
+ return agent
+}
+
export async function PUT(req: NextRequest, { params }: Props) {
const { rkey } = await params
const session = await getSession()
- // Auth check
+ // Auth check — user must be logged in
if (!session.did) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
- // Admin check — only the admin DID can edit pages
- if (session.did !== ADMIN_DID) {
+ // Admin check — only admin DIDs can edit pages
+ if (!ADMIN_DIDS.includes(session.did)) {
return NextResponse.json({ error: "Forbidden — admin only" }, { status: 403 })
}
- const agent = await getAuthenticatedAgent()
- if (!agent) {
- return NextResponse.json({ error: "Failed to restore session" }, { status: 401 })
- }
-
try {
const body: PageRecord = await req.json()
@@ -49,20 +63,39 @@ export async function PUT(req: NextRequest, { params }: Props) {
return NextResponse.json({ error: "Invalid page record" }, { status: 400 })
}
- // Write to the admin repo using putRecord (create or update)
+ // Snapshot the prior record so we can compute which fields changed.
+ const prior = await fetchPage(rkey).catch(() => null)
+
+ const nextRecord = {
+ $type: PAGE_COLLECTION,
+ pageId: body.pageId,
+ iconType: body.iconType,
+ leads: body.leads,
+ advisors: body.advisors,
+ sections: body.sections,
+ updatedAt: new Date().toISOString(),
+ }
+
+ // Authenticate as plresearch.org to write to its repo
+ const agent = await getPlresearchAgent()
+
+ // Write to plresearch.org's repo using putRecord
const response = await agent.com.atproto.repo.putRecord({
- repo: session.did,
+ repo: ADMIN_DID,
collection: PAGE_COLLECTION,
rkey,
- record: {
- $type: PAGE_COLLECTION,
- pageId: body.pageId,
- iconType: body.iconType,
- leads: body.leads,
- advisors: body.advisors,
- sections: body.sections,
- updatedAt: new Date().toISOString(),
- },
+ record: nextRecord,
+ })
+
+ // Bust the ISR cache so pages reflect the update immediately
+ revalidateTag("indexer")
+
+ // Fire-and-forget audit log to the editor's own PDS.
+ await writeEditEvent({
+ targetUri: response.data.uri,
+ targetCid: response.data.cid,
+ collection: PAGE_COLLECTION,
+ changedFields: diffFields(prior as Record | null, nextRecord),
})
return NextResponse.json({ uri: response.data.uri, cid: response.data.cid })
diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts
index 2c84ae7c..f135109c 100644
--- a/src/app/api/pages/route.ts
+++ b/src/app/api/pages/route.ts
@@ -1,12 +1,14 @@
import { NextResponse } from "next/server"
-import { getAllPageRecords } from "@/lib/atproto-client"
+import { fetchAllPages } from "@/lib/indexer"
export const dynamic = "force-dynamic"
export async function GET() {
try {
- const pages = await getAllPageRecords()
- return NextResponse.json({ pages })
+ const pages = await fetchAllPages()
+ return NextResponse.json({
+ pages: pages.map(p => ({ rkey: p.rkey, record: p }))
+ })
} catch (error) {
console.error("Failed to fetch pages:", error)
return NextResponse.json({ error: "Failed to fetch pages" }, { status: 500 })
diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts
index 20a41c08..144a49af 100644
--- a/src/app/api/posts/route.ts
+++ b/src/app/api/posts/route.ts
@@ -1,9 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAuthenticatedAgent } from '@/lib/agent'
import { getSession } from '@/lib/session'
-import { POST_COLLECTION } from '@/lib/lexicons'
+import { STANDARD_DOCUMENT_COLLECTION, PLRESEARCH_CONTENT_TYPE } from '@/lib/lexicons'
import { generateTid } from '@/lib/tid'
-import type { PostRecord } from '@/lib/lexicons'
export const dynamic = 'force-dynamic'
@@ -39,23 +38,31 @@ export async function POST(request: NextRequest) {
}
const rkey = generateTid()
- const record: PostRecord = {
- $type: POST_COLLECTION,
+ const record = {
+ $type: STANDARD_DOCUMENT_COLLECTION,
+ site: process.env.NEXT_PUBLIC_PUBLICATION_URI || 'https://www.plresearch.org',
title: title.trim(),
- content: content.trim(),
- summary: summary?.trim() || undefined,
- postType,
- venue: venue?.trim() || undefined,
- authors: Array.isArray(authors) ? authors.filter((a: string) => a.trim()) : undefined,
- doi: doi?.trim() || undefined,
- createdAt: new Date().toISOString(),
+ publishedAt: new Date().toISOString(),
+ path: '/blog/' + rkey,
+ description: summary?.trim() || undefined,
+ textContent: content.trim().replace(/[#*`\[\]]/g, ''),
+ tags: postType !== 'blog' ? [postType] : undefined,
+ content: {
+ $type: PLRESEARCH_CONTENT_TYPE,
+ markdown: content.trim(),
+ postType,
+ venue,
+ authors,
+ doi,
+ },
}
const result = await agent.com.atproto.repo.createRecord({
repo: session.did,
- collection: POST_COLLECTION,
+ collection: STANDARD_DOCUMENT_COLLECTION,
rkey,
record,
+ validate: false,
})
return NextResponse.json({
diff --git a/src/app/areas/[slug]/edit/page.tsx b/src/app/areas/[slug]/edit/page.tsx
new file mode 100644
index 00000000..f9542e9f
--- /dev/null
+++ b/src/app/areas/[slug]/edit/page.tsx
@@ -0,0 +1,166 @@
+'use client'
+
+import Link from 'next/link'
+import { useParams } from 'next/navigation'
+import { AreaIcon, type AreaIconType } from '@/components/AreaIcons'
+import AuthorCard from '@/components/AuthorCard'
+import Breadcrumb from '@/components/Breadcrumb'
+import {
+ useRequireAdmin,
+ usePageEdit,
+ EditableField,
+ EditBar,
+ EditBarSpacer,
+} from '@/components/InlineEdit'
+
+const SLUG_TO_ICON: Record = {
+ 'ai-robotics': 'neural',
+ 'digital-human-rights': 'shield',
+ 'neurotech': 'brain',
+}
+
+const SLUG_TO_TITLE: Record = {
+ 'ai-robotics': 'AI & Robotics',
+ 'digital-human-rights': 'Digital Human Rights',
+ 'neurotech': 'Neurotechnology',
+}
+
+export default function AreaEditPage() {
+ const params = useParams()
+ const slug = typeof params.slug === 'string' ? params.slug : String(params.slug ?? '')
+
+ const { ready } = useRequireAdmin()
+ const { get, set, isDirty, isLoading, isSaving, saveStatus, save, discard } =
+ usePageEdit(`area-${slug}`)
+
+ if (!ready || isLoading) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ const iconType: AreaIconType = SLUG_TO_ICON[slug] ?? 'hexagon'
+ const fallbackTitle = SLUG_TO_TITLE[slug] ?? slug
+
+ // Read leads/advisors from the page record
+ // usePageEdit exposes the full record indirectly through get()
+ // but leads/advisors are top-level fields, not sections.
+ // For now we show static leads from the known data.
+ const KNOWN_LEADS: Record = {
+ 'ai-robotics': ['molly-mackinlay'],
+ 'digital-human-rights': ['will-scott'],
+ 'neurotech': ['sean-escola'],
+ }
+ const KNOWN_ADVISORS: Record = {
+ 'neurotech': ['adam-marblestone', 'david-markowitz', 'doris-tsao', 'edward-chang', 'greg-wayne', 'ilan-gur', 'matthew-botvinick', 'max-hodak'],
+ }
+
+ const leads = KNOWN_LEADS[slug] || []
+ const advisors = KNOWN_ADVISORS[slug] || []
+
+ return (
+
+
+
+ {/* Hero */}
+
+
+
+
+ set('hero', 'title', v)}
+ placeholder="Focus area title"
+ className="text-2xl lg:text-[44px] font-semibold leading-[1.1] tracking-tight max-w-xl"
+ />
+
+
+
+
+ set('hero', 'subtitle', v)}
+ multiline
+ placeholder="Short summary of this focus area"
+ className="text-lg text-gray-600 leading-relaxed max-w-2xl"
+ />
+
+
+
+
+ Opportunity Spaces
+
+
+
+
+
+
+ {/* Leads */}
+ {leads.length > 0 && (
+
+ {leads.map((authorSlug) => (
+
+ ))}
+
+ )}
+
+
+ {/* Advisors */}
+ {advisors.length > 0 && (
+
+
Advisors
+
+ {advisors.map((authorSlug) => (
+
+ ))}
+
+
+ )}
+
+ {/* Content body */}
+
+
Body content
+
set('hero', 'body', v)}
+ multiline
+ placeholder="Main description of this focus area. Paragraphs separated by blank lines."
+ className="text-base text-gray-700 leading-relaxed max-w-3xl"
+ />
+ Paragraphs separated by blank lines
+
+
+ {/* Publications (static, non-editable) */}
+
+
Publications
+
Publications are loaded from markdown files and cannot be edited here.
+
+ View all publications →
+
+
+
+ {/* Talks (static, non-editable) */}
+
+
Talks
+
Talks are loaded from markdown files and cannot be edited here.
+
+ View all talks →
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/areas/[slug]/page.tsx b/src/app/areas/[slug]/page.tsx
index bdd2f0f8..d1e5316b 100644
--- a/src/app/areas/[slug]/page.tsx
+++ b/src/app/areas/[slug]/page.tsx
@@ -1,4 +1,6 @@
import type { Metadata } from 'next'
+import EditPageButton from '@/components/EditPageButton'
+import { PageEditHistoryByline } from '@/components/EditHistoryByline'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { areas, publications, talks } from '@/lib/content'
@@ -6,6 +8,7 @@ import { stripFaPrefix } from '@/lib/format'
import { AreaIcon, type AreaIconType } from '@/components/AreaIcons'
import AuthorCard from '@/components/AuthorCard'
import Breadcrumb from '@/components/Breadcrumb'
+import { fetchPage, getSection } from '@/lib/indexer'
type Props = { params: Promise<{ slug: string }> }
@@ -38,12 +41,24 @@ export default async function AreaPage({ params }: Props) {
const area = areas.find((a) => a.slug === slug)
if (!area) notFound()
+ const pageRkey = `area-${slug}`
+ const page = await fetchPage(pageRkey)
+ const heroSection = getSection(page, "hero")
+
+ const summary = heroSection?.subtitle || area.summary
+ const bodyFromIndexer = heroSection?.body ?? null
+ const leads = page?.leads || area.leads
+ const advisors = page?.advisors || area.advisors
+
const areaPubs = publications.filter((p) => p.areas.includes(slug)).slice(0, 8)
const areaTalks = talks.filter((t) => t.areas.includes(slug)).slice(0, 6)
return (
+
{/* Hero */}
@@ -54,9 +69,9 @@ export default async function AreaPage({ params }: Props) {
{stripFaPrefix(area.title)}
- {area.summary && (
+ {summary && (
- {area.summary}
+ {summary}
)}
@@ -70,9 +85,9 @@ export default async function AreaPage({ params }: Props) {
- {area.leads.length > 0 && (
+ {leads.length > 0 && (
- {area.leads.map((authorSlug) => (
+ {leads.map((authorSlug) => (
))}
@@ -80,11 +95,11 @@ export default async function AreaPage({ params }: Props) {
{/* Advisors */}
- {area.advisors && area.advisors.length > 0 && (
+ {advisors && advisors.length > 0 && (
Advisors
- {area.advisors.map((authorSlug) => (
+ {advisors.map((authorSlug) => (
))}
@@ -92,9 +107,17 @@ export default async function AreaPage({ params }: Props) {
)}
{/* Content */}
- {area.html && (
+ {(bodyFromIndexer || area.html) && (
-
+ {bodyFromIndexer ? (
+
+ {bodyFromIndexer.split('\n\n').map((para, i) => (
+
{para}
+ ))}
+
+ ) : (
+
+ )}
)}
@@ -141,6 +164,7 @@ export default async function AreaPage({ params }: Props) {
)}
+
)
}
diff --git a/src/app/areas/ai-robotics/opportunity-spaces/[slug]/edit/page.tsx b/src/app/areas/ai-robotics/opportunity-spaces/[slug]/edit/page.tsx
new file mode 100644
index 00000000..d7c8cfcc
--- /dev/null
+++ b/src/app/areas/ai-robotics/opportunity-spaces/[slug]/edit/page.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import { use } from 'react'
+import OpportunitySpaceEditor from '@/components/OpportunitySpaceEditor'
+
+type Props = { params: Promise<{ slug: string }> }
+
+export default function EditOpportunitySpacePage({ params }: Props) {
+ const { slug } = use(params)
+ return
+}
diff --git a/src/app/areas/ai-robotics/opportunity-spaces/[slug]/page.tsx b/src/app/areas/ai-robotics/opportunity-spaces/[slug]/page.tsx
index bd19b9ae..63a1b691 100644
--- a/src/app/areas/ai-robotics/opportunity-spaces/[slug]/page.tsx
+++ b/src/app/areas/ai-robotics/opportunity-spaces/[slug]/page.tsx
@@ -1,21 +1,56 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Breadcrumb from '@/components/Breadcrumb'
+import EditPageButton from '@/components/EditPageButton'
+import EditHistoryByline from '@/components/EditHistoryByline'
import opportunityData from '@/data/fa2/ai-opportunityspaces.json'
+import { fetchOpportunitySpace } from '@/lib/indexer'
+import { ADMIN_DID, OPPORTUNITY_COLLECTION, opportunitySpaceRkey } from '@/lib/lexicons'
type Props = {
params: Promise<{ slug: string }>
}
+type Opportunity = (typeof opportunityData)['opportunities'][number]
+
export function generateStaticParams() {
return opportunityData.opportunities.map((opp) => ({
slug: opp.id,
}))
}
+/**
+ * Prefer live data from the indexer (org.plresearch.opportunitySpace record),
+ * fall back to the static JSON checked into git if the indexer is unreachable
+ * or the record isn't there. Same pattern as /areas/[slug]/page.tsx.
+ */
+async function loadOpp(slug: string): Promise
{
+ const staticOpp = opportunityData.opportunities.find((o) => o.id === slug)
+ const rkey = opportunitySpaceRkey('ai-robotics', slug)
+ const remote = await fetchOpportunitySpace(rkey)
+ if (remote) {
+ return {
+ id: remote.id,
+ title: remote.title,
+ tagline: remote.tagline ?? '',
+ image: remote.image ?? staticOpp?.image ?? '',
+ description: remote.description,
+ inflectionPoint: remote.inflectionPoint ?? '',
+ shift: remote.shift ?? '',
+ theOpportunity: remote.theOpportunity ?? '',
+ subfields: remote.subfields ?? [],
+ tippingSignals: remote.tippingSignals ?? [],
+ keyAssumptions: remote.keyAssumptions ?? [],
+ observations: remote.observations ?? [],
+ fieldSignals: remote.fieldSignals ?? [],
+ } as Opportunity
+ }
+ return staticOpp ?? null
+}
+
export async function generateMetadata({ params }: Props): Promise {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) return { title: 'Not Found' }
return {
title: `${opp.title} – AI & Robotics`,
@@ -25,8 +60,9 @@ export async function generateMetadata({ params }: Props): Promise {
export default async function OpportunityDetailPage({ params }: Props) {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) notFound()
+ const rkey = opportunitySpaceRkey('ai-robotics', slug)
return (
@@ -38,6 +74,15 @@ export default async function OpportunityDetailPage({ params }: Props) {
{ label: opp.title },
]}
/>
+
+
+
+
{/* Hero */}
diff --git a/src/app/areas/ai-robotics/opportunity-spaces/page.tsx b/src/app/areas/ai-robotics/opportunity-spaces/page.tsx
index 419dcdb9..2bc29123 100644
--- a/src/app/areas/ai-robotics/opportunity-spaces/page.tsx
+++ b/src/app/areas/ai-robotics/opportunity-spaces/page.tsx
@@ -2,13 +2,52 @@ import Link from 'next/link'
import type { Metadata } from 'next'
import Breadcrumb from '@/components/Breadcrumb'
import opportunityData from '@/data/fa2/ai-opportunityspaces.json'
+import { fetchOpportunitySpaces } from '@/lib/indexer'
export const metadata: Metadata = {
title: 'Opportunity Spaces – AI & Robotics',
description: 'Four foundational infrastructure layers where PL can catalyze the open substrate that autonomous AI and robotics systems require.',
}
-export default function OpportunitySpacesPage() {
+type OpportunityCard = {
+ id: string
+ title: string
+ tagline?: string
+ image?: string
+ description: string
+ subfields: string[]
+}
+
+/**
+ * Prefer the live indexer (org.plresearch.opportunitySpace records) and
+ * fall back to the static JSON checked into git if the indexer is
+ * unreachable. The static JSON is also used to seed generateStaticParams
+ * on detail pages so build-time rendering stays deterministic.
+ */
+async function loadCards(): Promise
{
+ const remote = await fetchOpportunitySpaces('ai-robotics')
+ if (remote.length > 0) {
+ return remote.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline ?? '',
+ image: o.image ?? opportunityData.opportunities.find((s) => s.id === o.id)?.image ?? '',
+ description: o.description,
+ subfields: o.subfields ?? [],
+ }))
+ }
+ return opportunityData.opportunities.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline,
+ image: o.image,
+ description: o.description,
+ subfields: o.subfields,
+ }))
+}
+
+export default async function OpportunitySpacesPage() {
+ const opportunities = await loadCards()
return (
- {opportunityData.opportunities.map((opp) => (
+ {opportunities.map((opp) => (
}
+
+export default function EditOpportunitySpacePage({ params }: Props) {
+ const { slug } = use(params)
+ return (
+
+ )
+}
diff --git a/src/app/areas/digital-human-rights/opportunity-spaces/[slug]/page.tsx b/src/app/areas/digital-human-rights/opportunity-spaces/[slug]/page.tsx
index 92098f95..7a527bef 100644
--- a/src/app/areas/digital-human-rights/opportunity-spaces/[slug]/page.tsx
+++ b/src/app/areas/digital-human-rights/opportunity-spaces/[slug]/page.tsx
@@ -1,21 +1,51 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Breadcrumb from '@/components/Breadcrumb'
+import EditPageButton from '@/components/EditPageButton'
+import EditHistoryByline from '@/components/EditHistoryByline'
import opportunityData from '@/data/fa2/dhr-opportunityspaces.json'
+import { fetchOpportunitySpace } from '@/lib/indexer'
+import { ADMIN_DID, OPPORTUNITY_COLLECTION, opportunitySpaceRkey } from '@/lib/lexicons'
type Props = {
params: Promise<{ slug: string }>
}
+type Opportunity = (typeof opportunityData)['opportunities'][number]
+
export function generateStaticParams() {
return opportunityData.opportunities.map((opp) => ({
slug: opp.id,
}))
}
+async function loadOpp(slug: string): Promise {
+ const staticOpp = opportunityData.opportunities.find((o) => o.id === slug)
+ const rkey = opportunitySpaceRkey('digital-human-rights', slug)
+ const remote = await fetchOpportunitySpace(rkey)
+ if (remote) {
+ return {
+ id: remote.id,
+ title: remote.title,
+ tagline: remote.tagline ?? '',
+ image: remote.image ?? staticOpp?.image ?? '',
+ description: remote.description,
+ inflectionPoint: remote.inflectionPoint ?? '',
+ shift: remote.shift ?? '',
+ theOpportunity: remote.theOpportunity ?? '',
+ subfields: remote.subfields ?? [],
+ tippingSignals: remote.tippingSignals ?? [],
+ keyAssumptions: remote.keyAssumptions ?? [],
+ observations: remote.observations ?? [],
+ fieldSignals: remote.fieldSignals ?? [],
+ } as Opportunity
+ }
+ return staticOpp ?? null
+}
+
export async function generateMetadata({ params }: Props): Promise {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) return { title: 'Not Found' }
return {
title: `${opp.title} – Digital Human Rights`,
@@ -25,8 +55,9 @@ export async function generateMetadata({ params }: Props): Promise {
export default async function OpportunityDetailPage({ params }: Props) {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) notFound()
+ const rkey = opportunitySpaceRkey('digital-human-rights', slug)
return (
@@ -38,6 +69,15 @@ export default async function OpportunityDetailPage({ params }: Props) {
{ label: opp.title },
]}
/>
+
+
+
+
{/* Hero */}
diff --git a/src/app/areas/digital-human-rights/opportunity-spaces/page.tsx b/src/app/areas/digital-human-rights/opportunity-spaces/page.tsx
index 065b6479..c0621f95 100644
--- a/src/app/areas/digital-human-rights/opportunity-spaces/page.tsx
+++ b/src/app/areas/digital-human-rights/opportunity-spaces/page.tsx
@@ -2,13 +2,46 @@ import Link from 'next/link'
import type { Metadata } from 'next'
import Breadcrumb from '@/components/Breadcrumb'
import opportunityData from '@/data/fa2/dhr-opportunityspaces.json'
+import { fetchOpportunitySpaces } from '@/lib/indexer'
+
+type OpportunityCard = {
+ id: string
+ title: string
+ tagline?: string
+ image?: string
+ description: string
+ subfields: string[]
+}
+
+async function loadCards(): Promise
{
+ const remote = await fetchOpportunitySpaces('digital-human-rights')
+ if (remote.length > 0) {
+ return remote.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline ?? '',
+ image: o.image ?? opportunityData.opportunities.find((s) => s.id === o.id)?.image ?? '',
+ description: o.description,
+ subfields: o.subfields ?? [],
+ }))
+ }
+ return opportunityData.opportunities.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline,
+ image: o.image,
+ description: o.description,
+ subfields: o.subfields,
+ }))
+}
export const metadata: Metadata = {
title: 'Opportunity Spaces – Digital Human Rights',
description: 'Four strategic convergence zones where PL R&D can catalyze the most durable structural change in digital human rights.',
}
-export default function OpportunitySpacesPage() {
+export default async function OpportunitySpacesPage() {
+ const opportunities = await loadCards()
return (
- {opportunityData.opportunities.map((opp) => (
+ {opportunities.map((opp) => (
+ Loading…
+
+ )
+ }
+
+ return (
+
+
+
+ {/* Hero */}
+
+
+
+
+ set('hero', 'title', v)}
+ placeholder="Economies & Governance"
+ className="text-2xl lg:text-[44px] font-semibold leading-[1.1] tracking-tight max-w-xl"
+ />
+
+
+
+
+ set('hero', 'subtitle', v)}
+ multiline
+ placeholder="Summary description"
+ className="text-lg text-gray-600 leading-relaxed max-w-2xl"
+ />
+
+
+
+
+
+
+
+ {/* Content body */}
+
+
set('body', 'body', v)}
+ multiline
+ placeholder="Main description of this focus area"
+ className="text-base text-gray-700 leading-relaxed max-w-3xl"
+ />
+ Paragraphs separated by blank lines
+
+
+ {/* Explore */}
+
Explore
+
+
+
+
+
+
+
+
+ {/* Grants */}
+
+
+ {/* How to Engage */}
+
+
+
+
+
+ )
+}
+
+// ── Sub-components ────────────────────────────────────────────────────────────
+
+function ExploreCardEdit({
+ sectionId, label, href, get, set,
+}: {
+ sectionId: string
+ label: string
+ href: string
+ get: (s: string, f: 'title' | 'subtitle' | 'body' | 'label') => string
+ set: (s: string, f: 'title' | 'subtitle' | 'body' | 'label', v: string) => void
+}) {
+ return (
+
+
{label}
+
set(sectionId, 'title', v)}
+ placeholder="Card title"
+ className="text-base font-medium text-black mb-1"
+ />
+ set(sectionId, 'subtitle', v)}
+ placeholder="Card description"
+ className="text-sm text-gray-500 leading-relaxed"
+ />
+ View →
+
+ )
+}
+
+function EngageItemEdit({
+ sectionId, get, set,
+}: {
+ sectionId: string
+ get: (s: string, f: 'title' | 'subtitle' | 'body' | 'label') => string
+ set: (s: string, f: 'title' | 'subtitle' | 'body' | 'label', v: string) => void
+}) {
+ return (
+
+ set(sectionId, 'title', v)}
+ placeholder="Engagement item title"
+ className="text-base font-medium text-black mb-1"
+ />
+ set(sectionId, 'body', v)}
+ multiline
+ placeholder="Description"
+ className="text-sm text-gray-500 leading-relaxed"
+ />
+
+ )
+}
+
+function HexagonIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/areas/economies-governance/impact/page.tsx b/src/app/areas/economies-governance/impact/page.tsx
index 9019deb5..c4de9d97 100644
--- a/src/app/areas/economies-governance/impact/page.tsx
+++ b/src/app/areas/economies-governance/impact/page.tsx
@@ -1,13 +1,21 @@
import Link from 'next/link'
import type { Metadata } from 'next'
+import EditPageButton from '@/components/EditPageButton'
+import { PageEditHistoryByline } from '@/components/EditHistoryByline'
import Breadcrumb from '@/components/Breadcrumb'
+import { fetchPage, getSection } from '@/lib/indexer'
export const metadata: Metadata = {
title: 'Impact',
description: 'Track the impact of decentralized economies and governance initiatives.',
}
-export default function ImpactOverviewPage() {
+export default async function ImpactOverviewPage() {
+ const page = await fetchPage("area-eg-impact")
+ const heroSection = getSection(page, "hero")
+ const cardReport = getSection(page, "card-report-2025")
+ const cardDashboard = getSection(page, "card-live-dashboard")
+
return (
+
{/* Hero */}
- Impact
+ {heroSection?.title || "Impact"}
- Track ecosystem growth, measure outcomes, and explore the real-world impact of decentralized economies and governance.
+ {heroSection?.subtitle || "Track ecosystem growth, measure outcomes, and explore the real-world impact of decentralized economies and governance."}
@@ -37,10 +48,12 @@ export default function ImpactOverviewPage() {
-
Impact Report 2025
+
+ {cardReport?.title || "Impact Report 2025"}
+
- Annual report on ecosystem growth, key initiatives, and measurable outcomes.
+ {cardReport?.body || "Annual report on ecosystem growth, key initiatives, and measurable outcomes."}
@@ -57,10 +70,12 @@ export default function ImpactOverviewPage() {
- Live Dashboard
+
+ {cardDashboard?.title || "Live Dashboard"}
+
- Real-time metrics and data visualizations tracking ecosystem activity.
+ {cardDashboard?.body || "Real-time metrics and data visualizations tracking ecosystem activity."}
@@ -68,6 +83,7 @@ export default function ImpactOverviewPage() {
+
)
}
diff --git a/src/app/areas/economies-governance/impact/report-2025/page.tsx b/src/app/areas/economies-governance/impact/report-2025/page.tsx
index a2ee48f4..c3976ddb 100644
--- a/src/app/areas/economies-governance/impact/report-2025/page.tsx
+++ b/src/app/areas/economies-governance/impact/report-2025/page.tsx
@@ -77,6 +77,47 @@ export default function ImpactReport2025Page() {
+ {/* Co-Funders */}
+ {impactData.cofunders && impactData.cofunders.length > 0 && (
+
+
Co-Funders
+
+ {impactData.cofunders.map((cf) => (
+
+
+
+ {cf.arms.map((arm) => (
+
+ {arm.size}
+ {arm.name} — {arm.focus}
+
+ ))}
+
+
+ {cf.focus.map((f) => (
+ {f}
+ ))}
+
+
+ ))}
+
+
+ )}
+
{/* Ecosystem */}
Ecosystem Overview
diff --git a/src/app/areas/economies-governance/opportunity-spaces/[slug]/edit/page.tsx b/src/app/areas/economies-governance/opportunity-spaces/[slug]/edit/page.tsx
new file mode 100644
index 00000000..f95da1c2
--- /dev/null
+++ b/src/app/areas/economies-governance/opportunity-spaces/[slug]/edit/page.tsx
@@ -0,0 +1,17 @@
+'use client'
+
+import { use } from 'react'
+import OpportunitySpaceEditor from '@/components/OpportunitySpaceEditor'
+
+type Props = { params: Promise<{ slug: string }> }
+
+export default function EditOpportunitySpacePage({ params }: Props) {
+ const { slug } = use(params)
+ return (
+
+ )
+}
diff --git a/src/app/areas/economies-governance/opportunity-spaces/[slug]/page.tsx b/src/app/areas/economies-governance/opportunity-spaces/[slug]/page.tsx
index fe88abf9..66b2c4c5 100644
--- a/src/app/areas/economies-governance/opportunity-spaces/[slug]/page.tsx
+++ b/src/app/areas/economies-governance/opportunity-spaces/[slug]/page.tsx
@@ -2,21 +2,51 @@ import Link from 'next/link'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Breadcrumb from '@/components/Breadcrumb'
+import EditPageButton from '@/components/EditPageButton'
+import EditHistoryByline from '@/components/EditHistoryByline'
import opportunityData from '@/data/fa2/opportunityspaces.json'
+import { fetchOpportunitySpace } from '@/lib/indexer'
+import { ADMIN_DID, OPPORTUNITY_COLLECTION, opportunitySpaceRkey } from '@/lib/lexicons'
type Props = {
params: Promise<{ slug: string }>
}
+type Opportunity = (typeof opportunityData)['opportunities'][number]
+
export function generateStaticParams() {
return opportunityData.opportunities.map((opp) => ({
slug: opp.id,
}))
}
+async function loadOpp(slug: string): Promise
{
+ const staticOpp = opportunityData.opportunities.find((o) => o.id === slug)
+ const rkey = opportunitySpaceRkey('economies-governance', slug)
+ const remote = await fetchOpportunitySpace(rkey)
+ if (remote) {
+ return {
+ id: remote.id,
+ title: remote.title,
+ tagline: remote.tagline ?? '',
+ image: remote.image ?? staticOpp?.image ?? '',
+ description: remote.description,
+ inflectionPoint: remote.inflectionPoint ?? '',
+ shift: remote.shift ?? '',
+ theOpportunity: remote.theOpportunity ?? '',
+ subfields: remote.subfields ?? [],
+ tippingSignals: remote.tippingSignals ?? [],
+ keyAssumptions: remote.keyAssumptions ?? [],
+ observations: remote.observations ?? [],
+ fieldSignals: remote.fieldSignals ?? [],
+ } as Opportunity
+ }
+ return staticOpp ?? null
+}
+
export async function generateMetadata({ params }: Props): Promise {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) return { title: 'Not Found' }
return {
title: opp.title,
@@ -26,8 +56,9 @@ export async function generateMetadata({ params }: Props): Promise {
export default async function OpportunityDetailPage({ params }: Props) {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) notFound()
+ const rkey = opportunitySpaceRkey('economies-governance', slug)
return (
@@ -39,6 +70,15 @@ export default async function OpportunityDetailPage({ params }: Props) {
{ label: opp.title },
]}
/>
+
+
+
+
{/* Hero */}
diff --git a/src/app/areas/economies-governance/opportunity-spaces/page.tsx b/src/app/areas/economies-governance/opportunity-spaces/page.tsx
index d22e51f9..66ff87a1 100644
--- a/src/app/areas/economies-governance/opportunity-spaces/page.tsx
+++ b/src/app/areas/economies-governance/opportunity-spaces/page.tsx
@@ -2,13 +2,46 @@ import Link from 'next/link'
import type { Metadata } from 'next'
import Breadcrumb from '@/components/Breadcrumb'
import opportunityData from '@/data/fa2/opportunityspaces.json'
+import { fetchOpportunitySpaces } from '@/lib/indexer'
+
+type OpportunityCard = {
+ id: string
+ title: string
+ tagline?: string
+ image?: string
+ description: string
+ subfields: string[]
+}
+
+async function loadCards(): Promise
{
+ const remote = await fetchOpportunitySpaces('economies-governance')
+ if (remote.length > 0) {
+ return remote.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline ?? '',
+ image: o.image ?? opportunityData.opportunities.find((s) => s.id === o.id)?.image ?? '',
+ description: o.description,
+ subfields: o.subfields ?? [],
+ }))
+ }
+ return opportunityData.opportunities.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline,
+ image: o.image,
+ description: o.description,
+ subfields: o.subfields,
+ }))
+}
export const metadata: Metadata = {
title: 'Opportunity Spaces',
description: 'Convergence zones where multiple subfields unite to create transformative opportunities.',
}
-export default function OpportunitySpacesPage() {
+export default async function OpportunitySpacesPage() {
+ const opportunities = await loadCards()
return (
- {opportunityData.opportunities.map((opp) => (
+ {opportunities.map((opp) => (
+
{/* Hero */}
{/* Background image - rotated hexagon clip */}
@@ -63,7 +92,7 @@ export default function FA2MainPage() {
- Building crypto-native economic and governance infrastructure to create more efficient, equitable structures that coordinate at the scale of nation-states.
+ {heroSection?.subtitle || "Building crypto-native economic and governance infrastructure to create more efficient, equitable structures that coordinate at the scale of nation-states."}
-
- This focus area rectifies the inadequacies of current macro systems, which often struggle to coordinate and solve monumental challenges like climate change. By leveraging cryptoeconomics and improved governance tools, we are rethinking how capital is formed and deployed.
-
-
- This movement harnesses mechanism design to align millions of people worldwide toward shared goals, creating structures that can allocate resources at the scale of nation-states for the benefit of all humanity.
-
+ {bodyParagraphs.map((para, i) => (
+
+ {para}
+
+ ))}
{/* Explore */}
@@ -109,32 +137,32 @@ export default function FA2MainPage() {
@@ -161,24 +189,24 @@ export default function FA2MainPage() {
We are actively seeking:
+
)
}
diff --git a/src/app/areas/economies-governance/subareas/page.tsx b/src/app/areas/economies-governance/subareas/page.tsx
index c93be5b6..122dd8b8 100644
--- a/src/app/areas/economies-governance/subareas/page.tsx
+++ b/src/app/areas/economies-governance/subareas/page.tsx
@@ -1,5 +1,8 @@
import type { Metadata } from 'next'
+import EditPageButton from '@/components/EditPageButton'
+import { PageEditHistoryByline } from '@/components/EditHistoryByline'
import Breadcrumb from '@/components/Breadcrumb'
+import { fetchPage, getSection } from '@/lib/indexer'
export const metadata: Metadata = {
title: 'Subareas',
@@ -8,7 +11,7 @@ export const metadata: Metadata = {
type SubareaIconType = 'dpi' | 'desci' | 'depin' | 'pgf' | 'defi' | 'degov' | 'network-states' | 'nations' | 'refi'
-const subareas: { title: string; tagline: string; description: string; icon: SubareaIconType }[] = [
+const HARDCODED_SUBAREAS: { title: string; tagline: string; description: string; icon: SubareaIconType }[] = [
{
title: '(Sovereign) DPI',
tagline: 'Digital Public Infrastructure',
@@ -65,7 +68,17 @@ const subareas: { title: string; tagline: string; description: string; icon: Sub
},
]
-export default function SubareasPage() {
+export default async function SubareasPage() {
+ const page = await fetchPage("area-eg-subareas")
+ const subareas = HARDCODED_SUBAREAS.map(sub => {
+ const section = getSection(page, sub.icon)
+ return {
+ ...sub,
+ title: section?.title || sub.title,
+ tagline: section?.subtitle || sub.tagline,
+ description: section?.body || sub.description,
+ }
+ })
return (
+
{/* Header */}
@@ -103,6 +119,7 @@ export default function SubareasPage() {
More subareas may emerge as the field grows. These subfields overlap and create new opportunity spaces at their convergence points.
+
)
}
diff --git a/src/app/areas/neurotech/opportunity-spaces/[slug]/edit/page.tsx b/src/app/areas/neurotech/opportunity-spaces/[slug]/edit/page.tsx
new file mode 100644
index 00000000..627748e0
--- /dev/null
+++ b/src/app/areas/neurotech/opportunity-spaces/[slug]/edit/page.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import { use } from 'react'
+import OpportunitySpaceEditor from '@/components/OpportunitySpaceEditor'
+
+type Props = { params: Promise<{ slug: string }> }
+
+export default function EditOpportunitySpacePage({ params }: Props) {
+ const { slug } = use(params)
+ return
+}
diff --git a/src/app/areas/neurotech/opportunity-spaces/[slug]/page.tsx b/src/app/areas/neurotech/opportunity-spaces/[slug]/page.tsx
index d8a102d9..96b1d908 100644
--- a/src/app/areas/neurotech/opportunity-spaces/[slug]/page.tsx
+++ b/src/app/areas/neurotech/opportunity-spaces/[slug]/page.tsx
@@ -1,21 +1,51 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Breadcrumb from '@/components/Breadcrumb'
+import EditPageButton from '@/components/EditPageButton'
+import EditHistoryByline from '@/components/EditHistoryByline'
import opportunityData from '@/data/fa2/neuro-opportunityspaces.json'
+import { fetchOpportunitySpace } from '@/lib/indexer'
+import { ADMIN_DID, OPPORTUNITY_COLLECTION, opportunitySpaceRkey } from '@/lib/lexicons'
type Props = {
params: Promise<{ slug: string }>
}
+type Opportunity = (typeof opportunityData)['opportunities'][number]
+
export function generateStaticParams() {
return opportunityData.opportunities.map((opp) => ({
slug: opp.id,
}))
}
+async function loadOpp(slug: string): Promise
{
+ const staticOpp = opportunityData.opportunities.find((o) => o.id === slug)
+ const rkey = opportunitySpaceRkey('neurotech', slug)
+ const remote = await fetchOpportunitySpace(rkey)
+ if (remote) {
+ return {
+ id: remote.id,
+ title: remote.title,
+ tagline: remote.tagline ?? '',
+ image: remote.image ?? staticOpp?.image ?? '',
+ description: remote.description,
+ inflectionPoint: remote.inflectionPoint ?? '',
+ shift: remote.shift ?? '',
+ theOpportunity: remote.theOpportunity ?? '',
+ subfields: remote.subfields ?? [],
+ tippingSignals: remote.tippingSignals ?? [],
+ keyAssumptions: remote.keyAssumptions ?? [],
+ observations: remote.observations ?? [],
+ fieldSignals: remote.fieldSignals ?? [],
+ } as Opportunity
+ }
+ return staticOpp ?? null
+}
+
export async function generateMetadata({ params }: Props): Promise {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) return { title: 'Not Found' }
return {
title: `${opp.title} – Neurotech`,
@@ -25,8 +55,9 @@ export async function generateMetadata({ params }: Props): Promise {
export default async function OpportunityDetailPage({ params }: Props) {
const { slug } = await params
- const opp = opportunityData.opportunities.find((o) => o.id === slug)
+ const opp = await loadOpp(slug)
if (!opp) notFound()
+ const rkey = opportunitySpaceRkey('neurotech', slug)
return (
@@ -38,6 +69,15 @@ export default async function OpportunityDetailPage({ params }: Props) {
{ label: opp.title },
]}
/>
+
+
+
+
{/* Hero */}
diff --git a/src/app/areas/neurotech/opportunity-spaces/page.tsx b/src/app/areas/neurotech/opportunity-spaces/page.tsx
index 31818cf1..aa216a01 100644
--- a/src/app/areas/neurotech/opportunity-spaces/page.tsx
+++ b/src/app/areas/neurotech/opportunity-spaces/page.tsx
@@ -2,13 +2,46 @@ import Link from 'next/link'
import type { Metadata } from 'next'
import Breadcrumb from '@/components/Breadcrumb'
import opportunityData from '@/data/fa2/neuro-opportunityspaces.json'
+import { fetchOpportunitySpaces } from '@/lib/indexer'
+
+type OpportunityCard = {
+ id: string
+ title: string
+ tagline?: string
+ image?: string
+ description: string
+ subfields: string[]
+}
+
+async function loadCards(): Promise
{
+ const remote = await fetchOpportunitySpaces('neurotech')
+ if (remote.length > 0) {
+ return remote.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline ?? '',
+ image: o.image ?? opportunityData.opportunities.find((s) => s.id === o.id)?.image ?? '',
+ description: o.description,
+ subfields: o.subfields ?? [],
+ }))
+ }
+ return opportunityData.opportunities.map((o) => ({
+ id: o.id,
+ title: o.title,
+ tagline: o.tagline,
+ image: o.image,
+ description: o.description,
+ subfields: o.subfields,
+ }))
+}
export const metadata: Metadata = {
title: 'Opportunity Spaces – Neurotech',
description: 'Three convergence zones where PL Neuro can accelerate the integration of minds and machines.',
}
-export default function OpportunitySpacesPage() {
+export default async function OpportunitySpacesPage() {
+ const opportunities = await loadCards()
return (
- {opportunityData.opportunities.map((opp) => (
+ {opportunities.map((opp) => (
= {
'neurotech': 'brain',
}
-export default function AreasPage() {
+export default async function AreasPage() {
+ const page = await fetchPage("areas")
+ const heroSection = getSection(page, "hero")
return (
+
{/* Hero */}
@@ -27,7 +35,7 @@ export default function AreasPage() {
Focus Areas
- Four research directions driving breakthroughs in computing, coordination, and human capability.
+ {heroSection?.subtitle || "Four research directions driving breakthroughs in computing, coordination, and human capability."}
@@ -54,6 +62,7 @@ export default function AreasPage() {
))}
+
)
}
diff --git a/src/app/blog/AtprotoFeed.tsx b/src/app/blog/AtprotoFeed.tsx
deleted file mode 100644
index 68a1bc53..00000000
--- a/src/app/blog/AtprotoFeed.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-'use client'
-
-import { useState, useEffect } from 'react'
-import Link from 'next/link'
-import { useAuth } from '@/lib/atproto'
-import { formatDate } from '@/lib/format'
-import type { PostEntry } from '@/lib/lexicons'
-
-export default function AtprotoFeed() {
- const { isAuthenticated } = useAuth()
- const [posts, setPosts] = useState([])
- const [isLoading, setIsLoading] = useState(true)
-
- useEffect(() => {
- async function fetchPosts() {
- try {
- const response = await fetch('/api/feed')
- if (response.ok) {
- const data = await response.json()
- setPosts(data.posts || [])
- }
- } catch {
- // ATProto feed unavailable — silent fallback
- } finally {
- setIsLoading(false)
- }
- }
- fetchPosts()
- }, [])
-
- // Don't render anything if ATProto has no posts and isn't loading
- if (!isLoading && posts.length === 0 && !isAuthenticated) return null
-
- return (
-
- {isAuthenticated && (
-
- New entry →
-
- )}
-
- {isLoading ? (
-
Loading feed...
- ) : posts.length > 0 ? (
- <>
-
Community Feed
-
- {posts.map((post) => (
-
-
-
- {post.record.postType}
-
-
- {formatDate(post.record.createdAt)}
-
-
-
- {post.record.title}
-
- {post.record.summary && (
-
{post.record.summary}
- )}
-
- {post.author.avatar && (
-
- )}
-
- {post.author.displayName || post.author.handle}
-
- {post.record.venue && (
-
· {post.record.venue}
- )}
-
-
- ))}
-
- >
- ) : null}
-
- )
-}
diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx
index 49ae4de9..3e28a796 100644
--- a/src/app/blog/page.tsx
+++ b/src/app/blog/page.tsx
@@ -2,9 +2,10 @@ import Link from 'next/link'
import { blogPosts } from '@/lib/content'
import { formatDate } from '@/lib/format'
import Breadcrumb from '@/components/Breadcrumb'
-import AtprotoFeed from './AtprotoFeed'
+import { fetchAtproPosts } from '@/lib/indexer'
-export default function BlogPage() {
+export default async function BlogPage() {
+ const atprotoPosts = await fetchAtproPosts()
return (
@@ -53,8 +54,33 @@ export default function BlogPage() {
)}
- {/* ATProto feed (loads client-side when available) */}
-
+ {atprotoPosts.length > 0 && (
+
+ {atprotoPosts.map((post) => {
+ const href = post.path || `/blog/${post.rkey}`
+ const tag = post.tags?.find(t => t !== "blog") || "blog"
+ return (
+
+
+ PL R&D
+ {formatDate(post.publishedAt)}
+ {tag !== "blog" && {tag} }
+
+
+ {post.title}
+
+ {post.description && (
+
{post.description}
+ )}
+
+ )
+ })}
+
+ )}
+
)
}
diff --git a/src/app/edit/page.tsx b/src/app/edit/page.tsx
new file mode 100644
index 00000000..2617d773
--- /dev/null
+++ b/src/app/edit/page.tsx
@@ -0,0 +1,259 @@
+'use client'
+
+import Link from 'next/link'
+import { AreaIcon } from '@/components/AreaIcons'
+import {
+ useRequireAdmin,
+ usePageEdit,
+ EditableField,
+ EditBar,
+ EditBarSpacer,
+} from '@/components/InlineEdit'
+
+export default function LandingEditPage() {
+ const { ready } = useRequireAdmin()
+ const { get, set, isDirty, isLoading, isSaving, saveStatus, save, discard } =
+ usePageEdit('landing')
+
+ if (!ready || isLoading) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ return (
+
+ {/* Hero */}
+
+ {/* Hero banner image - extends to screen edge */}
+
+
+
+ set('hero', 'title', v)}
+ placeholder="Driving R&D breakthroughs to push humanity forward."
+ className="font-serif text-[36px] md:text-[52px] lg:text-[64px] font-normal leading-[1.1] tracking-tight"
+ />
+
+
+
+
+ About us
+
+
+
+
+
+ Focus areas
+
+
+
+
+
+
+
+
+
+ {/* R&D approach section */}
+
+
+
+
+ set('approach', 'title', v)}
+ placeholder="Use-inspired research across four frontiers"
+ className="text-[28px] md:text-[36px] font-normal leading-tight tracking-tight"
+ />
+
+
+ set('approach', 'body', v)}
+ multiline
+ placeholder="We work in Pasteur's Quadrant — pursuing fundamental understanding while staying anchored to real-world impact."
+ className="text-base text-gray-600 leading-relaxed"
+ />
+
+
+
+
+ {/* Digital Human Rights */}
+
+
+
+
+ set('approach-dhr', 'title', v)}
+ placeholder="Digital Human Rights"
+ className="text-sm font-medium text-black"
+ />
+
+
set('approach-dhr', 'subtitle', v)}
+ multiline
+ placeholder="Building decentralized infrastructure that enshrines freedom and safety in the digital age."
+ className="text-sm text-gray-500 leading-relaxed"
+ />
+
+
+
+
+
+
+ {/* Economies & Governance */}
+
+
+
+
+ set('approach-eg', 'title', v)}
+ placeholder="Economies & Governance"
+ className="text-sm font-medium text-black"
+ />
+
+
set('approach-eg', 'subtitle', v)}
+ multiline
+ placeholder="Crypto-native tools for more efficient, equitable coordination at global scale."
+ className="text-sm text-gray-500 leading-relaxed"
+ />
+
+
+
+
+
+
+ {/* AI & Robotics */}
+
+
+
+
+ set('approach-ai', 'title', v)}
+ placeholder="AI & Robotics"
+ className="text-sm font-medium text-black"
+ />
+
+
set('approach-ai', 'subtitle', v)}
+ multiline
+ placeholder="Responsible advancement in AGI, robotics, and immersive technologies that reshape how we interact with the world."
+ className="text-sm text-gray-500 leading-relaxed"
+ />
+
+
+
+
+
+
+ {/* Neurotechnology */}
+
+
+
+
+ set('approach-neuro', 'title', v)}
+ placeholder="Neurotechnology"
+ className="text-sm font-medium text-black"
+ />
+
+
set('approach-neuro', 'subtitle', v)}
+ multiline
+ placeholder="Accelerating brain-computer interfaces and NeuroAI to expand human cognition and treat brain disorders."
+ className="text-sm text-gray-500 leading-relaxed"
+ />
+
+
+
+
+
+
+
+
+
+ {/* Team */}
+
+
Team
+
+ set('team', 'body', v)}
+ multiline
+ placeholder="A fully remote team distributed across the globe, working with talented and intellectually curious people who share a passion for improving technology for humanity."
+ className="text-lg text-gray-700 leading-relaxed max-w-2xl"
+ />
+
+
+ Meet the team
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index a69c0bad..2b05243c 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -5,10 +5,17 @@
&::-webkit-scrollbar { display: none; }
}
+@font-face {
+ font-family: 'Aileron';
+ src: url('/fonts/Aileron-Regular.woff2') format('woff2');
+ font-weight: 400;
+ font-style: normal;
+}
+
/* ===== Theme (Tailwind v4 CSS-first config) ===== */
@theme {
- --font-body: var(--font-inter), sans-serif;
- --font-serif: var(--font-serif), Georgia, "Times New Roman", serif;
+ --font-family-body: 'Aileron', sans-serif;
+ --font-family-serif: 'Newsreader', Georgia, "Times New Roman", serif;
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
@@ -53,7 +60,8 @@ html {
}
h1, h2, h3, h4, h5, h6 {
- font-family: var(--font-serif);
+ font-family: var(--font-newsreader);
+ font-weight: 400;
}
p + ul, p + ol, p + blockquote {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e893845b..501e4d3e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,19 +1,31 @@
import type { Metadata } from 'next'
-import { Inter, Newsreader } from 'next/font/google'
+import localFont from 'next/font/local'
+import { Newsreader } from 'next/font/google'
import { siteConfig } from '@/lib/site-config'
import { AuthProvider } from '@/lib/atproto'
import SiteShell from '@/components/SiteShell'
import './globals.css'
-const inter = Inter({
- subsets: ['latin'],
- variable: '--font-inter',
+const aileron = localFont({
+ src: [
+ {
+ path: '../../public/fonts/Aileron-Regular.woff2',
+ weight: '400',
+ style: 'normal',
+ },
+ {
+ path: '../../public/fonts/Aileron-Bold.woff2',
+ weight: '700',
+ style: 'normal',
+ },
+ ],
+ variable: '--font-aileron',
})
const newsreader = Newsreader({
subsets: ['latin'],
weight: '400',
- variable: '--font-serif',
+ variable: '--font-newsreader',
})
export const metadata: Metadata = {
@@ -50,11 +62,11 @@ export default function RootLayout({
}) {
return (
-
+
{children}
)
-}
+}
\ No newline at end of file
diff --git a/src/app/outreach/collaboration/page.tsx b/src/app/outreach/collaboration/page.tsx
index 0b60071d..0ed6c489 100644
--- a/src/app/outreach/collaboration/page.tsx
+++ b/src/app/outreach/collaboration/page.tsx
@@ -1,35 +1,53 @@
import type { Metadata } from 'next'
+import EditPageButton from '@/components/EditPageButton'
+import { PageEditHistoryByline } from '@/components/EditHistoryByline'
import Breadcrumb from '@/components/Breadcrumb'
+import { fetchPage, getSection } from '@/lib/indexer'
export const metadata: Metadata = {
title: 'Collaborate',
description: 'Partner with PL R&D to advance the frontiers of computing and build infrastructure for humanity.',
}
-export default function CollaborationPage() {
+export default async function CollaborationPage() {
+ const page = await fetchPage("collaborate")
+ const heroSection = getSection(page, "hero")
+ const bodySection = getSection(page, "body")
+
+ const heroTitle = heroSection?.title || "Collaborate With Us"
+ const heroBody = heroSection?.subtitle || "We believe in open collaboration. Whether you're a researcher, developer, institution, or visionary builder, there are many ways to work together on problems that matter."
+ const bodyParagraphs = bodySection?.body
+ ? bodySection.body.split("\n\n").filter(Boolean)
+ : [
+ "PL R&D works at the intersection of fundamental research and real-world impact. We're building the infrastructure for a better internet and exploring how technology can help humanity flourish.",
+ "Our collaborative approach brings together researchers, engineers, and institutions worldwide. We're always looking for partners who share our vision of open, decentralized, and human-centric technology.",
+ ]
+
return (
+
{/* Hero */}
- Collaborate With Us
+ {heroTitle}
- We believe in open collaboration. Whether you're a researcher, developer, institution, or visionary builder, there are many ways to work together on problems that matter.
+ {heroBody}
{/* Content */}
-
- PL R&D works at the intersection of fundamental research and real-world impact. We're building the infrastructure for a better internet and exploring how technology can help humanity flourish.
-
-
- Our collaborative approach brings together researchers, engineers, and institutions worldwide. We're always looking for partners who share our vision of open, decentralized, and human-centric technology.
-
+ {bodyParagraphs.map((para, i) => (
+
+ {para}
+
+ ))}
{/* CTA */}
@@ -45,6 +63,7 @@ export default function CollaborationPage() {
+
)
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 324abf18..365b0f2b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,8 +1,10 @@
import Link from 'next/link'
+import EditPageButton from '@/components/EditPageButton'
import { publications, talks, blogPosts } from '@/lib/content'
import { formatDate } from '@/lib/format'
import { AreaIcon } from '@/components/AreaIcons'
import { GeoIllustration } from '@/components/GeoIllustration'
+import { fetchPage, getSection, getSectionsWithPrefix } from "@/lib/indexer"
type UpdateItem = {
title: string
@@ -51,9 +53,18 @@ function CardIllustration({ slug, areas }: { slug: string; areas: string[] }) {
}
-export default function HomePage() {
+export default async function HomePage() {
const updates = getLatestUpdates(8)
+ const page = await fetchPage("landing")
+ const hero = getSection(page, "hero")
+ const approach = getSection(page, "approach")
+ const dhr = getSection(page, "approach-dhr")
+ const eg = getSection(page, "approach-eg")
+ const ai = getSection(page, "approach-ai")
+ const neuro = getSection(page, "approach-neuro")
+ const team = getSection(page, "team")
+
return (
@@ -73,8 +84,8 @@ export default function HomePage() {
aria-hidden="true"
/>
-
- Driving R&D breakthroughs to push humanity forward.
+
+ {hero?.title || "Driving R&D breakthroughs to push humanity forward."}
- Use-inspired research across four frontiers
+ {approach?.title || "Use-inspired research across four frontiers"}
- We work in Pasteur's Quadrant — pursuing fundamental understanding while staying anchored to real-world impact. Our four focus areas span the most consequential frontiers in computing, society, and human cognition.
+ {approach?.body || "We work in Pasteur's Quadrant — pursuing fundamental understanding while staying anchored to real-world impact. Our four focus areas span the most consequential frontiers in computing, society, and human cognition."}
@@ -119,8 +130,8 @@ export default function HomePage() {
-
Digital Human Rights
-
Building decentralized infrastructure that enshrines freedom and safety in the digital age.
+
{dhr?.title || "Digital Human Rights"}
+
{dhr?.subtitle || "Building decentralized infrastructure that enshrines freedom and safety in the digital age."}
@@ -129,8 +140,8 @@ export default function HomePage() {
-
Economies & Governance
-
Crypto-native tools for more efficient, equitable coordination at global scale.
+
{eg?.title || "Economies & Governance"}
+
{eg?.subtitle || "Crypto-native tools for more efficient, equitable coordination at global scale."}
@@ -139,8 +150,8 @@ export default function HomePage() {
-
AI & Robotics
-
Responsible advancement in AGI, robotics, and immersive technologies that reshape how we interact with the world.
+
{ai?.title || "AI & Robotics"}
+
{ai?.subtitle || "Responsible advancement in AGI, robotics, and immersive technologies that reshape how we interact with the world."}
@@ -149,8 +160,8 @@ export default function HomePage() {
-
Neurotechnology
-
Accelerating brain-computer interfaces and NeuroAI to expand human cognition and treat brain disorders.
+
{neuro?.title || "Neurotechnology"}
+
{neuro?.subtitle || "Accelerating brain-computer interfaces and NeuroAI to expand human cognition and treat brain disorders."}
@@ -227,7 +238,7 @@ export default function HomePage() {
Team
- A fully remote team distributed across the globe, working with talented and intellectually curious people who share a passion for improving technology for humanity.
+ {team?.body || "A fully remote team distributed across the globe, working with talented and intellectually curious people who share a passion for improving technology for humanity."}
+
)
}
-
-
diff --git a/src/components/EditHistoryByline.tsx b/src/components/EditHistoryByline.tsx
new file mode 100644
index 00000000..6042594a
--- /dev/null
+++ b/src/components/EditHistoryByline.tsx
@@ -0,0 +1,318 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { formatDate } from '@/lib/format'
+import { ADMIN_DID, OPPORTUNITY_COLLECTION, PAGE_COLLECTION } from '@/lib/lexicons'
+
+type EditEvent = {
+ uri?: string
+ target: string
+ editor: string
+ editorHandle?: string | null
+ changedFields?: string[] | null
+ editedAt: string
+ note?: string | null
+}
+
+type Profile = {
+ did: string
+ handle: string
+ displayName?: string
+ avatar?: string
+}
+
+/**
+ * Small inline byline for a content record: shows the latest editor + relative
+ * time, plus a "View history (N)" control that opens a full-log drawer.
+ *
+ * Mounted on public detail pages and on edit pages. Fetches from
+ * /api/edit-history?target= on mount. Swallows errors — if the indexer
+ * is down, the byline just doesn't render.
+ */
+export default function EditHistoryByline({ targetUri }: { targetUri: string }) {
+ const [events, setEvents] = useState(null)
+ const [profiles, setProfiles] = useState>({})
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ fetch(`/api/edit-history?target=${encodeURIComponent(targetUri)}`)
+ .then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
+ .then((data: { events: EditEvent[] }) => {
+ if (!cancelled) setEvents(data.events ?? [])
+ })
+ .catch(() => {
+ if (!cancelled) setEvents([])
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [targetUri])
+
+ // Hydrate profiles (avatar/displayName) from public Bluesky API as events arrive.
+ useEffect(() => {
+ if (!events || events.length === 0) return
+ const dids = Array.from(new Set(events.map((e) => e.editor))).filter(
+ (d) => !profiles[d],
+ )
+ if (dids.length === 0) return
+ Promise.all(
+ dids.map((did) =>
+ fetch(
+ `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
+ )
+ .then((r) => (r.ok ? r.json() : null))
+ .then((p) =>
+ p
+ ? ({
+ did,
+ handle: p.handle,
+ displayName: p.displayName,
+ avatar: p.avatar,
+ } as Profile)
+ : null,
+ )
+ .catch(() => null),
+ ),
+ ).then((results) => {
+ const next = { ...profiles }
+ for (const p of results) {
+ if (p) next[p.did] = p
+ }
+ setProfiles(next)
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [events])
+
+ // Close on Escape.
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setOpen(false)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [open])
+
+ if (!events || events.length === 0) return null
+
+ const latest = events[0]
+ const latestProfile = profiles[latest.editor]
+
+ return (
+ <>
+
+ {latestProfile?.avatar ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
Last edited by
+
+
·
+
+
·
+
setOpen(true)}
+ className="text-blue hover:underline"
+ >
+ View history ({events.length})
+
+
+
+ {open && (
+ setOpen(false)}
+ />
+ )}
+ >
+ )
+}
+
+/** Convenience wrapper: byline for an org.plresearch.page record by rkey. */
+export function PageEditHistoryByline({ rkey }: { rkey: string }) {
+ return (
+
+ )
+}
+
+/** Convenience wrapper: byline for an opportunity-space record by rkey. */
+export function OpportunitySpaceEditHistoryByline({ rkey }: { rkey: string }) {
+ return (
+
+ )
+}
+
+function HistoryDrawer({
+ events,
+ profiles,
+ onClose,
+}: {
+ events: EditEvent[]
+ profiles: Record
+ onClose: () => void
+}) {
+ // Lock body scroll while the drawer is open.
+ useEffect(() => {
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.body.style.overflow = prev
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+
Edit history
+
{events.length} edits
+
+
+ ×
+
+
+
+ {events.map((e) => {
+ const p = profiles[e.editor]
+ const changed = (e.changedFields ?? []).filter(Boolean)
+ return (
+
+
+ {p?.avatar ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
+
+ ·
+
+
+ {changed.length > 0 ? (
+
+ changed:{' '}
+ {changed.map((f, i) => (
+
+
+ {f}
+
+ {i < changed.length - 1 ? ' · ' : ''}
+
+ ))}
+
+ ) : (
+
+ No tracked field changes
+
+ )}
+ {e.note && (
+
+ “{e.note}”
+
+ )}
+
+
+
+ )
+ })}
+
+
+ Each entry is an org.plresearch.editEvent record on the editor’s own
+ ATProto PDS, indexed live. This page is the source of truth.
+
+
+
+ )
+}
+
+function HandleLink({
+ did,
+ profile,
+ fallback,
+}: {
+ did: string
+ profile?: Profile
+ fallback?: string | null
+}) {
+ const handle = profile?.handle ?? fallback ?? did.slice(0, 20) + '…'
+ const label = profile?.displayName ? `${profile.displayName}` : `@${handle}`
+ return (
+
+ {label}
+
+ )
+}
+
+function RelativeTime({ iso, title }: { iso: string; title?: string }) {
+ const [label, setLabel] = useState(() => relative(iso))
+ useEffect(() => {
+ const interval = setInterval(() => setLabel(relative(iso)), 60_000)
+ return () => clearInterval(interval)
+ }, [iso])
+ return (
+
+ {label}
+
+ )
+}
+
+function relative(iso: string): string {
+ const then = new Date(iso).getTime()
+ if (!Number.isFinite(then)) return ''
+ const diff = Date.now() - then
+ const sec = Math.floor(diff / 1000)
+ if (sec < 60) return `${sec}s ago`
+ const min = Math.floor(sec / 60)
+ if (min < 60) return `${min} min ago`
+ const hr = Math.floor(min / 60)
+ if (hr < 24) return `${hr}h ago`
+ const day = Math.floor(hr / 24)
+ if (day < 30) return `${day}d ago`
+ return formatDate(iso)
+}
+
+function formatAbsolute(iso: string): string {
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return iso
+ return d.toLocaleString()
+}
diff --git a/src/components/EditPageButton.tsx b/src/components/EditPageButton.tsx
new file mode 100644
index 00000000..66534b18
--- /dev/null
+++ b/src/components/EditPageButton.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import Link from 'next/link'
+import { useAuth } from '@/lib/atproto'
+
+const EDIT_ROUTES: Record = {
+ landing: '/edit',
+ about: '/about/edit',
+ areas: '/areas/edit',
+ collaborate: '/outreach/collaboration/edit',
+ 'area-ai-robotics': '/areas/ai-robotics/edit',
+ 'area-digital-human-rights': '/areas/digital-human-rights/edit',
+ 'area-neurotech': '/areas/neurotech/edit',
+ 'area-economies-governance': '/areas/economies-governance/edit',
+ 'area-eg-subareas': '/areas/economies-governance/subareas/edit',
+ 'area-eg-impact': '/areas/economies-governance/impact/edit',
+}
+
+/**
+ * Edit button — only visible to admin users.
+ * Positioned top-right of the page content area.
+ * Links to the inline edit route for the given page rkey.
+ *
+ * If `href` is provided, it overrides the EDIT_ROUTES lookup — useful for
+ * record collections other than `org.plresearch.page` (e.g. opportunity
+ * spaces) that don't fit the rkey-based map.
+ */
+export default function EditPageButton({ rkey, href }: { rkey: string; href?: string }) {
+ const { isAuthenticated, isAdmin } = useAuth()
+
+ if (!isAuthenticated || !isAdmin) return null
+
+ return (
+
+
+
+
+ Edit page
+
+ )
+}
diff --git a/src/components/InlineEdit.tsx b/src/components/InlineEdit.tsx
new file mode 100644
index 00000000..fc4e4a84
--- /dev/null
+++ b/src/components/InlineEdit.tsx
@@ -0,0 +1,338 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useAuth } from '@/lib/atproto'
+import type { PageRecord, PageSection } from '@/lib/lexicons'
+
+// ---------------------------------------------------------------------------
+// useRequireAdmin
+// ---------------------------------------------------------------------------
+
+/**
+ * Redirects to /admin if not authenticated + admin.
+ * Returns { ready: true } only once authenticated + admin is confirmed.
+ */
+export function useRequireAdmin(): { ready: boolean } {
+ const { isAuthenticated, isAdmin, isLoading } = useAuth()
+ const router = useRouter()
+
+ useEffect(() => {
+ if (isLoading) return
+ if (!isAuthenticated || !isAdmin) {
+ router.replace('/admin')
+ }
+ }, [isLoading, isAuthenticated, isAdmin, router])
+
+ const ready = !isLoading && isAuthenticated && isAdmin
+ return { ready }
+}
+
+// ---------------------------------------------------------------------------
+// usePageEdit
+// ---------------------------------------------------------------------------
+
+type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
+type SectionField = keyof Omit
+
+export interface UsePageEditResult {
+ get: (sectionId: string, field: SectionField) => string
+ set: (sectionId: string, field: SectionField, value: string) => void
+ isDirty: boolean
+ isLoading: boolean
+ isSaving: boolean
+ saveStatus: SaveStatus
+ save: () => Promise
+ discard: () => void
+}
+
+/**
+ * Fetches GET /api/pages/:rkey, stores the PageRecord as state.
+ * Provides get/set to read/write individual section fields.
+ * save() PUTs the full updated PageRecord back.
+ */
+export function usePageEdit(rkey: string): UsePageEditResult {
+ const [original, setOriginal] = useState(null)
+ const [record, setRecord] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [saveStatus, setSaveStatus] = useState('idle')
+
+ // Fetch on mount
+ useEffect(() => {
+ let cancelled = false
+ setIsLoading(true)
+ fetch(`/api/pages/${rkey}`)
+ .then((res) => {
+ if (res.status === 404) {
+ // Page doesn't exist yet — create a skeleton record so the editor works
+ const skeleton: PageRecord = {
+ $type: 'org.plresearch.page',
+ pageId: rkey,
+ sections: [],
+ updatedAt: new Date().toISOString(),
+ }
+ if (!cancelled) {
+ setOriginal(skeleton)
+ setRecord(structuredClone(skeleton))
+ }
+ return undefined
+ }
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return res.json() as Promise<{ rkey: string; record: PageRecord }>
+ })
+ .then((data) => {
+ if (cancelled || !data) return
+ setOriginal(data.record)
+ setRecord(structuredClone(data.record))
+ })
+ .catch((err) => {
+ console.error('[usePageEdit] fetch error', err)
+ // Even on error, create a skeleton so the page is usable
+ if (!cancelled) {
+ const skeleton: PageRecord = {
+ $type: 'org.plresearch.page',
+ pageId: rkey,
+ sections: [],
+ updatedAt: new Date().toISOString(),
+ }
+ setOriginal(skeleton)
+ setRecord(structuredClone(skeleton))
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [rkey])
+
+ const get = useCallback(
+ (sectionId: string, field: SectionField): string => {
+ if (!record) return ''
+ const section = record.sections.find((s) => s.sectionId === sectionId)
+ return section?.[field] ?? ''
+ },
+ [record],
+ )
+
+ const set = useCallback(
+ (sectionId: string, field: SectionField, value: string) => {
+ setRecord((prev) => {
+ if (!prev) return prev
+ const exists = prev.sections.some((s) => s.sectionId === sectionId)
+ if (exists) {
+ const sections = prev.sections.map((s) =>
+ s.sectionId === sectionId ? { ...s, [field]: value } : s,
+ )
+ return { ...prev, sections }
+ } else {
+ // Create a new section when it doesn't exist yet
+ const newSection: PageSection = { sectionId, [field]: value }
+ return { ...prev, sections: [...prev.sections, newSection] }
+ }
+ })
+ },
+ [],
+ )
+
+ const isDirty =
+ record !== null &&
+ original !== null &&
+ JSON.stringify(record.sections) !== JSON.stringify(original.sections)
+
+ const save = useCallback(async () => {
+ if (!record) return
+ setSaveStatus('saving')
+ try {
+ const res = await fetch(`/api/pages/${rkey}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(record),
+ })
+ if (!res.ok) {
+ const msg = await res.text()
+ throw new Error(`HTTP ${res.status}: ${msg}`)
+ }
+ // Update original so isDirty resets
+ setOriginal(structuredClone(record))
+ setSaveStatus('saved')
+ // Auto-reset to idle after 2 s
+ setTimeout(() => setSaveStatus('idle'), 2000)
+ } catch (err) {
+ console.error('[usePageEdit] save error', err)
+ setSaveStatus('error')
+ }
+ }, [record, rkey])
+
+ const discard = useCallback(() => {
+ if (!original) return
+ setRecord(structuredClone(original))
+ setSaveStatus('idle')
+ }, [original])
+
+ return {
+ get,
+ set,
+ isDirty,
+ isLoading,
+ isSaving: saveStatus === 'saving',
+ saveStatus,
+ save,
+ discard,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// EditableField
+// ---------------------------------------------------------------------------
+
+/**
+ * A transparent, auto-growing textarea that blends into surrounding text.
+ * Pass all text-styling classes via `className`.
+ */
+export function EditableField({
+ value,
+ onChange,
+ className = '',
+ multiline = false,
+ placeholder = '',
+}: {
+ value: string
+ onChange: (v: string) => void
+ className?: string
+ multiline?: boolean
+ placeholder?: string
+}) {
+ const ref = useRef(null)
+
+ // Auto-grow whenever value changes
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ el.style.height = 'auto'
+ el.style.height = el.scrollHeight + 'px'
+ }, [value])
+
+ return (
+