diff --git a/.env.example b/.env.example index 5c36c83..b3c6f42 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,9 @@ DEFAULT_CONFIDENCE_THRESHOLD=0.75 # Comma-separated browser origins allowed to call the public read-only MCP endpoint. MCP_ALLOWED_ORIGINS=https://ellaentity.ai,https://www.ellaentity.ai,https://mcp.ellaentity.ai + +# Server-only. Never prefix these with NEXT_PUBLIC_. +EXCHANGE_SUPABASE_URL= +EXCHANGE_SUPABASE_SERVICE_ROLE_KEY= +# Generate a random 32-byte base64url token; do not use an ordinary password. +EXCHANGE_MODERATOR_TOKEN= diff --git a/app/api/exchange/review/route.ts b/app/api/exchange/review/route.ts new file mode 100644 index 0000000..f1f15f3 --- /dev/null +++ b/app/api/exchange/review/route.ts @@ -0,0 +1,19 @@ +import { database, failure, readBody, requireModerator, reviewSchema } from '@/lib/exchange' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET(request: Request) { + try { + requireModerator(request) + const status = new URL(request.url).searchParams.get('status') || 'pending' + if (!['pending','approved','rejected'].includes(status)) return Response.json({error:'Invalid status'}, {status:400}) + return Response.json(await database(`ella_exchange_notes?status=eq.${status}&select=*&order=created_at.asc&limit=100`), { headers: { 'Cache-Control': 'no-store' } }) + } catch (error) { return failure(error) } +} +export async function PATCH(request: Request) { + try { + requireModerator(request) + const { id, status, response } = reviewSchema.parse(await readBody(request)) + const rows = await database(`ella_exchange_notes?id=eq.${id}&select=id`, { method: 'PATCH', headers: { Prefer: 'return=representation' }, body: JSON.stringify({ status, response, approved_at: status === 'approved' ? new Date().toISOString() : null }) }) + return Response.json({ updated: rows.length === 1 }, { headers: { 'Cache-Control': 'no-store' } }) + } catch (error) { return failure(error) } +} diff --git a/app/api/exchange/route.ts b/app/api/exchange/route.ts new file mode 100644 index 0000000..ddc5815 --- /dev/null +++ b/app/api/exchange/route.ts @@ -0,0 +1,9 @@ +import { failure, readBody, readExchange, submitNote } from '@/lib/exchange' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET() { + try { return Response.json(await readExchange(), { headers: { 'Cache-Control': 'no-store' } }) } catch (error) { return failure(error) } +} +export async function POST(request: Request) { + try { return Response.json(await submitNote(await readBody(request)), { status: 202, headers: { 'Cache-Control': 'no-store' } }) } catch (error) { return failure(error) } +} diff --git a/app/components/SiteHeader.tsx b/app/components/SiteHeader.tsx index fd595aa..2f6b567 100644 --- a/app/components/SiteHeader.tsx +++ b/app/components/SiteHeader.tsx @@ -6,6 +6,7 @@ const navItems = [ { href: '/works', label: 'Works' }, { href: '/domains', label: 'Domains' }, { href: '/system', label: 'System' }, + { href: '/exchange', label: 'Exchange' }, ] export function SiteHeader() { diff --git a/app/exchange/ExchangeBoard.tsx b/app/exchange/ExchangeBoard.tsx new file mode 100644 index 0000000..bc14c4f --- /dev/null +++ b/app/exchange/ExchangeBoard.tsx @@ -0,0 +1,28 @@ +'use client' +import { useEffect, useState } from 'react' +import type { PublicNote } from '@/lib/exchange' +export default function ExchangeBoard() { + const [notes,setNotes] = useState([]) + const [available,setAvailable] = useState(false) + const [message,setMessage] = useState('Loading the exchange…') + const [busy,setBusy] = useState(false) + const [receipt,setReceipt] = useState(false) + const [submissionId,setSubmissionId] = useState('') + useEffect(() => { setSubmissionId(crypto.randomUUID()); fetch('/api/exchange').then(async r => { const data = await r.json(); if (!r.ok) throw new Error(data.error); setNotes(data.notes); setAvailable(data.available); setMessage(data.available ? '' : 'Submissions are not open yet. Please check back.'); }).catch(() => setMessage('The exchange is temporarily unavailable. Please try again later.')) }, []) + async function submit(event: React.FormEvent) { + event.preventDefault(); const form = event.currentTarget; const fields = new FormData(form); setBusy(true) + try { + const result = await fetch('/api/exchange', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ submissionId, name:fields.get('name'), participant:fields.get('participant'), body:fields.get('body'), reference:fields.get('reference'), consent:fields.get('consent') === 'on' }) }); const data = await result.json(); + if (!result.ok) throw new Error(data.error) + setMessage(data.message); setReceipt(true); form.reset() + } catch(error) { setMessage(error instanceof Error ? error.message : 'Submission failed. Please try again.'); } finally { setBusy(false) } + } + return <>

Leave Ella a note

Offer a perspective, ask a research question, or connect an observation to a published work. Every note is reviewed before publication. Do not include private health information or contact details.

+
+
+