From 2b7098770626689fbf9817ca15ce34397586a69a Mon Sep 17 00:00:00 2001 From: William Felker Date: Mon, 11 May 2026 13:19:47 -0700 Subject: [PATCH] Inline block editor with Are.na sync Adds a new "Content" tab on /sites/[id] that lists every block in the site's Are.na channel and lets the owner edit title, description, and Text-block content directly. Edits round-trip to Are.na via PUT /v3/blocks/{id} using the user's OAuth token, then queue a background rebuild so the static site reflects the change. - src/lib/arena.ts: ArenaClient.updateBlock + getBlock with rate-limit retry - src/app/api/sites/[id]/blocks/route.ts: GET editable blocks (live from Are.na) - src/app/api/sites/[id]/blocks/[blockId]/route.ts: PATCH a single block and trigger rebuild - src/components/site-content-editor.tsx: tab UI with per-block draft, save, discard, and inline error/success states - src/app/sites/[id]/page.tsx: third "Content" segment in the existing tab control, renders SiteContentEditor v1 scope: text-only edits. Image replacement and block reordering are not covered. Are.na owns authorization on the block; we surface the 401/403 verbatim when it rejects. Co-authored-by: Cursor --- .../api/sites/[id]/blocks/[blockId]/route.ts | 92 +++++ src/app/api/sites/[id]/blocks/route.ts | 85 +++++ src/app/sites/[id]/page.tsx | 17 +- src/components/site-content-editor.tsx | 358 ++++++++++++++++++ src/lib/arena.ts | 86 +++++ 5 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 src/app/api/sites/[id]/blocks/[blockId]/route.ts create mode 100644 src/app/api/sites/[id]/blocks/route.ts create mode 100644 src/components/site-content-editor.tsx diff --git a/src/app/api/sites/[id]/blocks/[blockId]/route.ts b/src/app/api/sites/[id]/blocks/[blockId]/route.ts new file mode 100644 index 000000000..51bddd8f8 --- /dev/null +++ b/src/app/api/sites/[id]/blocks/[blockId]/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse, after } from "next/server"; +import { ArenaClient } from "@/lib/arena"; +import { buildSite } from "@/lib/build"; +import { prisma } from "@/lib/db"; +import { getRequestAuth } from "@/lib/request-auth"; + +export const maxDuration = 60; + +/** + * Update a single Are.na block belonging to a site's channel, using the + * caller's Are.na OAuth token. Triggers a background rebuild so the static + * site reflects the change. + * + * Authorization model: a user may patch any block in a channel they own a + * tiny.garden site for. We do NOT verify that Are.na considers them owner of + * the block — Are.na enforces that itself and will return 401/403 if not. + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string; blockId: string }> } +) { + const auth = await getRequestAuth(req); + if (!auth) { + return NextResponse.json( + { error: "Unauthorized", code: "unauthorized" }, + { status: 401 } + ); + } + + const { id, blockId: blockIdRaw } = await params; + const blockId = Number.parseInt(blockIdRaw, 10); + if (!Number.isFinite(blockId) || blockId <= 0) { + return NextResponse.json({ error: "Invalid block id" }, { status: 400 }); + } + + const site = await prisma.site.findUnique({ where: { id } }); + if (!site || site.userId !== auth.userId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + let body: { title?: unknown; description?: unknown; content?: unknown; rebuild?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const patch: { title?: string; description?: string | null; content?: string | null } = {}; + if (typeof body.title === "string") patch.title = body.title.slice(0, 500); + if (body.description === null || typeof body.description === "string") { + patch.description = body.description === null ? "" : String(body.description).slice(0, 10_000); + } + if (body.content === null || typeof body.content === "string") { + patch.content = body.content === null ? "" : String(body.content).slice(0, 50_000); + } + + if (Object.keys(patch).length === 0) { + return NextResponse.json( + { error: "No editable fields supplied" }, + { status: 400 } + ); + } + + const client = new ArenaClient(auth.arenaToken); + let updated; + try { + updated = await client.updateBlock(blockId, patch); + } catch (err) { + const message = err instanceof Error ? err.message : "Are.na update failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } + + const shouldRebuild = body.rebuild !== false; // default true + if (shouldRebuild) { + after(() => + buildSite(id).catch((rebuildErr) => { + console.error("Rebuild after block edit failed", id, blockId, rebuildErr); + }) + ); + } + + return NextResponse.json({ + block: { + id: updated.id, + title: updated.title, + description: updated.description, + content: typeof updated.content === "string" ? updated.content : null, + updated_at: updated.updated_at, + }, + rebuildQueued: shouldRebuild, + }); +} diff --git a/src/app/api/sites/[id]/blocks/route.ts b/src/app/api/sites/[id]/blocks/route.ts new file mode 100644 index 000000000..98e1e16be --- /dev/null +++ b/src/app/api/sites/[id]/blocks/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ArenaClient } from "@/lib/arena"; +import { channelBlocksForTemplate } from "@/lib/build"; +import { prisma } from "@/lib/db"; +import { getRequestAuth } from "@/lib/request-auth"; + +export const maxDuration = 60; + +/** + * List editable blocks for a site, sourced live from Are.na (not the build + * cache) so the inline editor always shows the current canonical state. + * + * Returned shape is a subset of `TemplateBlock` plus a stable + * `editableFields` hint that the UI uses to decide which inputs to render. + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await getRequestAuth(req); + if (!auth) { + return NextResponse.json( + { error: "Unauthorized", code: "unauthorized" }, + { status: 401 } + ); + } + + const { id } = await params; + const site = await prisma.site.findUnique({ where: { id } }); + if (!site || site.userId !== auth.userId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const client = new ArenaClient(auth.arenaToken); + let raw; + try { + raw = await client.getAllChannelBlocks(site.channelSlug); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load blocks"; + return NextResponse.json({ error: message }, { status: 502 }); + } + + const blocks = channelBlocksForTemplate(raw).map((block) => ({ + id: block.id, + type: block.type, + title: block.title, + description: block.description, + content: block.content ?? null, + position: block.position, + updated_at: block.updated_at, + arena_url: block.arena_url, + image: block.image ? { display: block.image.display, square: block.image.square } : null, + link: block.link ? { url: block.link.url, title: block.link.title, description: block.link.description } : null, + attachment: block.attachment + ? { + file_name: block.attachment.file_name, + kind: block.attachment.kind, + preview_image_url: block.attachment.preview_image_url, + } + : null, + /** + * Tells the client which fields are user-editable on Are.na for this block type. + * Are.na only accepts content on Text blocks; image/link/media/attachment blocks + * are still editable for title/description. + */ + editableFields: editableFieldsForType(block.type), + })); + + return NextResponse.json({ + site: { + id: site.id, + subdomain: site.subdomain, + channelSlug: site.channelSlug, + channelTitle: site.channelTitle, + }, + blocks, + }); +} + +function editableFieldsForType( + type: "image" | "text" | "link" | "media" | "attachment" +): Array<"title" | "description" | "content"> { + if (type === "text") return ["title", "content"]; + return ["title", "description"]; +} diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx index cd4417794..a0c83f429 100644 --- a/src/app/sites/[id]/page.tsx +++ b/src/app/sites/[id]/page.tsx @@ -10,6 +10,7 @@ import { IdeTextEditor } from "@/components/ide-text-editor"; import { ThemeTokensPillEditor } from "@/components/theme-tokens-pill-editor"; import { SegmentedControl } from "@/components/toolbar"; import { SiteChannelSettingsCard } from "@/components/site-channel-settings-card"; +import { SiteContentEditor } from "@/components/site-content-editor"; import { SiteSettingsSkeleton } from "@/components/sites-dashboard-skeletons"; import { PlantIconFrame, SitePreviewColumn } from "@/components/site-preview-column"; import { @@ -50,7 +51,7 @@ interface AccountInfo { const DEFAULT_COLORS = DEFAULT_THEME_COLORS; const DEFAULT_FONTS = DEFAULT_THEME_FONTS; -type SettingsTab = "settings" | "theme"; +type SettingsTab = "settings" | "content" | "theme"; type ThemeFileTab = "theme-css" | "styles-css"; @@ -569,12 +570,13 @@ export default function SiteSettingsPage() { segments={[ { value: "settings", label: "Settings" }, + { value: "content", label: "Content" }, { value: "theme", label: "Theme" }, ]} value={activeTab} onChange={setActiveTab} ariaLabel="Site settings sections" - className="w-full max-w-[13.5rem] shrink-0 sm:w-[13.5rem] sm:self-end" + className="w-full max-w-[18rem] shrink-0 sm:w-[18rem] sm:self-end" labelClassName="px-3 text-xs font-medium" /> @@ -583,8 +585,17 @@ export default function SiteSettingsPage() {

- {activeTab === "settings" ? "Settings" : "Theme"} + {activeTab === "settings" + ? "Settings" + : activeTab === "content" + ? "Content" + : "Theme"}

+ {activeTab === "content" && ( +
+ +
+ )} {activeTab === "settings" && (
diff --git a/src/components/site-content-editor.tsx b/src/components/site-content-editor.tsx new file mode 100644 index 000000000..b1c2aa386 --- /dev/null +++ b/src/components/site-content-editor.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { track } from "@/lib/track"; + +/** + * Subset of `TemplateBlock` returned by `GET /api/sites/[id]/blocks`. Kept + * narrow so the editor doesn't depend on the build-time shape. + */ +export type EditableBlock = { + id: number; + type: "image" | "text" | "link" | "media" | "attachment"; + title: string; + description: string; + content: string | null; + position: number; + updated_at: string; + arena_url: string; + image: { display: string; square: string } | null; + link: { url: string; title: string; description: string } | null; + attachment: { + file_name: string; + kind: string; + preview_image_url: string; + } | null; + editableFields: Array<"title" | "description" | "content">; +}; + +type SaveState = + | { kind: "idle" } + | { kind: "saving" } + | { kind: "saved"; at: number } + | { kind: "error"; message: string }; + +type DraftMap = Record; + +function blockTypeLabel(type: EditableBlock["type"]): string { + switch (type) { + case "image": + return "Image"; + case "text": + return "Text"; + case "link": + return "Link"; + case "media": + return "Media"; + case "attachment": + return "File"; + } +} + +function blockPreview(block: EditableBlock): React.ReactNode { + if (block.image?.display) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + } + if (block.attachment?.preview_image_url) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + } + return ( +
+ {blockTypeLabel(block.type)} +
+ ); +} + +/** + * Inline editor for a site's Are.na blocks. + * + * - Fetches blocks live from Are.na via the local API on mount. + * - User edits go into a per-block draft. "Save" PATCHes the block on Are.na + * and triggers a debounced rebuild server-side. + * - We refetch the row on success so the UI shows Are.na's canonical state. + */ +export function SiteContentEditor({ siteId }: { siteId: string }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [blocks, setBlocks] = useState([]); + const [drafts, setDrafts] = useState({}); + const [saveState, setSaveState] = useState>({}); + + const refreshAll = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/sites/${siteId}/blocks`, { + credentials: "same-origin", + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Failed to load blocks (${res.status})`); + } + const data = (await res.json()) as { blocks: EditableBlock[] }; + setBlocks(data.blocks); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load blocks"); + } finally { + setLoading(false); + } + }, [siteId]); + + useEffect(() => { + void refreshAll(); + }, [refreshAll]); + + const setDraftField = ( + blockId: number, + field: "title" | "description" | "content", + value: string + ) => { + setDrafts((prev) => ({ + ...prev, + [blockId]: { ...prev[blockId], [field]: value }, + })); + }; + + const hasDraft = (blockId: number) => { + const d = drafts[blockId]; + if (!d) return false; + return ( + typeof d.title === "string" || + typeof d.description === "string" || + typeof d.content === "string" + ); + }; + + const discardDraft = (blockId: number) => { + setDrafts((prev) => { + const next = { ...prev }; + delete next[blockId]; + return next; + }); + setSaveState((prev) => ({ ...prev, [blockId]: { kind: "idle" } })); + }; + + const saveBlock = async (block: EditableBlock) => { + const draft = drafts[block.id]; + if (!draft) return; + + setSaveState((prev) => ({ ...prev, [block.id]: { kind: "saving" } })); + try { + const res = await fetch(`/api/sites/${siteId}/blocks/${block.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error || `Save failed (${res.status})`); + } + const data = (await res.json()) as { + block: { id: number; title: string; description: string; content: string | null; updated_at: string }; + rebuildQueued: boolean; + }; + setBlocks((prev) => + prev.map((b) => + b.id === block.id + ? { + ...b, + title: data.block.title, + description: data.block.description ?? "", + content: data.block.content, + updated_at: data.block.updated_at, + } + : b + ) + ); + discardDraft(block.id); + setSaveState((prev) => ({ + ...prev, + [block.id]: { kind: "saved", at: Date.now() }, + })); + track("block-edited", { siteId, blockId: block.id, fields: Object.keys(draft).join(",") }); + } catch (err) { + setSaveState((prev) => ({ + ...prev, + [block.id]: { + kind: "error", + message: err instanceof Error ? err.message : "Save failed", + }, + })); + } + }; + + if (loading) { + return ( +
+ Loading blocks from Are.na… +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (blocks.length === 0) { + return ( +
+ This channel has no blocks yet. Add a block on Are.na and refresh. +
+ ); + } + + return ( +
+
+ {blocks.length} blocks · edits sync to Are.na and queue a rebuild + +
+
    + {blocks.map((block) => { + const draft = drafts[block.id] || {}; + const state = saveState[block.id] || { kind: "idle" as const }; + const title = draft.title ?? block.title; + const description = draft.description ?? block.description; + const content = draft.content ?? block.content ?? ""; + const canSave = hasDraft(block.id) && state.kind !== "saving"; + + return ( +
  • +
    + {blockPreview(block)} +
    +
    + {blockTypeLabel(block.type)} + + #{block.id} + + {block.link?.url ? ( + + {block.link.url} + + ) : null} + {block.attachment?.file_name ? ( + + {block.attachment.file_name} + + ) : null} +
    + + {block.editableFields.includes("title") && ( + + )} + + {block.editableFields.includes("description") && ( +