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
92 changes: 92 additions & 0 deletions src/app/api/sites/[id]/blocks/[blockId]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
85 changes: 85 additions & 0 deletions src/app/api/sites/[id]/blocks/route.ts
Original file line number Diff line number Diff line change
@@ -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"];
}
17 changes: 14 additions & 3 deletions src/app/sites/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -569,12 +570,13 @@ export default function SiteSettingsPage() {
<SegmentedControl<SettingsTab>
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"
/>
</div>
Expand All @@ -583,8 +585,17 @@ export default function SiteSettingsPage() {
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-6 md:flex-row md:items-stretch md:gap-6">
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 md:min-h-0">
<h2 className="shrink-0 text-xs font-medium text-neutral-500 dark:text-neutral-400">
{activeTab === "settings" ? "Settings" : "Theme"}
{activeTab === "settings"
? "Settings"
: activeTab === "content"
? "Content"
: "Theme"}
</h2>
{activeTab === "content" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-950">
<SiteContentEditor siteId={id} />
</div>
)}
{activeTab === "settings" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-950">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
Expand Down
Loading