From c0a3138a0a41909a6bbd3c383f3b956d72e80dc7 Mon Sep 17 00:00:00 2001 From: William Felker Date: Mon, 11 May 2026 13:27:23 -0700 Subject: [PATCH] Native AI theme editor on /sites/[id] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the existing Anthropic-backed theme generator (previously admin-only, used once on site create) into a first-class native editor on the site settings page. Owners can iterate on their look by typing a prompt; the model returns colors + fonts, the tokens are written to the site, and a rebuild is queued — all in one round trip. Changes - src/app/api/sites/[id]/theme/ai/route.ts: owner-scoped POST. Reuses generateAiSiteTheme() but gates on the same plan rule as the existing /theme PUT (admin/friend/pro/studio). With `apply: true` (default) it writes themeColors/themeFonts and triggers buildSite() via after(). - src/components/site-ai-theme-box.tsx: prompt + apply UI, inline error + "Applied · rebuild queued" feedback, and a per-browser prompt history (localStorage, top 8) for iterative "the same, but darker" tweaks. No schema change in v1. - src/app/sites/[id]/page.tsx: drops the AI box above the existing theme.css / styles.css editor on the Theme tab, so users can describe a vibe and then nudge the result by hand. Template consolidation from docs/features.md is intentionally NOT in this PR — leaving the existing template surface in place for now. Co-authored-by: Cursor --- src/app/api/sites/[id]/theme/ai/route.ts | 114 ++++++++++++ src/app/sites/[id]/page.tsx | 14 ++ src/components/site-ai-theme-box.tsx | 213 +++++++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 src/app/api/sites/[id]/theme/ai/route.ts create mode 100644 src/components/site-ai-theme-box.tsx diff --git a/src/app/api/sites/[id]/theme/ai/route.ts b/src/app/api/sites/[id]/theme/ai/route.ts new file mode 100644 index 000000000..7360b4c0e --- /dev/null +++ b/src/app/api/sites/[id]/theme/ai/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse, after } from "next/server"; +import { prisma } from "@/lib/db"; +import { generateAiSiteTheme } from "@/lib/ai-site-theme"; +import { getRequestAuth } from "@/lib/request-auth"; +import { buildSite } from "@/lib/build"; + +export const maxDuration = 120; + +const MAX_PROMPT_LEN = 6000; + +/** + * Generate (and optionally apply) an AI theme for a specific site. + * + * Unlike `/api/admin/ai-site-theme`, this is owner-scoped: + * - The caller must own the site. + * - The same plan gate as `/api/sites/[id]/theme` (admin/friend/pro/studio). + * + * Body: `{ prompt: string; apply?: boolean }`. + * + * When `apply` is true (default), the returned tokens are written to the site + * and a rebuild is queued — single round-trip for the editor UI. + */ +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 } + ); + } + + if (!process.env.ANTHROPIC_API_KEY) { + return NextResponse.json( + { + error: + "AI theme is not configured. Set ANTHROPIC_API_KEY (and optionally ANTHROPIC_MODEL) on the server.", + code: "ai_unconfigured", + }, + { status: 503 } + ); + } + + 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 user = await prisma.user.findUniqueOrThrow({ + where: { id: auth.userId }, + include: { subscription: true }, + }); + + const plan = user.subscription?.plan || "free"; + const canCustomize = + user.isAdmin || user.isFriend || plan === "pro" || plan === "studio"; + if (!canCustomize) { + return NextResponse.json( + { error: "Upgrade to Pro to use the AI theme editor.", code: "plan_required" }, + { status: 403 } + ); + } + + let body: { prompt?: unknown; apply?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt || prompt.length > MAX_PROMPT_LEN) { + return NextResponse.json( + { error: "Prompt must be 1–6000 characters." }, + { status: 400 } + ); + } + const apply = body.apply !== false; + + let result; + try { + result = await generateAiSiteTheme({ + userPrompt: prompt, + templateSlug: site.template, + }); + } catch (err) { + console.error("[sites/theme/ai]", err); + const message = err instanceof Error ? err.message : "AI request failed"; + return NextResponse.json( + { error: message, code: "ai_error" }, + { status: 502 } + ); + } + + if (apply) { + await prisma.site.update({ + where: { id }, + data: { + themeColors: JSON.stringify(result.colors), + themeFonts: JSON.stringify(result.fonts), + }, + }); + after(() => + buildSite(id).catch((err) => { + console.error("Rebuild after AI theme apply failed", id, err); + }) + ); + } + + return NextResponse.json({ ...result, applied: apply }); +} diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx index cd4417794..99746e571 100644 --- a/src/app/sites/[id]/page.tsx +++ b/src/app/sites/[id]/page.tsx @@ -9,6 +9,7 @@ import { SearchInput } from "@/components/search-input"; import { IdeTextEditor } from "@/components/ide-text-editor"; import { ThemeTokensPillEditor } from "@/components/theme-tokens-pill-editor"; import { SegmentedControl } from "@/components/toolbar"; +import { SiteAiThemeBox } from "@/components/site-ai-theme-box"; import { SiteChannelSettingsCard } from "@/components/site-channel-settings-card"; import { SiteSettingsSkeleton } from "@/components/sites-dashboard-skeletons"; import { PlantIconFrame, SitePreviewColumn } from "@/components/site-preview-column"; @@ -865,6 +866,18 @@ export default function SiteSettingsPage() { )} {activeTab === "theme" && ( +
+ { + setColors(nextColors); + setFonts(nextFonts); + setThemeCssDraft(formatThemeCss(nextColors, nextFonts)); + setThemeCssError(""); + setPreviewRev((n) => n + 1); + }} + />
@@ -982,6 +995,7 @@ export default function SiteSettingsPage() {
)}
+
)}
diff --git a/src/components/site-ai-theme-box.tsx b/src/components/site-ai-theme-box.tsx new file mode 100644 index 000000000..f9918142f --- /dev/null +++ b/src/components/site-ai-theme-box.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { track } from "@/lib/track"; +import type { ThemeColors, ThemeFonts } from "@/lib/theme-css-tokens"; + +const MAX_HISTORY = 8; + +type HistoryEntry = { + prompt: string; + at: number; + notes: string | null; +}; + +function historyKey(siteId: string) { + return `tg.ai-theme.history.${siteId}`; +} + +function loadHistory(siteId: string): HistoryEntry[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(historyKey(siteId)); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (entry): entry is HistoryEntry => + !!entry && + typeof entry === "object" && + typeof (entry as { prompt?: unknown }).prompt === "string" && + typeof (entry as { at?: unknown }).at === "number" + ) + .slice(0, MAX_HISTORY); + } catch { + return []; + } +} + +function saveHistory(siteId: string, entries: HistoryEntry[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + historyKey(siteId), + JSON.stringify(entries.slice(0, MAX_HISTORY)) + ); + } catch { + /* ignore quota / disabled storage */ + } +} + +type ApplyState = + | { kind: "idle" } + | { kind: "running" } + | { kind: "applied"; at: number; notes: string | null } + | { kind: "error"; message: string }; + +/** + * Prompt-driven AI theme editor. Calls `POST /api/sites/[id]/theme/ai` + * with `apply: true`, which writes the generated tokens to the site and + * queues a rebuild server-side. Prompt history is per-browser (localStorage) + * to avoid a schema change for v1. + */ +export function SiteAiThemeBox({ + siteId, + canCustomize, + onApplied, +}: { + siteId: string; + canCustomize: boolean; + /** Invoked after the server confirms the theme was written — caller refetches tokens. */ + onApplied?: (result: { colors: ThemeColors; fonts: ThemeFonts; notes: string | null }) => void; +}) { + const [prompt, setPrompt] = useState(""); + const [state, setState] = useState({ kind: "idle" }); + const [history, setHistory] = useState([]); + + useEffect(() => { + setHistory(loadHistory(siteId)); + }, [siteId]); + + const pushHistory = useCallback( + (entry: HistoryEntry) => { + setHistory((prev) => { + const next = [entry, ...prev.filter((p) => p.prompt !== entry.prompt)].slice( + 0, + MAX_HISTORY + ); + saveHistory(siteId, next); + return next; + }); + }, + [siteId] + ); + + const runPrompt = async () => { + if (!prompt.trim() || state.kind === "running") return; + setState({ kind: "running" }); + try { + const res = await fetch(`/api/sites/${siteId}/theme/ai`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: prompt.trim(), apply: true }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error || `AI request failed (${res.status})`); + } + const data = (await res.json()) as { + colors: ThemeColors; + fonts: ThemeFonts; + customCss: string | null; + notes: string | null; + applied: boolean; + }; + pushHistory({ prompt: prompt.trim(), at: Date.now(), notes: data.notes }); + setState({ kind: "applied", at: Date.now(), notes: data.notes }); + track("ai-theme-applied", { siteId }); + onApplied?.({ colors: data.colors, fonts: data.fonts, notes: data.notes }); + } catch (err) { + setState({ + kind: "error", + message: err instanceof Error ? err.message : "AI request failed", + }); + } + }; + + if (!canCustomize) { + return ( +
+ The AI theme editor is available on Pro and Studio plans. +
+ ); + } + + return ( +
+
+

+ AI theme editor +

+ + edits write tokens + rebuild + +
+ +

+ Describe the look. The model returns a cohesive theme — colors and fonts — + and applies it to your site immediately. You can keep tweaking the values + below in theme.css after. +

+ +