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
114 changes: 114 additions & 0 deletions src/app/api/sites/[id]/theme/ai/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
14 changes: 14 additions & 0 deletions src/app/sites/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -865,6 +866,18 @@ export default function SiteSettingsPage() {
)}

{activeTab === "theme" && (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<SiteAiThemeBox
siteId={id}
canCustomize={!!canCustomize}
onApplied={({ colors: nextColors, fonts: nextFonts }) => {
setColors(nextColors);
setFonts(nextFonts);
setThemeCssDraft(formatThemeCss(nextColors, nextFonts));
setThemeCssError("");
setPreviewRev((n) => n + 1);
}}
/>
<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="flex shrink-0 flex-wrap items-center gap-2 border-b border-neutral-200 bg-neutral-50 px-3 py-2.5 dark:border-neutral-800 dark:bg-neutral-900/95">
<SegmentedControl<ThemeFileTab>
Expand Down Expand Up @@ -982,6 +995,7 @@ export default function SiteSettingsPage() {
</div>
)}
</div>
</div>
)}
</div>
<div className="hidden min-h-0 w-full min-w-0 flex-1 flex-col gap-2 md:flex md:min-h-0">
Expand Down
213 changes: 213 additions & 0 deletions src/components/site-ai-theme-box.tsx
Original file line number Diff line number Diff line change
@@ -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<ApplyState>({ kind: "idle" });
const [history, setHistory] = useState<HistoryEntry[]>([]);

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 (
<div className="rounded-lg border border-dashed border-neutral-300 p-4 text-xs text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">
The AI theme editor is available on Pro and Studio plans.
</div>
);
}

return (
<div className="space-y-3 rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-700 dark:bg-neutral-950">
<div className="flex items-baseline justify-between">
<h3 className="text-xs font-medium text-neutral-700 dark:text-neutral-200">
AI theme editor
</h3>
<span className="text-[11px] text-neutral-400 dark:text-neutral-500">
edits write tokens + rebuild
</span>
</div>

<p className="text-xs leading-relaxed text-neutral-500 dark:text-neutral-400">
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 <code className="font-mono text-[11px]">theme.css</code> after.
</p>

<textarea
value={prompt}
rows={3}
placeholder={
'e.g. "Warm, serif, mid-century book — cream background, deep red accent, generous line height."'
}
onChange={(e) => setPrompt(e.target.value)}
className="w-full resize-y rounded border border-neutral-200 bg-white px-2 py-1 font-mono text-xs dark:border-neutral-700 dark:bg-neutral-900"
/>

<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void runPrompt()}
disabled={!prompt.trim() || state.kind === "running"}
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"
>
{state.kind === "running" ? "Generating…" : "Apply theme"}
</button>
{state.kind === "applied" && (
<span className="text-[11px] text-green-600 dark:text-green-400">
Applied · rebuild queued
{state.notes ? ` · ${state.notes}` : ""}
</span>
)}
{state.kind === "error" && (
<span className="text-[11px] text-red-600 dark:text-red-400">
{state.message}
</span>
)}
</div>

{history.length > 0 && (
<details className="text-xs text-neutral-500 dark:text-neutral-400">
<summary className="cursor-pointer text-[11px] uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
Recent prompts ({history.length})
</summary>
<ul className="mt-2 space-y-1.5">
{history.map((entry) => (
<li key={`${entry.at}-${entry.prompt}`} className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => setPrompt(entry.prompt)}
className="truncate text-left text-xs text-neutral-700 underline-offset-2 hover:underline dark:text-neutral-200"
title={entry.prompt}
>
{entry.prompt}
</button>
{entry.notes && (
<span className="text-[11px] text-neutral-400 dark:text-neutral-500">
{entry.notes}
</span>
)}
</li>
))}
</ul>
</details>
)}
</div>
);
}