feat: add multi-app support with app switcher, per-app branding, and filtered queries

Apps share the same backend, API keys, and publishing flow but each gets its own
branding (name, colors, icon, app URL), knowledge files (brand identity, product
info, platform guidelines), and campaigns. The pipeline dynamically writes
_knowledge/ files and copies app assets before each run.

- Add App model with slug, colors, appUrl, and knowledge markdown fields
- Add appId FK to Campaign, seed honeyDue as first app with existing knowledge
- App switcher dropdown in sidebar with icon previews
- Filter campaigns, stats, and assets by active app (cookie-based)
- De-hardcode lib/claude.ts: AppConfig interface, templated prompts, dynamic
  _knowledge/ and Remotion asset copying
- App management pages (list, create, edit) with icon upload and color pickers
- Asset library sort options (newest, oldest, name, platform, type)
- Asset cards show creation date
- Remotion HoneyDueAd accepts colors/appName props

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-23 22:21:45 -05:00
parent 66c2bbec8b
commit 80a1ffbe4d
29 changed files with 1279 additions and 78 deletions
+47
View File
@@ -0,0 +1,47 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { slug } = await params;
const app = await prisma.app.findUnique({ where: { slug } });
if (!app) return Response.json({ error: "App not found" }, { status: 404 });
const formData = await request.formData();
const type = formData.get("type") as string; // "icon", "phone", or "screenshot"
const file = formData.get("file") as File | null;
if (!file || !file.type.startsWith("image/")) {
return Response.json({ error: "No valid image file provided" }, { status: 400 });
}
const appDir = path.join(PIPELINE_ROOT, "apps", slug);
const buffer = Buffer.from(await file.arrayBuffer());
if (type === "icon" || type === "phone") {
const filePath = path.join(appDir, `${type}.png`);
await mkdir(appDir, { recursive: true });
await writeFile(filePath, buffer);
return Response.json({ path: `apps/${slug}/${type}.png` });
}
if (type === "screenshot") {
const screenshotsDir = path.join(appDir, "screenshots");
await mkdir(screenshotsDir, { recursive: true });
const fileName = file.name || "screenshot.png";
const filePath = path.join(screenshotsDir, fileName);
await writeFile(filePath, buffer);
return Response.json({ path: `apps/${slug}/screenshots/${fileName}` });
}
return Response.json({ error: "Invalid type. Use: icon, phone, or screenshot" }, { status: 400 });
}
+74
View File
@@ -0,0 +1,74 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { slug } = await params;
const app = await prisma.app.findUnique({
where: { slug },
include: { _count: { select: { campaigns: true } } },
});
if (!app) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(app);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { slug } = await params;
const body = await request.json();
const app = await prisma.app.findUnique({ where: { slug } });
if (!app) return Response.json({ error: "Not found" }, { status: 404 });
const updated = await prisma.app.update({
where: { slug },
data: {
name: body.name ?? undefined,
description: body.description ?? undefined,
appUrl: body.appUrl ?? undefined,
primaryColor: body.primaryColor ?? undefined,
accentColor: body.accentColor ?? undefined,
darkBg: body.darkBg ?? undefined,
brandIdentity: body.brandIdentity ?? undefined,
productInfo: body.productInfo ?? undefined,
platformGuidelines: body.platformGuidelines ?? undefined,
},
});
return Response.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { slug } = await params;
const app = await prisma.app.findUnique({ where: { slug } });
if (!app) return Response.json({ error: "Not found" }, { status: 404 });
// Check for campaigns
const campaignCount = await prisma.campaign.count({ where: { appId: app.id } });
if (campaignCount > 0) {
return Response.json(
{ error: `Cannot delete app with ${campaignCount} campaign(s). Remove campaigns first.` },
{ status: 409 }
);
}
await prisma.app.delete({ where: { slug } });
return Response.json({ deleted: true });
}
+69
View File
@@ -0,0 +1,69 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { mkdirSync } from "fs";
import path from "path";
export async function GET() {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const apps = await prisma.app.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
slug: true,
description: true,
primaryColor: true,
accentColor: true,
darkBg: true,
createdAt: true,
_count: { select: { campaigns: true } },
},
});
return Response.json(apps);
}
export async function POST(request: Request) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const body = await request.json();
const { name, slug, description, appUrl, primaryColor, accentColor, darkBg, brandIdentity, productInfo, platformGuidelines } = body;
if (!name || !slug) {
return Response.json({ error: "Name and slug are required" }, { status: 400 });
}
// Validate slug format
if (!/^[a-z0-9-]+$/.test(slug)) {
return Response.json({ error: "Slug must be lowercase alphanumeric with hyphens only" }, { status: 400 });
}
const existing = await prisma.app.findUnique({ where: { slug } });
if (existing) {
return Response.json({ error: "An app with this slug already exists" }, { status: 409 });
}
const app = await prisma.app.create({
data: {
name,
slug,
description: description || null,
appUrl: appUrl || null,
primaryColor: primaryColor || "#0079FF",
accentColor: accentColor || "#FF9400",
darkBg: darkBg || "#1a1a2e",
brandIdentity: brandIdentity || null,
productInfo: productInfo || null,
platformGuidelines: platformGuidelines || null,
},
});
// Create filesystem structure
const pipelineRoot = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
mkdirSync(path.join(pipelineRoot, "apps", slug, "screenshots"), { recursive: true });
return Response.json(app, { status: 201 });
}