From 567f6add29a544ee924d81448cf4491bdc51d283 Mon Sep 17 00:00:00 2001 From: daviddao Date: Thu, 26 Mar 2026 05:21:45 +0100 Subject: [PATCH 01/35] feat: wire all site pages to read content from Hyperindex (plresearch.org DID only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created src/lib/indexer.ts — typed GraphQL client for hi.gainforest.app - Landing page, about page, areas listing, collaborate page all fetch from indexer - E&G pages (main, subareas, impact) fetch from indexer - Focus area [slug] page fetches from indexer - API routes (GET /api/pages) switched to indexer reads - PUT /api/pages still writes to PDS via OAuth - All pages fall back to hardcoded content on fetch failure - ISR revalidation every 60 seconds --- .beads/interactions.jsonl | 1 + .beads/last-touched | 2 +- .env | 17 ++++ src/app/about/page.tsx | 56 +++++++------ src/app/api/pages/[rkey]/route.ts | 4 +- src/app/api/pages/route.ts | 8 +- src/app/areas/[slug]/page.tsx | 34 ++++++-- .../economies-governance/impact/page.tsx | 24 ++++-- src/app/areas/economies-governance/page.tsx | 79 ++++++++++++------- .../economies-governance/subareas/page.tsx | 15 +++- src/app/areas/page.tsx | 7 +- src/app/outreach/collaboration/page.tsx | 31 +++++--- src/app/page.tsx | 38 +++++---- 13 files changed, 213 insertions(+), 103 deletions(-) create mode 100644 .env diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e40fea54..4a80adb1 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -9,3 +9,4 @@ {"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"}} diff --git a/.beads/last-touched b/.beads/last-touched index c3b6b867..a9dfb05f 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -plresearch-wz0.1 +plresearch-wz0.2 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/src/app/about/page.tsx b/src/app/about/page.tsx index 58d04da0..0eae02ab 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -2,12 +2,27 @@ import type { Metadata } from 'next' 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 */} @@ -56,10 +71,10 @@ export default function AboutPage() {

- 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?.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 +146,17 @@ export default function AboutPage() {

- More innovation faster + {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,7 +164,7 @@ 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 +191,3 @@ function FocusCard({ slug, title, description }: { slug: string; title: string; ) } - - diff --git a/src/app/api/pages/[rkey]/route.ts b/src/app/api/pages/[rkey]/route.ts index f7ee3d88..3a392f20 100644 --- a/src/app/api/pages/[rkey]/route.ts +++ b/src/app/api/pages/[rkey]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { getPageRecord } from "@/lib/atproto-client" +import { fetchPage } from "@/lib/indexer" import { getAuthenticatedAgent } from "@/lib/agent" import { getSession } from "@/lib/session" import { ADMIN_DID, PAGE_COLLECTION } from "@/lib/lexicons" @@ -12,7 +12,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 }) } 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/areas/[slug]/page.tsx b/src/app/areas/[slug]/page.tsx index bdd2f0f8..dcd2be01 100644 --- a/src/app/areas/[slug]/page.tsx +++ b/src/app/areas/[slug]/page.tsx @@ -6,6 +6,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,6 +39,15 @@ 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) @@ -54,9 +64,9 @@ export default async function AreaPage({ params }: Props) { {stripFaPrefix(area.title)}
- {area.summary && ( + {summary && (

- {area.summary} + {summary}

)}
@@ -70,9 +80,9 @@ export default async function AreaPage({ params }: Props) {
- {area.leads.length > 0 && ( + {leads.length > 0 && (
- {area.leads.map((authorSlug) => ( + {leads.map((authorSlug) => ( ))}
@@ -80,11 +90,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 +102,17 @@ export default async function AreaPage({ params }: Props) { )} {/* Content */} - {area.html && ( + {(bodyFromIndexer || area.html) && (
-
+ {bodyFromIndexer ? ( +
+ {bodyFromIndexer.split('\n\n').map((para, i) => ( +

{para}

+ ))} +
+ ) : ( +
+ )}
)} diff --git a/src/app/areas/economies-governance/impact/page.tsx b/src/app/areas/economies-governance/impact/page.tsx index 9019deb5..dc5f04df 100644 --- a/src/app/areas/economies-governance/impact/page.tsx +++ b/src/app/areas/economies-governance/impact/page.tsx @@ -1,13 +1,19 @@ import Link from 'next/link' import type { Metadata } from 'next' 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 (

- 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 +43,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 +65,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."}

diff --git a/src/app/areas/economies-governance/page.tsx b/src/app/areas/economies-governance/page.tsx index fbdabff5..3ae751ec 100644 --- a/src/app/areas/economies-governance/page.tsx +++ b/src/app/areas/economies-governance/page.tsx @@ -3,13 +3,37 @@ import type { Metadata } from 'next' import AuthorCard from '@/components/AuthorCard' import Breadcrumb from '@/components/Breadcrumb' import FundingPipeline from '@/components/FundingPipeline' +import { fetchPage, getSection } from '@/lib/indexer' export const metadata: Metadata = { title: 'Economies & Governance', description: 'Creating more efficient and equitable structures for global progress through crypto-native infrastructure.', } -export default function FA2MainPage() { +export default async function FA2MainPage() { + const page = await fetchPage("area-economies-governance") + + const heroSection = getSection(page, "hero") + const bodySection = getSection(page, "body") + + const exploreSubareas = getSection(page, "explore-subareas") + const exploreOpportunities = getSection(page, "explore-opportunities") + const exploreImpact = getSection(page, "explore-impact") + const exploreProjects = getSection(page, "explore-projects") + const exploreDepgraph = getSection(page, "explore-depgraph") + + const engFeedback = getSection(page, "engage-feedback") + const engCases = getSection(page, "engage-cases") + const engPartners = getSection(page, "engage-partners") + const engTechnical = getSection(page, "engage-technical") + const engStrategic = getSection(page, "engage-strategic") + + const bodyParagraphs = bodySection?.body + ? bodySection.body.split("\n\n").filter(Boolean) + : [ + "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.", + ] return (
@@ -63,7 +87,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 +132,32 @@ export default function FA2MainPage() { @@ -161,24 +184,24 @@ export default function FA2MainPage() {

We are actively seeking:

{ + const section = getSection(page, sub.icon) + return { + ...sub, + title: section?.title || sub.title, + tagline: section?.subtitle || sub.tagline, + description: section?.body || sub.description, + } + }) return (
= { 'neurotech': 'brain', } -export default function AreasPage() { +export default async function AreasPage() { + const page = await fetchPage("areas") + const heroSection = getSection(page, "hero") return (
@@ -27,7 +30,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."}

diff --git a/src/app/outreach/collaboration/page.tsx b/src/app/outreach/collaboration/page.tsx index 0b60071d..878110c2 100644 --- a/src/app/outreach/collaboration/page.tsx +++ b/src/app/outreach/collaboration/page.tsx @@ -1,12 +1,26 @@ import type { Metadata } from 'next' 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 (
@@ -15,21 +29,20 @@ export default function CollaborationPage() {

- 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 */} diff --git a/src/app/page.tsx b/src/app/page.tsx index 324abf18..15c0928f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ 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 +52,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 (
@@ -74,7 +84,7 @@ export default function HomePage() { />

- 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 +129,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 +139,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 +149,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 +159,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 +237,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."}

) } - - From 8809bbcd29a1b4920d89ccd62f0f63490f1e023a Mon Sep 17 00:00:00 2001 From: daviddao Date: Thu, 26 Mar 2026 07:13:20 +0100 Subject: [PATCH 02/35] feat: add floating Edit button on all pages (admin-only), auto-select page in editor via ?page= param --- .beads/interactions.jsonl | 3 +++ src/app/about/page.tsx | 2 ++ src/app/areas/[slug]/page.tsx | 2 ++ .../economies-governance/impact/page.tsx | 2 ++ src/app/areas/economies-governance/page.tsx | 2 ++ .../economies-governance/subareas/page.tsx | 2 ++ src/app/areas/page.tsx | 2 ++ src/app/outreach/collaboration/page.tsx | 2 ++ src/app/page.tsx | 2 ++ src/components/EditPageButton.tsx | 27 +++++++++++++++++++ src/components/PageEditor.tsx | 19 ++++++++++--- 11 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/components/EditPageButton.tsx diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 4a80adb1..d4c54aee 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -10,3 +10,6 @@ {"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"}} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 0eae02ab..ee0d3b8c 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import EditPageButton from '@/components/EditPageButton' import Link from 'next/link' import AuthorCard from '@/components/AuthorCard' import Breadcrumb from '@/components/Breadcrumb' @@ -169,6 +170,7 @@ export default async function AboutPage() {
+
) } diff --git a/src/app/areas/[slug]/page.tsx b/src/app/areas/[slug]/page.tsx index dcd2be01..46889ca6 100644 --- a/src/app/areas/[slug]/page.tsx +++ b/src/app/areas/[slug]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import EditPageButton from '@/components/EditPageButton' import Link from 'next/link' import { notFound } from 'next/navigation' import { areas, publications, talks } from '@/lib/content' @@ -159,6 +160,7 @@ export default async function AreaPage({ params }: Props) {
)} +
) } diff --git a/src/app/areas/economies-governance/impact/page.tsx b/src/app/areas/economies-governance/impact/page.tsx index dc5f04df..d77aeee6 100644 --- a/src/app/areas/economies-governance/impact/page.tsx +++ b/src/app/areas/economies-governance/impact/page.tsx @@ -1,5 +1,6 @@ import Link from 'next/link' import type { Metadata } from 'next' +import EditPageButton from '@/components/EditPageButton' import Breadcrumb from '@/components/Breadcrumb' import { fetchPage, getSection } from '@/lib/indexer' @@ -78,6 +79,7 @@ export default async function ImpactOverviewPage() { + ) } diff --git a/src/app/areas/economies-governance/page.tsx b/src/app/areas/economies-governance/page.tsx index 3ae751ec..fb037067 100644 --- a/src/app/areas/economies-governance/page.tsx +++ b/src/app/areas/economies-governance/page.tsx @@ -1,5 +1,6 @@ import Link from 'next/link' import type { Metadata } from 'next' +import EditPageButton from '@/components/EditPageButton' import AuthorCard from '@/components/AuthorCard' import Breadcrumb from '@/components/Breadcrumb' import FundingPipeline from '@/components/FundingPipeline' @@ -216,6 +217,7 @@ export default async function FA2MainPage() { + ) } diff --git a/src/app/areas/economies-governance/subareas/page.tsx b/src/app/areas/economies-governance/subareas/page.tsx index aca937ee..9ee996d4 100644 --- a/src/app/areas/economies-governance/subareas/page.tsx +++ b/src/app/areas/economies-governance/subareas/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import EditPageButton from '@/components/EditPageButton' import Breadcrumb from '@/components/Breadcrumb' import { fetchPage, getSection } from '@/lib/indexer' @@ -114,6 +115,7 @@ export default async 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/page.tsx b/src/app/areas/page.tsx index ba3a95ec..6fa96ba8 100644 --- a/src/app/areas/page.tsx +++ b/src/app/areas/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import EditPageButton from '@/components/EditPageButton' import Link from 'next/link' import { areas } from '@/lib/content' import { stripFaPrefix } from '@/lib/format' @@ -57,6 +58,7 @@ export default async function AreasPage() { ))} + ) } diff --git a/src/app/outreach/collaboration/page.tsx b/src/app/outreach/collaboration/page.tsx index 878110c2..9fd5d01f 100644 --- a/src/app/outreach/collaboration/page.tsx +++ b/src/app/outreach/collaboration/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import EditPageButton from '@/components/EditPageButton' import Breadcrumb from '@/components/Breadcrumb' import { fetchPage, getSection } from '@/lib/indexer' @@ -58,6 +59,7 @@ export default async function CollaborationPage() { + ) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 15c0928f..c3e7d5fb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ 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' @@ -249,6 +250,7 @@ export default async function HomePage() { + ) } diff --git a/src/components/EditPageButton.tsx b/src/components/EditPageButton.tsx new file mode 100644 index 00000000..74bd0546 --- /dev/null +++ b/src/components/EditPageButton.tsx @@ -0,0 +1,27 @@ +'use client' + +import Link from 'next/link' +import { useAuth } from '@/lib/atproto' + +/** + * Floating edit button — only visible to admin users. + * Links to /admin where the PageEditor can edit the given page rkey. + */ +export default function EditPageButton({ rkey }: { rkey: string }) { + const { isAuthenticated, isAdmin } = useAuth() + + if (!isAuthenticated || !isAdmin) return null + + return ( + + + + + Edit + + ) +} diff --git a/src/components/PageEditor.tsx b/src/components/PageEditor.tsx index e6bec558..c3cd92a9 100644 --- a/src/components/PageEditor.tsx +++ b/src/components/PageEditor.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect } from "react" +import { useSearchParams } from "next/navigation" import type { PageRecord, PageSection } from "@/lib/lexicons" type PageEntry = { rkey: string; record: PageRecord } @@ -13,16 +14,28 @@ export default function PageEditor() { const [isSaving, setIsSaving] = useState(false) const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) - // Fetch all pages on mount + const searchParams = useSearchParams() + const autoSelectRkey = searchParams.get("page") + + // Fetch all pages on mount, auto-select if ?page= is set useEffect(() => { fetch("/api/pages") .then((r) => r.json()) .then((data) => { - setPages(data.pages || []) + const loaded = data.pages || [] + setPages(loaded) setIsLoading(false) + // Auto-select page from URL query param + if (autoSelectRkey && !selectedRkey) { + const entry = loaded.find((p: PageEntry) => p.rkey === autoSelectRkey) + if (entry) { + setSelectedRkey(entry.rkey) + setEditingRecord(structuredClone(entry.record)) + } + } }) .catch(() => setIsLoading(false)) - }, []) + }, [autoSelectRkey]) const selectPage = (entry: PageEntry) => { setSelectedRkey(entry.rkey) From 19724bc2cbcd4e8906884d98f59a7c9eb2158c6c Mon Sep 17 00:00:00 2001 From: daviddao Date: Thu, 26 Mar 2026 15:45:04 +0100 Subject: [PATCH 03/35] fix: set NEXT_PUBLIC_ADMIN_DID on Vercel, move edit button to top-right --- src/components/EditPageButton.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/EditPageButton.tsx b/src/components/EditPageButton.tsx index 74bd0546..49e7f25b 100644 --- a/src/components/EditPageButton.tsx +++ b/src/components/EditPageButton.tsx @@ -4,7 +4,8 @@ import Link from 'next/link' import { useAuth } from '@/lib/atproto' /** - * Floating edit button — only visible to admin users. + * Edit button — only visible to admin users. + * Positioned top-right of the page content area. * Links to /admin where the PageEditor can edit the given page rkey. */ export default function EditPageButton({ rkey }: { rkey: string }) { @@ -15,13 +16,13 @@ export default function EditPageButton({ rkey }: { rkey: string }) { return ( - Edit + Edit page ) } From 5e7a05cc9e27986ee7d3a7f1e8f8757bb932886d Mon Sep 17 00:00:00 2001 From: daviddao Date: Fri, 27 Mar 2026 12:59:40 +0100 Subject: [PATCH 04/35] =?UTF-8?q?fix:=20support=20multiple=20admin=20DIDs?= =?UTF-8?q?=20=E2=80=94=20add=20daviddao.org=20as=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADMIN_DIDS replaces ADMIN_DID for isAdmin check, supports comma-separated list via NEXT_PUBLIC_ADMIN_DIDS env var. daviddao.org DID added to Vercel. --- src/lib/atproto.tsx | 4 ++-- src/lib/lexicons.ts | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/atproto.tsx b/src/lib/atproto.tsx index 26e81aa2..36e5081c 100644 --- a/src/lib/atproto.tsx +++ b/src/lib/atproto.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, useCallback, createContext, useContext } from 'react' -import { ADMIN_DID } from './lexicons' +import { ADMIN_DIDS } from './lexicons' // Session type export interface Session { @@ -129,7 +129,7 @@ export function useAuth() { const { state, login, logout } = context - const isAdmin = state.session?.did === ADMIN_DID + const isAdmin = state.session ? ADMIN_DIDS.includes(state.session.did) : false return { status: state.status, diff --git a/src/lib/lexicons.ts b/src/lib/lexicons.ts index 48344a84..a7f69609 100644 --- a/src/lib/lexicons.ts +++ b/src/lib/lexicons.ts @@ -10,7 +10,13 @@ export const CURATEDLIST_COLLECTION = 'org.plresearch.curatedlist' // Admin DID - the only user who can manage the curated list // Uses NEXT_PUBLIC_ prefix so it's available in both server and client code -export const ADMIN_DID = process.env.NEXT_PUBLIC_ADMIN_DID || 'did:plc:qc42fmqqlsmdq7jiypiiigww' +// Primary admin DID (plresearch.org) — used for reading/writing page records +export const ADMIN_DID = process.env.NEXT_PUBLIC_ADMIN_DID || 'did:plc:pgwr6hkosgznfl5nz7egajei' + +// All DIDs allowed to see the edit button and use the admin panel +// Comma-separated in NEXT_PUBLIC_ADMIN_DIDS env var, falls back to ADMIN_DID +const _adminDidsRaw = process.env.NEXT_PUBLIC_ADMIN_DIDS || ADMIN_DID +export const ADMIN_DIDS: string[] = _adminDidsRaw.split(',').map(d => d.trim()).filter(Boolean) // Post types export type PostType = 'blog' | 'publication' | 'talk' | 'tutorial' From 5389f752e8b1314f5f049ca3cf47a1147b9c8494 Mon Sep 17 00:00:00 2001 From: daviddao Date: Fri, 27 Mar 2026 13:09:25 +0100 Subject: [PATCH 05/35] fix: use ADMIN_DIDS array in API routes (pages PUT + curated-list) --- src/app/api/curated-list/route.ts | 4 ++-- src/app/api/pages/[rkey]/route.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/curated-list/route.ts b/src/app/api/curated-list/route.ts index 5d58721c..f1f50aa1 100644 --- a/src/app/api/curated-list/route.ts +++ b/src/app/api/curated-list/route.ts @@ -2,14 +2,14 @@ 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 { CURATEDLIST_COLLECTION, ADMIN_DIDS } 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 + return did != null && ADMIN_DIDS.includes(did) } /** diff --git a/src/app/api/pages/[rkey]/route.ts b/src/app/api/pages/[rkey]/route.ts index 3a392f20..3c37b65b 100644 --- a/src/app/api/pages/[rkey]/route.ts +++ b/src/app/api/pages/[rkey]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server" import { fetchPage } from "@/lib/indexer" import { getAuthenticatedAgent } from "@/lib/agent" import { getSession } from "@/lib/session" -import { ADMIN_DID, PAGE_COLLECTION } from "@/lib/lexicons" +import { ADMIN_DID, ADMIN_DIDS, PAGE_COLLECTION } from "@/lib/lexicons" import type { PageRecord } from "@/lib/lexicons" export const dynamic = "force-dynamic" @@ -32,7 +32,7 @@ export async function PUT(req: NextRequest, { params }: Props) { } // Admin check — only the admin DID can edit pages - if (session.did !== ADMIN_DID) { + if (!ADMIN_DIDS.includes(session.did)) { return NextResponse.json({ error: "Forbidden — admin only" }, { status: 403 }) } From fe8072a73c1b7a779b1a3a240a1613f5e760ea22 Mon Sep 17 00:00:00 2001 From: daviddao Date: Fri, 27 Mar 2026 13:17:53 +0100 Subject: [PATCH 06/35] =?UTF-8?q?feat:=20remove=20curated=20accounts=20?= =?UTF-8?q?=E2=80=94=20delete=20curated-list=20API,=20feed=20API,=20Atprot?= =?UTF-8?q?oFeed,=20clean=20up=20lexicons=20and=20admin=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 225 +----------------------------- src/app/api/curated-list/route.ts | 156 --------------------- src/app/api/feed/route.ts | 17 --- src/app/blog/AtprotoFeed.tsx | 82 ----------- src/app/blog/page.tsx | 5 +- src/lib/atproto-client.ts | 119 +--------------- src/lib/lexicons.ts | 43 +----- 7 files changed, 9 insertions(+), 638 deletions(-) delete mode 100644 src/app/api/curated-list/route.ts delete mode 100644 src/app/api/feed/route.ts delete mode 100644 src/app/blog/AtprotoFeed.tsx 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 */} -
-
- setNewHandle(e.target.value)} - placeholder="handle.bsky.social" - disabled={isAdding} - className="flex-1 border-b border-gray-300 focus:border-black outline-none py-2 text-sm bg-transparent transition-colors" - /> - -
- - {/* Profile preview */} - {newHandle.trim().length >= 3 && ( -
- {isLoadingPreview && ( -

Looking up user...

- )} - {previewError && ( -

{previewError}

- )} - {profilePreview && ( -
- {profilePreview.avatar && ( - {profilePreview.handle} - )} -
- - {profilePreview.displayName || profilePreview.handle} - - - @{profilePreview.handle} - -
-
- )} -
- )} -
- - {listError &&

{listError}

} - - {/* Users list */} - {isLoadingUsers ? ( -

Loading...

- ) : users.length === 0 ? ( -

No curated accounts yet.

- ) : ( -
- {users.map((user) => ( -
-
- - @{user.handle} - - - {new Date(user.addedAt).toLocaleDateString()} - -
- -
- ))} -
- )} -
- )} - {/* 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 f1f50aa1..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_DIDS } 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 != null && ADMIN_DIDS.includes(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/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/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..9a666281 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -2,8 +2,6 @@ import Link from 'next/link' import { blogPosts } from '@/lib/content' import { formatDate } from '@/lib/format' import Breadcrumb from '@/components/Breadcrumb' -import AtprotoFeed from './AtprotoFeed' - export default function BlogPage() { return (
@@ -53,8 +51,7 @@ export default function BlogPage() {
)} - {/* ATProto feed (loads client-side when available) */} - +
) } diff --git a/src/lib/atproto-client.ts b/src/lib/atproto-client.ts index f44d3062..e4601be7 100644 --- a/src/lib/atproto-client.ts +++ b/src/lib/atproto-client.ts @@ -1,6 +1,6 @@ import { AtpAgent } from '@atproto/api' -import { CURATEDLIST_COLLECTION, ADMIN_DID, POST_COLLECTION, PAGE_COLLECTION } from './lexicons' -import type { CuratedListRecord, CuratedListEntry, PostRecord, PostEntry, PageRecord } from './lexicons' +import { ADMIN_DID, PAGE_COLLECTION } from './lexicons' +import type { PageRecord } from './lexicons' /** * Public Bluesky API agent (for resolving handles and profiles) @@ -71,121 +71,6 @@ export async function getPublicProfile(actor: string) { } } -// Singleton rkey for the curated list record -const CURATED_LIST_RKEY = 'self' - -/** - * Get the full curated list with metadata - */ -export async function getCuratedList(): Promise { - try { - const pdsUrl = await resolvePds(ADMIN_DID) - if (!pdsUrl) return [] - - const pdsAgent = getPdsAgent(pdsUrl) - const response = await pdsAgent.com.atproto.repo.getRecord({ - repo: ADMIN_DID, - collection: CURATEDLIST_COLLECTION, - rkey: CURATED_LIST_RKEY, - }) - - const record = response.data.value as CuratedListRecord - if (!record?.users || !Array.isArray(record.users)) { - return [] - } - - return record.users - } catch { - return [] - } -} - -/** - * Get the curated list record CID for swap updates - */ -export async function getCuratedListRecordCid(): Promise { - try { - const pdsUrl = await resolvePds(ADMIN_DID) - if (!pdsUrl) return undefined - - const pdsAgent = getPdsAgent(pdsUrl) - const response = await pdsAgent.com.atproto.repo.getRecord({ - repo: ADMIN_DID, - collection: CURATEDLIST_COLLECTION, - rkey: CURATED_LIST_RKEY, - }) - - return response.data.cid - } catch { - return undefined - } -} - -/** - * Fetch posts from a user's PDS - */ -export async function getUserPosts(did: string): Promise { - try { - const pdsUrl = await resolvePds(did) - if (!pdsUrl) return [] - - const pdsAgent = getPdsAgent(pdsUrl) - const response = await pdsAgent.com.atproto.repo.listRecords({ - repo: did, - collection: POST_COLLECTION, - limit: 100, - }) - - const profile = await getPublicProfile(did) - - return response.data.records.map((record) => ({ - uri: record.uri, - cid: record.cid, - author: { - did, - handle: profile?.handle || did, - displayName: profile?.displayName, - avatar: profile?.avatar, - }, - record: record.value as PostRecord, - })) - } catch { - return [] - } -} - -/** - * Fetch all posts from curated accounts - */ -export async function getFeedPosts(): Promise { - const curatedList = await getCuratedList() - const allPosts: PostEntry[] = [] - - for (const entry of curatedList) { - const posts = await getUserPosts(entry.did) - allPosts.push(...posts) - } - - // Also fetch admin's own posts - const adminPosts = await getUserPosts(ADMIN_DID) - // Avoid duplicates if admin is in curated list - const existingUris = new Set(allPosts.map(p => p.uri)) - for (const post of adminPosts) { - if (!existingUris.has(post.uri)) { - allPosts.push(post) - } - } - - // Sort by date descending - allPosts.sort((a, b) => - new Date(b.record.createdAt).getTime() - new Date(a.record.createdAt).getTime() - ) - - return allPosts -} - -export { CURATED_LIST_RKEY } - /** * Fetch a single page record from the admin PDS. */ diff --git a/src/lib/lexicons.ts b/src/lib/lexicons.ts index a7f69609..a9eb3cbe 100644 --- a/src/lib/lexicons.ts +++ b/src/lib/lexicons.ts @@ -2,26 +2,19 @@ * ATProto Lexicon Collection NSIDs for PL R&D */ -// Post collection - markdown articles (blog posts, publications, talks, tutorials) -export const POST_COLLECTION = 'org.plresearch.post' - -// Curated list collection - admin-managed list of users whose posts to display -export const CURATEDLIST_COLLECTION = 'org.plresearch.curatedlist' - -// Admin DID - the only user who can manage the curated list -// Uses NEXT_PUBLIC_ prefix so it's available in both server and client code // Primary admin DID (plresearch.org) — used for reading/writing page records export const ADMIN_DID = process.env.NEXT_PUBLIC_ADMIN_DID || 'did:plc:pgwr6hkosgznfl5nz7egajei' -// All DIDs allowed to see the edit button and use the admin panel +// All DIDs allowed to use the admin panel // Comma-separated in NEXT_PUBLIC_ADMIN_DIDS env var, falls back to ADMIN_DID const _adminDidsRaw = process.env.NEXT_PUBLIC_ADMIN_DIDS || ADMIN_DID export const ADMIN_DIDS: string[] = _adminDidsRaw.split(',').map(d => d.trim()).filter(Boolean) -// Post types +// Post collection (write page / blog posts authored on this PDS) +export const POST_COLLECTION = 'org.plresearch.post' + export type PostType = 'blog' | 'publication' | 'talk' | 'tutorial' -// Post record stored on ATProto export type PostRecord = { $type: string title: string @@ -34,21 +27,6 @@ export type PostRecord = { createdAt: string } -// Curated list entry -export type CuratedListEntry = { - did: string - handle: string - addedAt: string -} - -// Curated list record stored on ATProto -export type CuratedListRecord = { - $type: string - users: CuratedListEntry[] - createdAt: string - updatedAt: string -} - // Page collections export const PAGE_COLLECTION = "org.plresearch.page" export const OPPORTUNITY_COLLECTION = "org.plresearch.opportunitySpace" @@ -81,16 +59,3 @@ export const PAGE_RKEYS = [ ] as const export type PageRkey = typeof PAGE_RKEYS[number] - -// Post with metadata for display -export type PostEntry = { - uri: string - cid: string - author: { - did: string - handle: string - displayName?: string - avatar?: string - } - record: PostRecord -} From 1e8dabcd3b9fe222a0d050fdad9baf0496383ab9 Mon Sep 17 00:00:00 2001 From: daviddao Date: Fri, 27 Mar 2026 16:02:28 +0100 Subject: [PATCH 07/35] =?UTF-8?q?ux:=20redesign=20page=20content=20editor?= =?UTF-8?q?=20=E2=80=94=20collapsible=20sections,=20unsaved=20indicator,?= =?UTF-8?q?=20auto-grow=20textarea,=20write/preview=20per=20field,=20human?= =?UTF-8?q?=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PageEditor.tsx | 573 ++++++++++++++++++++++++++-------- 1 file changed, 451 insertions(+), 122 deletions(-) diff --git a/src/components/PageEditor.tsx b/src/components/PageEditor.tsx index c3cd92a9..5f89fe24 100644 --- a/src/components/PageEditor.tsx +++ b/src/components/PageEditor.tsx @@ -1,15 +1,365 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { useSearchParams } from "next/navigation" import type { PageRecord, PageSection } from "@/lib/lexicons" type PageEntry = { rkey: string; record: PageRecord } +// Human-readable labels for known rkeys +const PAGE_LABELS: Record = { + landing: "Landing", + about: "About", + areas: "Focus Areas", + collaborate: "Collaborate", + "area-ai-robotics": "AI & Robotics", + "area-digital-human-rights": "Digital Human Rights", + "area-neurotech": "Neurotechnology", + "area-economies-governance": "Economies & Governance", + "area-eg-subareas": "E&G — Subareas", + "area-eg-impact": "E&G — Impact", +} + +// Which fields are meaningful per sectionId prefix +function usedFields(section: PageSection): (keyof PageSection)[] { + const id = section.sectionId + const fields: (keyof PageSection)[] = [] + if (section.label !== undefined && section.label !== "") fields.push("label") + if (section.title !== undefined || id === "hero") fields.push("title") + if (section.subtitle !== undefined) fields.push("subtitle") + if (section.body !== undefined || section.body === "") fields.push("body") + // always show title + body; show others only if they have content + const always: (keyof PageSection)[] = ["title", "body"] + const extras: (keyof PageSection)[] = ["label", "subtitle"] + return [...new Set([...always, ...extras.filter(f => fields.includes(f))])] +} + +function FieldInput({ + label, + value, + onChange, + multiline = false, + mono = false, + placeholder = "", +}: { + label: string + value: string + onChange: (v: string) => void + multiline?: boolean + mono?: boolean + placeholder?: string +}) { + const textareaRef = useRef(null) + + // Auto-grow textarea + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto" + textareaRef.current.style.height = textareaRef.current.scrollHeight + "px" + } + }, [value]) + + const base = + "w-full bg-transparent outline-none transition-colors text-sm text-gray-800 placeholder:text-gray-300" + const borderClass = "border-b border-gray-200 focus:border-blue py-2" + + return ( +
+ + {multiline ? ( +