diff --git a/src/app/api/channels/route.ts b/src/app/api/channels/route.ts index f1d474deb..e59eaf14d 100644 --- a/src/app/api/channels/route.ts +++ b/src/app/api/channels/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getRequestAuth } from "@/lib/request-auth"; import { ArenaClient } from "@/lib/arena"; +export const maxDuration = 30; + export async function GET(req: NextRequest) { const auth = await getRequestAuth(req); if (!auth) @@ -39,3 +41,70 @@ export async function GET(req: NextRequest) { return NextResponse.json([], { status: 200 }); } } + +/** + * Create a new Are.na channel owned by the authenticated user. + * + * Body: `{ title: string; status?: "public" | "closed" | "private" }`. + * + * Returns the new channel as Are.na reports it (we trust their canonical slug + * and id rather than guessing from the title). + */ +export async function POST(req: NextRequest) { + const auth = await getRequestAuth(req); + if (!auth) { + return NextResponse.json( + { error: "Unauthorized", code: "unauthorized" }, + { status: 401 } + ); + } + + let body: { title?: unknown; status?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const title = typeof body.title === "string" ? body.title.trim() : ""; + if (!title) { + return NextResponse.json({ error: "title is required" }, { status: 400 }); + } + if (title.length > 200) { + return NextResponse.json( + { error: "title is too long (max 200 chars)" }, + { status: 400 } + ); + } + + let status: "public" | "closed" | "private" | undefined; + if ( + body.status === "public" || + body.status === "closed" || + body.status === "private" + ) { + status = body.status; + } + + const client = new ArenaClient(auth.arenaToken); + try { + const channel = await client.createChannel({ title, status }); + return NextResponse.json( + { + channel: { + id: channel.id, + title: channel.title, + slug: channel.slug, + length: channel.length ?? channel.counts?.contents ?? 0, + created_at: channel.created_at, + updated_at: channel.updated_at, + }, + }, + { status: 201 } + ); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to create channel"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/api/sites/[id]/blocks/route.ts b/src/app/api/sites/[id]/blocks/route.ts index 98e1e16be..e9564248c 100644 --- a/src/app/api/sites/[id]/blocks/route.ts +++ b/src/app/api/sites/[id]/blocks/route.ts @@ -1,6 +1,6 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest, NextResponse, after } from "next/server"; import { ArenaClient } from "@/lib/arena"; -import { channelBlocksForTemplate } from "@/lib/build"; +import { buildSite, channelBlocksForTemplate } from "@/lib/build"; import { prisma } from "@/lib/db"; import { getRequestAuth } from "@/lib/request-auth"; @@ -83,3 +83,117 @@ function editableFieldsForType( if (type === "text") return ["title", "content"]; return ["title", "description"]; } + +/** + * Create a new block in this site's Are.na channel. + * + * Body shapes: + * - `{ type: "text", content: string, title?: string }` + * - `{ type: "link", url: string, title?: string, description?: string }` + * - `{ type: "image", url: string, title?: string, description?: string }` + * (URL-based; Are.na fetches the image. Direct file uploads are not in v1.) + * + * Queues a rebuild on success. + */ +export async function POST( + 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 }); + } + + let body: { + type?: unknown; + content?: unknown; + url?: unknown; + title?: unknown; + description?: unknown; + rebuild?: unknown; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const type = body.type; + const title = typeof body.title === "string" ? body.title.slice(0, 500) : undefined; + const description = + typeof body.description === "string" + ? body.description.slice(0, 10_000) + : undefined; + + const client = new ArenaClient(auth.arenaToken); + + try { + let created; + if (type === "text") { + const content = typeof body.content === "string" ? body.content.slice(0, 50_000) : ""; + if (!content.trim()) { + return NextResponse.json( + { error: "content is required for text blocks" }, + { status: 400 } + ); + } + created = await client.createTextBlock(site.channelSlug, { content, title, description }); + } else if (type === "link") { + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!url) { + return NextResponse.json( + { error: "url is required for link blocks" }, + { status: 400 } + ); + } + created = await client.createLinkBlock(site.channelSlug, { url, title, description }); + } else if (type === "image") { + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!url) { + return NextResponse.json( + { error: "url is required for image blocks (v1 only supports image URLs)" }, + { status: 400 } + ); + } + created = await client.createImageBlockFromUrl(site.channelSlug, { url, title, description }); + } else { + return NextResponse.json( + { error: "type must be one of: text, link, image" }, + { status: 400 } + ); + } + + const shouldRebuild = body.rebuild !== false; + if (shouldRebuild) { + after(() => + buildSite(id).catch((err) => { + console.error("Rebuild after block create failed", id, created.id, err); + }) + ); + } + + return NextResponse.json( + { + block: { + id: created.id, + title: created.title, + updated_at: created.updated_at, + }, + rebuildQueued: shouldRebuild, + }, + { status: 201 } + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create block"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/site/new/page.tsx b/src/app/site/new/page.tsx index 08ecf3897..227c03bc5 100644 --- a/src/app/site/new/page.tsx +++ b/src/app/site/new/page.tsx @@ -326,6 +326,7 @@ export default function NewSitePage() { cancelHref="/sites" highlightChannelSlugs={existingSlugs} onSelect={handleChannelSelect} + enableCreate /> ); diff --git a/src/components/site-channel-picker.tsx b/src/components/site-channel-picker.tsx index 47e2c4eb0..a10e2d2b5 100644 --- a/src/components/site-channel-picker.tsx +++ b/src/components/site-channel-picker.tsx @@ -58,6 +58,7 @@ export function SiteChannelPicker({ highlightChannelSlugs, busy = false, embedded = false, + enableCreate = false, }: { onSelect: (channel: SiteChannelPickerChannel) => void; cancelHref: string; @@ -67,7 +68,42 @@ export function SiteChannelPicker({ busy?: boolean; /** Use inside a parent `main` (e.g. admin) — drops outer `min-h-screen` / `main` semantics. */ embedded?: boolean; + /** Show a "New channel" inline composer that creates an Are.na channel via our API and selects it. */ + enableCreate?: boolean; }) { + const [createOpen, setCreateOpen] = useState(false); + const [createTitle, setCreateTitle] = useState(""); + const [createStatus, setCreateStatus] = useState<"public" | "closed" | "private">("public"); + const [createBusy, setCreateBusy] = useState(false); + const [createError, setCreateError] = useState(null); + + const submitCreate = async () => { + const trimmed = createTitle.trim(); + if (!trimmed) { + setCreateError("Title is required."); + return; + } + setCreateBusy(true); + setCreateError(null); + try { + const res = await fetch("/api/channels", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: trimmed, status: createStatus }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error || `Create failed (${res.status})`); + } + const data = (await res.json()) as { channel: SiteChannelPickerChannel }; + onSelect(data.channel); + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Create failed"); + } finally { + setCreateBusy(false); + } + }; + const [ownChannels, setOwnChannels] = useState([]); const [followingChannels, setFollowingChannels] = useState< SiteChannelPickerChannel[] @@ -142,14 +178,82 @@ export function SiteChannelPicker({

{heading}

- - Cancel - +
+ {enableCreate && !createOpen && ( + + )} + + Cancel + +
+ {enableCreate && createOpen && ( +
+
+ Create a new Are.na channel +
+ setCreateTitle(e.target.value)} + className="w-full rounded border border-neutral-200 bg-white px-2 py-1 text-sm dark:border-neutral-700 dark:bg-neutral-950" + /> +
+ + +
+
+ + + {createError && ( + {createError} + )} +
+
+ )} +
setNewTitle(e.target.value)} + className="w-full rounded border border-neutral-200 bg-white px-2 py-1 text-sm dark:border-neutral-700 dark:bg-neutral-900" + /> + + {newKind === "text" ? ( +