Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/app/api/channels/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 });
}
}
118 changes: 116 additions & 2 deletions src/app/api/sites/[id]/blocks/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 });
}
}
1 change: 1 addition & 0 deletions src/app/site/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ export default function NewSitePage() {
cancelHref="/sites"
highlightChannelSlugs={existingSlugs}
onSelect={handleChannelSelect}
enableCreate
/>
</main>
);
Expand Down
116 changes: 110 additions & 6 deletions src/components/site-channel-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function SiteChannelPicker({
highlightChannelSlugs,
busy = false,
embedded = false,
enableCreate = false,
}: {
onSelect: (channel: SiteChannelPickerChannel) => void;
cancelHref: string;
Expand All @@ -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<string | null>(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<SiteChannelPickerChannel[]>([]);
const [followingChannels, setFollowingChannels] = useState<
SiteChannelPickerChannel[]
Expand Down Expand Up @@ -142,14 +178,82 @@ export function SiteChannelPicker({
<div className={rootClass}>
<div className="flex items-center justify-between mb-6">
<h1 className="text-lg font-medium">{heading}</h1>
<Link
href={cancelHref}
className="text-sm text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 dark:text-neutral-500"
>
Cancel
</Link>
<div className="flex items-center gap-3">
{enableCreate && !createOpen && (
<button
type="button"
onClick={() => {
setCreateOpen(true);
setCreateError(null);
}}
disabled={busy}
className="text-sm text-neutral-600 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-50"
>
+ New channel
</button>
)}
<Link
href={cancelHref}
className="text-sm text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 dark:text-neutral-500"
>
Cancel
</Link>
</div>
</div>

{enableCreate && createOpen && (
<div className="mb-4 space-y-2 rounded border border-neutral-200 bg-neutral-50/60 p-3 dark:border-neutral-700 dark:bg-neutral-900/40">
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Create a new Are.na channel
</div>
<input
type="text"
value={createTitle}
placeholder="Channel title"
autoFocus
onChange={(e) => 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"
/>
<div className="flex flex-wrap items-center gap-2 text-xs">
<label className="text-neutral-500 dark:text-neutral-400">Visibility</label>
<select
value={createStatus}
onChange={(e) => setCreateStatus(e.target.value as typeof createStatus)}
className="rounded border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-950"
>
<option value="public">Public (anyone)</option>
<option value="closed">Closed (collaborators only)</option>
<option value="private">Private (only me)</option>
</select>
</div>
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={() => void submitCreate()}
disabled={createBusy || !createTitle.trim()}
className="inline-flex items-center justify-center rounded border border-neutral-900 bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
{createBusy ? "Creating…" : "Create channel"}
</button>
<button
type="button"
onClick={() => {
setCreateOpen(false);
setCreateTitle("");
setCreateError(null);
}}
disabled={createBusy}
className="inline-flex items-center justify-center rounded px-2 py-1 text-xs font-medium text-neutral-500 hover:text-neutral-900 disabled:opacity-50 dark:text-neutral-400 dark:hover:text-neutral-100"
>
Cancel
</button>
{createError && (
<span className="text-[11px] text-red-600 dark:text-red-400">{createError}</span>
)}
</div>
</div>
)}

<div className="flex items-center border border-neutral-200 rounded mb-4 focus-within:border-neutral-400 transition-colors dark:focus-within:border-neutral-500 dark:border-neutral-700">
<select
value={filter}
Expand Down
Loading