diff --git a/.env.example b/.env.example index d2bd93d2..f7b564b9 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,13 @@ PUBLIC_URL= # In development, falls back to public client mode if not set ATPROTO_JWK_PRIVATE= +# Service account (app password) for the plresearch.org repo. +# Reused by the pages/opportunity-spaces editors AND blog comments — reader +# comments are stored in this repo (single-writer) after the commenter's +# Bluesky OAuth identity is verified server-side. +ATPROTO_HANDLE= +ATPROTO_PASSWORD= + # 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 diff --git a/docs/screenshots/bluesky-comments.png b/docs/screenshots/bluesky-comments.png new file mode 100644 index 00000000..2bdb2e0c Binary files /dev/null and b/docs/screenshots/bluesky-comments.png differ diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100644 index 00000000..4e43f268 --- /dev/null +++ b/src/app/api/comments/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSession } from "@/lib/session" +import { ADMIN_DIDS } from "@/lib/lexicons" +import { + listComments, + createComment, + deleteComment, + getComment, + MAX_COMMENT_LENGTH, +} from "@/lib/comments" +import type { CommentAuthor } from "@/lib/lexicons" + +export const dynamic = "force-dynamic" + +/** + * GET /api/comments?subject= — list comments for a blog post. Public. + */ +export async function GET(req: NextRequest) { + const subject = req.nextUrl.searchParams.get("subject")?.trim() + if (!subject) { + return NextResponse.json({ error: "subject is required" }, { status: 400 }) + } + try { + const comments = await listComments(subject) + return NextResponse.json({ comments }) + } catch (error) { + console.error("Failed to list comments:", error) + return NextResponse.json( + { error: "Failed to load comments" }, + { status: 500 }, + ) + } +} + +/** + * POST /api/comments — create a comment. Requires a Bluesky OAuth session. + * The author identity is taken from the verified session, never the request + * body, so commenters cannot impersonate one another. + */ +export async function POST(req: NextRequest) { + const session = await getSession() + if (!session.did) { + return NextResponse.json( + { error: "Sign in with Bluesky to comment" }, + { status: 401 }, + ) + } + + let body: { subject?: string; text?: string } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const subject = body.subject?.trim() + const text = body.text?.trim() + if (!subject) { + return NextResponse.json({ error: "subject is required" }, { status: 400 }) + } + if (!text) { + return NextResponse.json({ error: "Comment cannot be empty" }, { status: 400 }) + } + if (text.length > MAX_COMMENT_LENGTH) { + return NextResponse.json( + { error: `Comment must be ${MAX_COMMENT_LENGTH} characters or fewer` }, + { status: 400 }, + ) + } + + const author: CommentAuthor = { + did: session.did, + handle: session.handle || session.did, + displayName: session.displayName, + avatar: session.avatar, + } + + try { + const comment = await createComment(subject, text, author) + return NextResponse.json({ comment }, { status: 201 }) + } catch (error) { + console.error("Failed to create comment:", error) + const message = + error instanceof Error && error.message.includes("not configured") + ? "Comments are not configured on this deployment" + : "Failed to post comment" + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +/** + * DELETE /api/comments?rkey= — remove a comment. Allowed for the comment + * author or any admin DID. + */ +export async function DELETE(req: NextRequest) { + const session = await getSession() + if (!session.did) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const rkey = req.nextUrl.searchParams.get("rkey")?.trim() + if (!rkey) { + return NextResponse.json({ error: "rkey is required" }, { status: 400 }) + } + + try { + const existing = await getComment(rkey) + if (!existing) { + return NextResponse.json({ error: "Comment not found" }, { status: 404 }) + } + const isAuthor = existing.record.author.did === session.did + const isAdmin = ADMIN_DIDS.includes(session.did) + if (!isAuthor && !isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + await deleteComment(rkey) + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Failed to delete comment:", error) + return NextResponse.json( + { error: "Failed to delete comment" }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index e7b8a1dc..1ad70d8e 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -10,6 +10,12 @@ export async function POST(request: NextRequest) { const client = await getGlobalOAuthClient() const body = await request.json() const handle = body?.handle + // Optional same-origin path to return to after login (e.g. the blog post + // the reader was on when they clicked "Sign in to comment"). + const returnTo = + typeof body?.returnTo === 'string' && body.returnTo.startsWith('/') + ? body.returnTo + : undefined if (typeof handle !== 'string' || !isValidHandle(handle)) { return NextResponse.json({ error: 'Invalid handle' }, { status: 400 }) @@ -17,6 +23,7 @@ export async function POST(request: NextRequest) { const url = await client.authorize(handle, { scope: 'atproto transition:generic', + state: returnTo, }) return NextResponse.json({ redirectUrl: url.toString() }) diff --git a/src/app/api/oauth/callback/route.ts b/src/app/api/oauth/callback/route.ts index 895b782a..fb0cca4e 100644 --- a/src/app/api/oauth/callback/route.ts +++ b/src/app/api/oauth/callback/route.ts @@ -13,12 +13,17 @@ export async function GET(request: NextRequest) { // Retry OAuth callback up to 3 times for network errors let oauthSession + let returnTo: string | undefined let lastError for (let attempt = 1; attempt <= 3; attempt++) { try { const result = await client.callback(params) oauthSession = result.session + // `state` carries the optional same-origin return path set at login. + if (typeof result.state === 'string' && result.state.startsWith('/')) { + returnTo = result.state + } break } catch (error) { lastError = error @@ -68,11 +73,13 @@ export async function GET(request: NextRequest) { session.avatar = avatar await session.save() - // Redirect to home + // Redirect back to where the user started (if a safe same-origin path was + // provided), otherwise home. const requestUrl = new URL(request.url) const origin = requestUrl.origin - - return Response.redirect(`${origin}/`, 303) + const destination = returnTo ? `${origin}${returnTo}` : `${origin}/` + + return Response.redirect(destination, 303) } catch (error) { console.error('OAuth callback failed:', error) const requestUrl = new URL(request.url) diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index c5faebdd..c7794027 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -4,6 +4,7 @@ import { blogPosts } from '@/lib/content' import { formatDate } from '@/lib/format' import AuthorCard from '@/components/AuthorCard' import Breadcrumb from '@/components/Breadcrumb' +import Comments from '@/components/Comments' type Props = { params: Promise<{ slug: string }> } @@ -58,6 +59,9 @@ export default async function BlogPostPage({ params }: Props) { {post.html && (
)} + {/* External stub posts live on their original home; only host comments + for posts that actually live on this site. */} + {!post.external_url && }
) } diff --git a/src/components/Comments.tsx b/src/components/Comments.tsx new file mode 100644 index 00000000..abaae4af --- /dev/null +++ b/src/components/Comments.tsx @@ -0,0 +1,257 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useAuth } from '@/lib/atproto' +import { formatDate } from '@/lib/format' + +const MAX_COMMENT_LENGTH = 2000 + +type CommentAuthor = { + did: string + handle: string + displayName?: string + avatar?: string +} + +type Comment = { + uri: string + rkey: string + record: { + subject: string + text: string + author: CommentAuthor + createdAt: string + } +} + +function initials(author: CommentAuthor): string { + const source = author.displayName || author.handle || '?' + return source.trim().charAt(0).toUpperCase() +} + +function CommentItem({ + comment, + canDelete, + onDelete, +}: { + comment: Comment + canDelete: boolean + onDelete: (rkey: string) => void +}) { + const { author, text, createdAt } = comment.record + return ( +
  • + {author.avatar ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + {initials(author)} +
    + )} +
    +
    + + {author.displayName || author.handle} + + @{author.handle} + · + {formatDate(createdAt)} + {canDelete && ( + + )} +
    +

    + {text} +

    +
    +
  • + ) +} + +export default function Comments({ subject }: { subject: string }) { + const { session, isAuthenticated, isAdmin, login, logout } = useAuth() + const [comments, setComments] = useState([]) + const [loading, setLoading] = useState(true) + const [text, setText] = useState('') + const [handle, setHandle] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + try { + const res = await fetch( + `/api/comments?subject=${encodeURIComponent(subject)}`, + ) + if (res.ok) { + const data = await res.json() + setComments(data.comments || []) + } + } catch { + // Non-fatal — just show an empty thread. + } finally { + setLoading(false) + } + }, [subject]) + + useEffect(() => { + load() + }, [load]) + + const submit = async () => { + const body = text.trim() + if (!body) return + setSubmitting(true) + setError(null) + try { + const res = await fetch('/api/comments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subject, text: body }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to post comment') + setComments((prev) => [...prev, data.comment]) + setText('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to post comment') + } finally { + setSubmitting(false) + } + } + + const remove = async (rkey: string) => { + if (!confirm('Delete this comment?')) return + try { + const res = await fetch( + `/api/comments?rkey=${encodeURIComponent(rkey)}`, + { method: 'DELETE' }, + ) + if (res.ok) setComments((prev) => prev.filter((c) => c.rkey !== rkey)) + } catch { + // Ignore — the comment stays visible until the next reload. + } + } + + const signIn = async () => { + const h = handle.trim() + if (!h) return + setError(null) + try { + const returnTo = + typeof window !== 'undefined' + ? window.location.pathname + window.location.search + : undefined + await login(h, returnTo) + } catch (err) { + setError(err instanceof Error ? err.message : 'Sign-in failed') + } + } + + return ( +
    +

    + Comments{comments.length > 0 ? ` (${comments.length})` : ''} +

    + + {loading ? ( +

    Loading comments…

    + ) : comments.length === 0 ? ( +

    + No comments yet. Be the first to respond. +

    + ) : ( +
      + {comments.map((c) => ( + + ))} +
    + )} + + {isAuthenticated ? ( +
    +
    + Commenting as{' '} + + {session?.displayName || session?.handle} + + +
    +