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
+4
View File
@@ -63,3 +63,7 @@ pipeline/remotion-ad/public/icon.png
# uploaded screenshots (user content)
pipeline/assets/screenshots/*.png
!pipeline/assets/screenshots/.gitkeep
# per-app assets and dynamic knowledge files
pipeline/apps/*/screenshots/*.png
pipeline/_knowledge/
+1 -1
View File
@@ -44,7 +44,7 @@ export default function LoginPage() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">honeyDue Marketing</CardTitle>
<CardTitle className="text-2xl">Marketing Command Center</CardTitle>
<CardDescription>Sign in to the command center</CardDescription>
</CardHeader>
<CardContent>
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { AppForm } from "@/components/app-form";
export default function EditAppPage() {
const params = useParams<{ slug: string }>();
const [data, setData] = useState<{
name: string;
slug: string;
description: string;
appUrl: string;
primaryColor: string;
accentColor: string;
darkBg: string;
brandIdentity: string;
productInfo: string;
platformGuidelines: string;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
fetch(`/api/apps/${params.slug}`)
.then((r) => {
if (!r.ok) throw new Error("Not found");
return r.json();
})
.then((app) => {
setData({
name: app.name,
slug: app.slug,
description: app.description || "",
appUrl: app.appUrl || "",
primaryColor: app.primaryColor,
accentColor: app.accentColor,
darkBg: app.darkBg,
brandIdentity: app.brandIdentity || "",
productInfo: app.productInfo || "",
platformGuidelines: app.platformGuidelines || "",
});
setLoading(false);
})
.catch(() => {
setError("App not found");
setLoading(false);
});
}, [params.slug]);
if (loading) return <div className="text-muted-foreground">Loading...</div>;
if (error) return <div className="text-red-500">{error}</div>;
if (!data) return null;
return (
<div className="mx-auto max-w-2xl">
<AppForm mode="edit" initialData={data} />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { AppForm } from "@/components/app-form";
export default function NewAppPage() {
return (
<div className="mx-auto max-w-2xl">
<AppForm mode="create" />
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface AppItem {
id: string;
name: string;
slug: string;
description: string | null;
primaryColor: string;
accentColor: string;
createdAt: string;
_count: { campaigns: number };
}
export default function AppsPage() {
const [apps, setApps] = useState<AppItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/apps")
.then((r) => r.json())
.then((data) => {
setApps(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Apps</h1>
<p className="text-muted-foreground">
Manage apps sharing this marketing pipeline
</p>
</div>
<Button render={<Link href="/apps/new" />}>
<Plus className="mr-2 h-4 w-4" />
Add App
</Button>
</div>
{loading ? (
<div className="text-muted-foreground">Loading...</div>
) : apps.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
No apps yet. Create one to get started.
</CardContent>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{apps.map((app) => (
<Link key={app.id} href={`/apps/${app.slug}`}>
<Card className="transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div
className="h-8 w-8 rounded-lg"
style={{ backgroundColor: app.primaryColor }}
/>
<div>
<CardTitle className="text-lg">{app.name}</CardTitle>
<CardDescription>/{app.slug}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
{app.description || "No description"}
</p>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: app.primaryColor }}
/>
<span>{app.primaryColor}</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: app.accentColor }}
/>
<span>{app.accentColor}</span>
</div>
<span className="ml-auto text-muted-foreground">
{app._count.campaigns} campaign{app._count.campaigns !== 1 ? "s" : ""}
</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+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 });
}
+8
View File
@@ -1,5 +1,6 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getActiveAppId } from "@/lib/active-app";
export async function GET(request: Request) {
const session = await auth();
@@ -12,6 +13,8 @@ export async function GET(request: Request) {
const status = searchParams.get("status");
const search = searchParams.get("search");
const appId = await getActiveAppId();
const where: Record<string, unknown> = {};
if (campaignId) where.campaignId = campaignId;
if (type && type !== "all") where.type = type;
@@ -24,6 +27,11 @@ export async function GET(request: Request) {
];
}
// Filter by active app's campaigns
if (appId) {
where.campaign = { ...((where.campaign as object) || {}), appId };
}
const assets = await prisma.asset.findMany({
where,
orderBy: { createdAt: "desc" },
+39 -16
View File
@@ -1,6 +1,6 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { buildCampaignPrompt, launchPipeline } from "@/lib/claude";
import { buildCampaignPrompt, launchPipeline, type AppConfig } from "@/lib/claude";
import path from "path";
import { mkdirSync } from "fs";
@@ -13,7 +13,10 @@ export async function POST(
const { id } = await params;
const campaign = await prisma.campaign.findUnique({ where: { id } });
const campaign = await prisma.campaign.findUnique({
where: { id },
include: { app: true },
});
if (!campaign) {
return Response.json({ error: "Not found" }, { status: 404 });
}
@@ -25,6 +28,23 @@ export async function POST(
);
}
// Build AppConfig from the campaign's app
let appConfig: AppConfig | undefined;
if (campaign.app) {
const app = campaign.app;
appConfig = {
name: app.name,
slug: app.slug,
primaryColor: app.primaryColor,
accentColor: app.accentColor,
darkBg: app.darkBg,
assetsDir: `apps/${app.slug}`,
brandIdentity: app.brandIdentity,
productInfo: app.productInfo,
platformGuidelines: app.platformGuidelines,
};
}
const config = campaign.config ? JSON.parse(campaign.config) : {};
const pipelineRoot =
process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
@@ -39,19 +59,22 @@ export async function POST(
mkdirSync(path.join(pipelineRoot, outputPath, dir), { recursive: true });
}
const prompt = buildCampaignPrompt({
name: campaign.name,
platforms: JSON.parse(campaign.platforms),
goal: config.goal || "brand awareness",
keyMessage: config.keyMessage || campaign.name,
socialProof: config.socialProof,
targetAudience: config.targetAudience,
visualDirection: config.visualDirection,
competitorApps: config.competitorApps,
variations: config.variations,
useTrendReport: config.useTrendReport,
screenshots: config.screenshots,
});
const prompt = buildCampaignPrompt(
{
name: campaign.name,
platforms: JSON.parse(campaign.platforms),
goal: config.goal || "brand awareness",
keyMessage: config.keyMessage || campaign.name,
socialProof: config.socialProof,
targetAudience: config.targetAudience,
visualDirection: config.visualDirection,
competitorApps: config.competitorApps,
variations: config.variations,
useTrendReport: config.useTrendReport,
screenshots: config.screenshots,
},
appConfig
);
await prisma.campaign.update({
where: { id },
@@ -59,7 +82,7 @@ export async function POST(
});
// Launch pipeline asynchronously — don't await
launchPipeline(id, prompt, pipelineRoot).catch((err) =>
launchPipeline(id, prompt, pipelineRoot, appConfig).catch((err) =>
console.error(`Pipeline failed for campaign ${id}:`, err)
);
+7
View File
@@ -1,11 +1,15 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getActiveAppId } from "@/lib/active-app";
export async function GET() {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const appId = await getActiveAppId();
const campaigns = await prisma.campaign.findMany({
where: appId ? { appId } : {},
orderBy: { createdAt: "desc" },
include: {
_count: { select: { assets: true, agentRuns: true } },
@@ -29,11 +33,14 @@ export async function POST(request: Request) {
);
}
const appId = await getActiveAppId();
const campaign = await prisma.campaign.create({
data: {
name,
platforms: JSON.stringify(platforms),
config: config ? JSON.stringify(config) : null,
appId,
},
});
+9 -2
View File
@@ -1,12 +1,17 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getActiveAppId } from "@/lib/active-app";
export async function GET() {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const appId = await getActiveAppId();
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const campaignWhere = appId ? { appId } : {};
const assetCampaignFilter = appId ? { campaign: { appId } } : {};
const [
activeCampaigns,
pendingReview,
@@ -15,21 +20,23 @@ export async function GET() {
trendReports,
] = await Promise.all([
prisma.campaign.count({
where: { status: { in: ["running", "review"] } },
where: { ...campaignWhere, status: { in: ["running", "review"] } },
}),
prisma.asset.count({
where: {
status: "draft",
campaign: { status: { in: ["review", "running"] } },
campaign: { ...campaignWhere, status: { in: ["review", "running"] } },
},
}),
prisma.asset.count({
where: {
...assetCampaignFilter,
status: "published",
createdAt: { gte: oneWeekAgo },
},
}),
prisma.campaign.findMany({
where: campaignWhere,
take: 5,
orderBy: { createdAt: "desc" },
select: { id: true, name: true, status: true, createdAt: true },
+11 -2
View File
@@ -1,4 +1,5 @@
import { auth } from "@/lib/auth";
import { getActiveApp } from "@/lib/active-app";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
@@ -17,7 +18,15 @@ export async function POST(request: Request) {
return Response.json({ error: "No files provided" }, { status: 400 });
}
const screenshotsDir = path.join(PIPELINE_ROOT, "assets", "screenshots");
// Determine upload path based on active app
const activeApp = await getActiveApp();
const screenshotsDir = activeApp
? path.join(PIPELINE_ROOT, "apps", activeApp.slug, "screenshots")
: path.join(PIPELINE_ROOT, "assets", "screenshots");
const relativeBase = activeApp
? `apps/${activeApp.slug}/screenshots`
: "assets/screenshots";
await mkdir(screenshotsDir, { recursive: true });
const uploaded: { fileName: string; path: string }[] = [];
@@ -34,7 +43,7 @@ export async function POST(request: Request) {
uploaded.push({
fileName: file.name,
path: `assets/screenshots/${uniqueName}`,
path: `${relativeBase}/${uniqueName}`,
});
}
+2 -2
View File
@@ -13,8 +13,8 @@ const jetbrainsMono = JetBrains_Mono({
});
export const metadata: Metadata = {
title: "honeyDue — Marketing Command Center",
description: "AI-powered marketing pipeline for honeyDue",
title: "Marketing Command Center",
description: "AI-powered marketing pipeline",
};
export default function RootLayout({
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { ActiveAppContext, type AppData } from "@/hooks/use-active-app";
function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function setCookie(name: string, value: string, days = 365) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
export function ActiveAppProvider({ children }: { children: React.ReactNode }) {
const [apps, setApps] = useState<AppData[]>([]);
const [activeApp, setActiveAppState] = useState<AppData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/apps")
.then((r) => {
if (!r.ok) return [];
return r.json();
})
.then((data) => {
if (!Array.isArray(data)) return;
setApps(data);
const savedSlug = getCookie("active-app");
const matched = data.find((a: AppData) => a.slug === savedSlug);
setActiveAppState(matched || data[0] || null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const setActiveApp = useCallback(
(slug: string) => {
setCookie("active-app", slug);
const app = apps.find((a) => a.slug === slug);
if (app) setActiveAppState(app);
// Refresh the page to update server-side queries
window.location.reload();
},
[apps]
);
return (
<ActiveAppContext value={{ apps, activeApp, setActiveApp, loading }}>
{children}
</ActiveAppContext>
);
}
+373
View File
@@ -0,0 +1,373 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { Upload, ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface AppFormProps {
mode: "create" | "edit";
initialData?: {
name: string;
slug: string;
description: string;
appUrl: string;
primaryColor: string;
accentColor: string;
darkBg: string;
brandIdentity: string;
productInfo: string;
platformGuidelines: string;
};
}
export function AppForm({ mode, initialData }: AppFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [name, setName] = useState(initialData?.name ?? "");
const [slug, setSlug] = useState(initialData?.slug ?? "");
const [description, setDescription] = useState(initialData?.description ?? "");
const [appUrl, setAppUrl] = useState(initialData?.appUrl ?? "");
const [primaryColor, setPrimaryColor] = useState(initialData?.primaryColor ?? "#0079FF");
const [accentColor, setAccentColor] = useState(initialData?.accentColor ?? "#FF9400");
const [darkBg, setDarkBg] = useState(initialData?.darkBg ?? "#1a1a2e");
const [brandIdentity, setBrandIdentity] = useState(initialData?.brandIdentity ?? "");
const [productInfo, setProductInfo] = useState(initialData?.productInfo ?? "");
const [platformGuidelines, setPlatformGuidelines] = useState(initialData?.platformGuidelines ?? "");
// Icon upload state
const iconInputRef = useRef<HTMLInputElement>(null);
const [iconUploading, setIconUploading] = useState(false);
const [iconVersion, setIconVersion] = useState(0); // bust cache after upload
const [iconError, setIconError] = useState("");
const canUpload = mode === "edit" && !!initialData?.slug;
const iconSrc = canUpload
? `/api/files/apps/${initialData!.slug}/icon.png?v=${iconVersion}`
: null;
async function handleIconUpload(file: File) {
if (!canUpload) return;
setIconUploading(true);
setIconError("");
const formData = new FormData();
formData.append("file", file);
formData.append("type", "icon");
const res = await fetch(`/api/apps/${initialData!.slug}/assets`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json();
setIconError(data.error || "Upload failed");
} else {
setIconVersion((v) => v + 1);
}
setIconUploading(false);
}
function autoSlug(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const body = {
name,
slug,
description,
appUrl: appUrl || null,
primaryColor,
accentColor,
darkBg,
brandIdentity: brandIdentity || null,
productInfo: productInfo || null,
platformGuidelines: platformGuidelines || null,
};
const url = mode === "create" ? "/api/apps" : `/api/apps/${initialData?.slug}`;
const method = mode === "create" ? "POST" : "PATCH";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "Something went wrong");
setLoading(false);
return;
}
router.push("/apps");
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{mode === "create" ? "Create App" : "Edit App"}</CardTitle>
<CardDescription>
{mode === "create"
? "Add a new app to the marketing pipeline"
: "Update app details and branding"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">App Name</Label>
<Input
id="name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (mode === "create") setSlug(autoSlug(e.target.value));
}}
placeholder="My App"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="my-app"
pattern="^[a-z0-9-]+$"
required
disabled={mode === "edit"}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the app"
/>
</div>
<div className="space-y-2">
<Label htmlFor="appUrl">App URL</Label>
<Input
id="appUrl"
type="url"
value={appUrl}
onChange={(e) => setAppUrl(e.target.value)}
placeholder="https://apps.apple.com/app/..."
/>
<p className="text-xs text-muted-foreground">
App Store or website URL. Used for QR codes in ad creatives.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="primaryColor">Primary Color</Label>
<div className="flex gap-2">
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="h-9 w-12 cursor-pointer rounded border"
/>
<Input
id="primaryColor"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="accentColor">Accent Color</Label>
<div className="flex gap-2">
<input
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="h-9 w-12 cursor-pointer rounded border"
/>
<Input
id="accentColor"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="darkBg">Dark Background</Label>
<div className="flex gap-2">
<input
type="color"
value={darkBg}
onChange={(e) => setDarkBg(e.target.value)}
className="h-9 w-12 cursor-pointer rounded border"
/>
<Input
id="darkBg"
value={darkBg}
onChange={(e) => setDarkBg(e.target.value)}
className="flex-1"
/>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>App Icon</CardTitle>
<CardDescription>
{canUpload
? "Square icon PNG used in every ad creative."
: "Save the app first, then upload the icon from the edit page."}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start gap-6">
{/* Icon preview */}
{iconSrc && (
<img
src={iconSrc}
alt={`${initialData?.name} icon`}
className="h-24 w-24 rounded-2xl border object-cover shadow-sm"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
{!iconSrc && (
<div className="flex h-24 w-24 items-center justify-center rounded-2xl border border-dashed bg-muted">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)}
{/* Upload button */}
<div className="space-y-2">
<input
ref={iconInputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleIconUpload(file);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={!canUpload || iconUploading}
onClick={() => iconInputRef.current?.click()}
>
<Upload className="mr-2 h-3.5 w-3.5" />
{iconUploading ? "Uploading..." : "Replace Icon"}
</Button>
<p className="text-xs text-muted-foreground">
Recommended: 1024x1024 PNG
</p>
{iconError && <p className="text-xs text-red-500">{iconError}</p>}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Knowledge Files</CardTitle>
<CardDescription>
Markdown content that agents read before generating content.
These replace the pipeline/knowledge/ files for this app.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="brandIdentity">Brand Identity</Label>
<Textarea
id="brandIdentity"
value={brandIdentity}
onChange={(e) => setBrandIdentity(e.target.value)}
placeholder="# Brand Identity&#10;&#10;## Personality&#10;..."
rows={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="productInfo">Product Info</Label>
<Textarea
id="productInfo"
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
placeholder="# Product & Campaign Knowledge&#10;&#10;## Overview&#10;..."
rows={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="platformGuidelines">Platform Guidelines</Label>
<Textarea
id="platformGuidelines"
value={platformGuidelines}
onChange={(e) => setPlatformGuidelines(e.target.value)}
placeholder="# Platform Guidelines&#10;&#10;## Instagram&#10;..."
rows={8}
/>
</div>
</CardContent>
</Card>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading
? mode === "create"
? "Creating..."
: "Saving..."
: mode === "create"
? "Create App"
: "Save Changes"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Cancel
</Button>
</div>
</form>
);
}
+77 -6
View File
@@ -9,6 +9,10 @@ import {
TrendingUp,
Calendar,
Settings,
ChevronsUpDown,
Check,
Plus,
AppWindow,
} from "lucide-react";
import {
Sidebar,
@@ -21,6 +25,14 @@ import {
SidebarMenuItem,
SidebarHeader,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useActiveApp } from "@/hooks/use-active-app";
const navItems = [
{ title: "Dashboard", href: "/", icon: LayoutDashboard },
@@ -28,21 +40,80 @@ const navItems = [
{ title: "Assets", href: "/assets", icon: Image },
{ title: "Trends", href: "/trends", icon: TrendingUp },
{ title: "Queue", href: "/queue", icon: Calendar },
{ title: "Apps", href: "/apps", icon: AppWindow },
{ title: "Settings", href: "/settings", icon: Settings },
];
export function AppSidebar() {
const pathname = usePathname();
const { apps, activeApp, setActiveApp } = useActiveApp();
return (
<Sidebar>
<SidebarHeader className="border-b px-6 py-4">
<Link href="/" className="flex items-center gap-2 font-semibold">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary">
<Megaphone className="h-4 w-4 text-primary-foreground" />
</div>
<span>honeyDue Marketing</span>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left font-semibold hover:bg-accent/50 focus:outline-none" />
}
>
{activeApp ? (
<img
src={`/api/files/apps/${activeApp.slug}/icon.png`}
alt={activeApp.name}
className="h-7 w-7 rounded-md object-cover"
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
el.nextElementSibling?.classList.remove("hidden");
}}
/>
) : null}
<div
className={`flex h-7 w-7 items-center justify-center rounded-md ${activeApp ? "hidden" : ""}`}
style={{ backgroundColor: activeApp?.primaryColor || "hsl(var(--primary))" }}
>
<Megaphone className="h-4 w-4 text-white" />
</div>
<span className="flex-1 truncate">
{activeApp?.name || "Select App"}
</span>
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-56">
{apps.map((app) => (
<DropdownMenuItem
key={app.slug}
onClick={() => setActiveApp(app.slug)}
className="flex items-center gap-2"
>
<img
src={`/api/files/apps/${app.slug}/icon.png`}
alt={app.name}
className="h-5 w-5 rounded object-cover"
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
if (el.nextElementSibling) el.nextElementSibling.classList.remove("hidden");
}}
/>
<div
className="hidden h-5 w-5 rounded"
style={{ backgroundColor: app.primaryColor }}
/>
<span className="flex-1">{app.name}</span>
{activeApp?.slug === app.slug && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link href="/apps/new" />} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span>Add App</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
+14 -5
View File
@@ -14,6 +14,7 @@ interface Asset {
dimensions?: string | null;
metadata?: string | null;
status: string;
createdAt: string;
campaign?: { name: string };
}
@@ -156,11 +157,19 @@ export function AssetCard({
</p>
)}
{asset.campaign && (
<p className="text-xs text-muted-foreground">
{asset.campaign.name}
</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{asset.campaign && <span>{asset.campaign.name}</span>}
{asset.campaign && asset.createdAt && <span>·</span>}
{asset.createdAt && (
<span>
{new Date(asset.createdAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
)}
</div>
{/* Actions — only for images and videos */}
{(isImage || isVideo) ? (
+35 -2
View File
@@ -15,6 +15,7 @@ interface Asset {
dimensions?: string | null;
metadata?: string | null;
status: string;
createdAt: string;
campaign?: { name: string };
}
@@ -32,6 +33,7 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
status: "all",
});
const [search, setSearch] = useState("");
const [sort, setSort] = useState("newest");
const fetchAssets = useCallback(() => {
const params = new URLSearchParams();
@@ -87,6 +89,24 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
setSelectedIds(new Set());
}
const sortedAssets = [...assets].sort((a, b) => {
switch (sort) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case "name-asc":
return a.fileName.localeCompare(b.fileName);
case "name-desc":
return b.fileName.localeCompare(a.fileName);
case "platform":
return (a.platform || "").localeCompare(b.platform || "");
case "type":
return a.type.localeCompare(b.type);
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
});
return (
<div className="space-y-4">
{/* Filters */}
@@ -132,6 +152,19 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
<option value="published">Published</option>
</select>
<select
className="h-9 rounded-md border px-3 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value)}
>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
<option value="name-asc">Name AZ</option>
<option value="name-desc">Name ZA</option>
<option value="platform">Platform</option>
<option value="type">Type</option>
</select>
<Input
placeholder="Search..."
value={search}
@@ -168,13 +201,13 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
</div>
{/* Grid */}
{assets.length === 0 ? (
{sortedAssets.length === 0 ? (
<p className="text-center text-muted-foreground py-12">
No assets yet. Launch a pipeline to generate content.
</p>
) : (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{assets.map((asset) => (
{sortedAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
+4 -1
View File
@@ -2,11 +2,14 @@
import { SessionProvider } from "next-auth/react";
import { SidebarProvider } from "@/components/ui/sidebar";
import { ActiveAppProvider } from "@/components/active-app-provider";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<SidebarProvider>{children}</SidebarProvider>
<ActiveAppProvider>
<SidebarProvider>{children}</SidebarProvider>
</ActiveAppProvider>
</SessionProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext } from "react";
export interface AppData {
id: string;
name: string;
slug: string;
description: string | null;
primaryColor: string;
accentColor: string;
darkBg: string;
}
interface ActiveAppContextValue {
apps: AppData[];
activeApp: AppData | null;
setActiveApp: (slug: string) => void;
loading: boolean;
}
export const ActiveAppContext = createContext<ActiveAppContextValue>({
apps: [],
activeApp: null,
setActiveApp: () => {},
loading: true,
});
export function useActiveApp() {
return useContext(ActiveAppContext);
}
+23
View File
@@ -0,0 +1,23 @@
import { cookies } from "next/headers";
import { prisma } from "./prisma";
import type { App } from "./generated/prisma/client";
const COOKIE_NAME = "active-app";
export async function getActiveApp(): Promise<App | null> {
const cookieStore = await cookies();
const slug = cookieStore.get(COOKIE_NAME)?.value;
if (slug) {
const app = await prisma.app.findUnique({ where: { slug } });
if (app) return app;
}
// Default to first app
return prisma.app.findFirst({ orderBy: { createdAt: "asc" } });
}
export async function getActiveAppId(): Promise<string | null> {
const app = await getActiveApp();
return app?.id ?? null;
}
+108 -34
View File
@@ -1,5 +1,5 @@
import { spawn } from "child_process";
import { mkdirSync } from "fs";
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
import path from "path";
import { EventEmitter } from "events";
import { prisma } from "./prisma";
@@ -21,6 +21,18 @@ const AGENT_STEPS = [
export type AgentName = (typeof AGENT_STEPS)[number];
export interface AppConfig {
name: string;
slug: string;
primaryColor: string;
accentColor: string;
darkBg: string;
assetsDir: string;
brandIdentity: string | null;
productInfo: string | null;
platformGuidelines: string | null;
}
interface CampaignConfig {
name: string;
platforms: string[];
@@ -38,15 +50,23 @@ interface CampaignConfig {
/**
* Build a detailed campaign prompt that gives each agent enough context.
*/
export function buildCampaignPrompt(config: CampaignConfig): string {
export function buildCampaignPrompt(config: CampaignConfig, appConfig?: AppConfig): string {
const platforms = config.platforms.join(", ");
const variations = config.variations ?? 5;
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
const outputDir = `outputs/${taskName}_${dateStr}`;
const appName = appConfig?.name ?? "honeyDue";
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
const accentColor = appConfig?.accentColor ?? "#FF9400";
const knowledgeDir = "_knowledge";
return `# Campaign Brief: "${config.name}"
## App
${appName}
## Goal
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
@@ -62,9 +82,13 @@ ${config.visualDirection ? `## Visual Direction\n${config.visualDirection}` : ""
${config.competitorApps ? `## Competitor Context\n${config.competitorApps}` : ""}
${config.screenshots?.length ? `## App Screenshots\nThe user provided ${config.screenshots.length} app screenshot(s) to showcase in the ads. These are real screenshots of the app feature being promoted.\nScreenshot files:\n${config.screenshots.map((p) => `- ${p}`).join("\n")}\n\nIMPORTANT: The ad-creative-designer agent MUST incorporate these screenshots into the static ad layouts. Use them as the hero visual — frame them in a device mockup or place them prominently in the ad composition. The screenshots show the actual feature being advertised, so they should be the centerpiece of the ad creative, not the AI-generated imagery.` : ""}
## Brand Colors
- Primary: ${primaryColor}
- Accent: ${accentColor}
## Content Requirements
- ${variations} hook variations per platform
- Static ads at exact platform dimensions (see knowledge/platform_guidelines.md)
- Static ads at exact platform dimensions (see ${knowledgeDir}/platform_guidelines.md)
- Video ads with platform-appropriate styles:
- Instagram: "polished" style — clean motion graphics
- TikTok: "authentic" style — raw, native feel
@@ -86,7 +110,7 @@ ${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most rec
## Instructions
Read CLAUDE.md first. Then execute each agent skill in order:
1. Read knowledge files (brand_identity.md, platform_guidelines.md, product_campaign.md)
1. Read knowledge files (${knowledgeDir}/brand_identity.md, ${knowledgeDir}/platform_guidelines.md, ${knowledgeDir}/product_campaign.md)
2. Run trend-scout skill
3. Run marketing-research-agent skill
4. Run script-writer skill
@@ -105,17 +129,24 @@ function buildAgentPrompt(
agentName: string,
campaignBrief: string,
outputDir: string,
appConfig?: AppConfig,
screenshots?: string[]
): string {
const appName = appConfig?.name ?? "honeyDue";
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
const accentColor = appConfig?.accentColor ?? "#FF9400";
const assetsDir = appConfig?.assetsDir ?? "assets";
const knowledgeDir = "_knowledge";
const agentInstructions: Record<string, string> = {
"trend-scout": `You are the Trend Scout agent.
Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
First, read these knowledge files:
- knowledge/brand_identity.md
- knowledge/platform_guidelines.md
- knowledge/product_campaign.md
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Then execute the Tavily research queries described in the skill.
Use the Bash tool to run: npx tavily search "your query here" OR write a Node.js script using @tavily/core.
@@ -129,9 +160,9 @@ ${campaignBrief}`,
Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
First, read these knowledge files:
- knowledge/brand_identity.md
- knowledge/platform_guidelines.md
- knowledge/product_campaign.md
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
@@ -149,9 +180,9 @@ ${campaignBrief}`,
Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
First, read these knowledge files:
- knowledge/brand_identity.md
- knowledge/platform_guidelines.md
- knowledge/product_campaign.md
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/trend_report.json
@@ -172,9 +203,9 @@ ${campaignBrief}`,
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
First, read these knowledge files:
- knowledge/brand_identity.md
- knowledge/platform_guidelines.md
- knowledge/product_campaign.md
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
@@ -191,16 +222,16 @@ Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length
CRITICAL — REFERENCE IMAGES:
The user provided real app screenshots that MUST be used as reference images in every generate call.
Screenshot absolute paths:
${screenshots.map((p) => `- \${CWD}/${p}`).join("\n")}
${screenshots.map((p) => `\${CWD}/${p}`).join("\n")}
Where \${CWD} is your current working directory (use pwd to get the absolute path).` : ""}
APP ICON: The honeyDue app icon is at assets/icon.png (honeycomb pattern with golden checkmark on dark navy). This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the honeyDue icon prominently near the branding/CTA area of every ad.
APP ICON: The ${appName} app icon is at ${assetsDir}/icon.png. This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the ${appName} icon prominently near the branding/CTA area of every ad.
For EACH ad, follow this EXACT sequence:
1. First call mcp__nanobanana__set_aspect_ratio to set the correct ratio for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
2. Then call mcp__nanobanana__gemini_generate_image with:
- prompt: A detailed description of the ad layout, headline text, brand colors (#0079FF blue, #FF9400 orange), and style${screenshots?.length ? `
- prompt: A detailed description of the ad layout, headline text, brand colors (${primaryColor} blue, ${accentColor} orange), and style${screenshots?.length ? `
- reference_images: ["/absolute/path/to/screenshot.png"] — use the REAL app screenshot so Gemini incorporates the actual UI, NOT a made-up version. The prompt should say "Use the provided reference image as the app screenshot shown on the phone screen in the ad. Do NOT change or recreate the app UI — use it exactly as provided."` : ""}
- output_path: the destination file path
3. Save to ${outputDir}/ads/gemini/
@@ -230,8 +261,8 @@ The philosophy should:
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
- Emphasize: visual expression over text, spatial communication, artistic interpretation, minimal words
- Stress meticulous craftsmanship — the final work must appear as though someone at the top of their field labored over every detail
- Draw from the campaign's soul: home maintenance as an act of care, the quiet anxiety of forgetting, the relief of being organized
- Brand palette as foundation: #0079FF blue, #FF9400 orange, warm off-white, dark navy
- Draw from the campaign's soul
- Brand palette as foundation: ${primaryColor}, ${accentColor}, warm off-white, dark navy
### Step B: Express the Philosophy as Poster Art
Using the philosophy, create each poster as a .png file. For each:
@@ -240,10 +271,10 @@ Using the philosophy, create each poster as a .png file. For each:
3. Use repeating patterns, perfect geometric shapes, systematic visual language
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
5. The campaign's hook text appears as a visual accent, not a headline block
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at assets/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at ${assetsDir}/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
7. Every element contained within canvas boundaries with proper margins — nothing overlaps, everything breathes
8. The result should look like it could hang in a gallery or appear in a design magazine
9. The honeyDue app icon (assets/icon.png — honeycomb with golden checkmark on dark navy) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
9. The ${appName} app icon (${assetsDir}/icon.png) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
### MANDATORY Typography & Sizing Rules (Social Media Readability)
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
@@ -288,9 +319,9 @@ ${campaignBrief}`,
Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
First, read these knowledge files:
- knowledge/brand_identity.md
- knowledge/platform_guidelines.md
- knowledge/product_campaign.md
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
@@ -299,7 +330,7 @@ Read the upstream outputs:
- ${outputDir}/ads/posters/manifest.json
## Phone Frame Asset
A real iPhone frame PNG is at: assets/phone.png (orange rim, dynamic island, transparent screen)
A real iPhone frame PNG is at: ${assetsDir}/phone.png (orange rim, dynamic island, transparent screen)
Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
${screenshots?.length ? `
## App Screenshots
@@ -338,9 +369,9 @@ ${campaignBrief}`,
Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
First, read these knowledge files:
- knowledge/brand_identity.md
- knowledge/platform_guidelines.md
- knowledge/product_campaign.md
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/research_results.json
@@ -362,8 +393,8 @@ ${campaignBrief}`,
Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
First, read these knowledge files:
- knowledge/brand_identity.md
- knowledge/platform_guidelines.md
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
Gather ALL outputs from the campaign:
- ${outputDir}/ads/ (all PNG files)
@@ -526,10 +557,47 @@ async function loadPipelineEnv(): Promise<Record<string, string>> {
return env;
}
/**
* Write the active app's knowledge files to pipeline/_knowledge/ for agents to read.
*/
function writeKnowledgeFiles(pipelineRoot: string, appConfig: AppConfig) {
const knowledgeDir = path.join(pipelineRoot, "_knowledge");
mkdirSync(knowledgeDir, { recursive: true });
if (appConfig.brandIdentity) {
writeFileSync(path.join(knowledgeDir, "brand_identity.md"), appConfig.brandIdentity, "utf-8");
}
if (appConfig.productInfo) {
writeFileSync(path.join(knowledgeDir, "product_campaign.md"), appConfig.productInfo, "utf-8");
}
if (appConfig.platformGuidelines) {
writeFileSync(path.join(knowledgeDir, "platform_guidelines.md"), appConfig.platformGuidelines, "utf-8");
}
}
/**
* Copy app's icon and phone frame to remotion-ad/public/ for video rendering.
*/
function copyAppAssetsToRemotion(pipelineRoot: string, appConfig: AppConfig) {
const remotionPublic = path.join(pipelineRoot, "remotion-ad", "public");
mkdirSync(remotionPublic, { recursive: true });
const iconSrc = path.join(pipelineRoot, appConfig.assetsDir, "icon.png");
const phoneSrc = path.join(pipelineRoot, appConfig.assetsDir, "phone.png");
if (existsSync(iconSrc)) {
copyFileSync(iconSrc, path.join(remotionPublic, "icon.png"));
}
if (existsSync(phoneSrc)) {
copyFileSync(phoneSrc, path.join(remotionPublic, "phone.png"));
}
}
export async function launchPipeline(
campaignId: string,
prompt: string,
cwd: string
cwd: string,
appConfig?: AppConfig
) {
// Load API keys from settings
const pipelineEnv = await loadPipelineEnv();
@@ -540,6 +608,12 @@ export async function launchPipeline(
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
const screenshots: string[] = campaignConfig.screenshots || [];
// Write knowledge files and copy assets for the active app
if (appConfig) {
writeKnowledgeFiles(cwd, appConfig);
copyAppAssetsToRemotion(cwd, appConfig);
}
// Create output directories
const dirs = ["ads", "scripts", "video", "copy"];
for (const dir of dirs) {
@@ -575,7 +649,7 @@ export async function launchPipeline(
const startTime = Date.now();
try {
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, screenshots);
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, appConfig, screenshots);
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
const durationMs = Date.now() - startTime;
+5 -3
View File
@@ -22,9 +22,11 @@ Seven agents running in sequence:
# Knowledge Files (READ FIRST)
Every agent MUST read these before generating ANY content:
- `knowledge/brand_identity.md` — tone, voice, personality, CTA patterns
- `knowledge/platform_guidelines.md` — per-platform specs and formatting
- `knowledge/product_campaign.md` — product details, features, campaign direction
- `_knowledge/brand_identity.md` — tone, voice, personality, CTA patterns
- `_knowledge/platform_guidelines.md` — per-platform specs and formatting
- `_knowledge/product_campaign.md` — product details, features, campaign direction
NOTE: The `_knowledge/` directory is dynamically written per-pipeline-run with the active app's content. The original `knowledge/` directory contains the honeyDue defaults.
# Available Tools
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

+16 -2
View File
@@ -8,6 +8,15 @@ import {
staticFile,
} from "remotion";
export interface AdColors {
primary?: string;
accent?: string;
dark?: string;
light?: string;
white?: string;
red?: string;
}
export interface HoneyDueAdProps {
platform: "instagram" | "tiktok";
hookText: string;
@@ -15,9 +24,11 @@ export interface HoneyDueAdProps {
ctaText: string;
proofText: string;
screenshotSrc: string;
colors?: AdColors;
appName?: string;
}
const COLORS = {
const DEFAULT_COLORS = {
primary: "#0079FF",
accent: "#FF9400",
dark: "#1a1a2e",
@@ -33,7 +44,10 @@ export const HoneyDueAd: React.FC<HoneyDueAdProps> = ({
ctaText,
proofText,
screenshotSrc,
colors,
appName = "honeyDue",
}) => {
const COLORS = { ...DEFAULT_COLORS, ...colors };
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
@@ -283,7 +297,7 @@ export const HoneyDueAd: React.FC<HoneyDueAdProps> = ({
textAlign: "center",
}}
>
honeyDue
{appName}
</div>
{/* Icon — 50% of canvas width, centered */}
+19
View File
@@ -15,6 +15,23 @@ model User {
createdAt DateTime @default(now())
}
model App {
id String @id @default(cuid())
name String
slug String @unique
description String?
primaryColor String @default("#0079FF")
accentColor String @default("#FF9400")
darkBg String @default("#1a1a2e")
appUrl String?
brandIdentity String?
productInfo String?
platformGuidelines String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaigns Campaign[]
}
model Campaign {
id String @id @default(cuid())
name String
@@ -23,6 +40,8 @@ model Campaign {
platforms String // JSON array: ["instagram","tiktok","nextdoor"]
config String? // JSON: full campaign config from form
outputPath String?
appId String?
app App? @relation(fields: [appId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agentRuns AgentRun[]
+69 -2
View File
@@ -1,13 +1,26 @@
import bcrypt from "bcryptjs";
import { PrismaClient } from "../lib/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { readFileSync, mkdirSync, copyFileSync, existsSync } from "fs";
import path from "path";
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL || "file:./data/marketing.db",
url: process.env.DATABASE_URL || "file:./prisma/data/marketing.db",
});
const prisma = new PrismaClient({ adapter });
function readKnowledgeFile(filename: string): string | null {
const filePath = path.join(__dirname, "..", "pipeline", "knowledge", filename);
try {
return readFileSync(filePath, "utf-8");
} catch {
console.warn(`Knowledge file not found: ${filePath}`);
return null;
}
}
async function main() {
// Seed admin user
const email = process.env.ADMIN_EMAIL || "admin@localhost";
const password = process.env.ADMIN_PASSWORD || "admin123";
@@ -20,8 +33,62 @@ async function main() {
name: "Admin",
},
});
console.log(`Admin user created: ${email}`);
// Seed honeyDue app
const brandIdentity = readKnowledgeFile("brand_identity.md");
const productInfo = readKnowledgeFile("product_campaign.md");
const platformGuidelines = readKnowledgeFile("platform_guidelines.md");
const app = await prisma.app.upsert({
where: { slug: "honeydue" },
update: {
brandIdentity,
productInfo,
platformGuidelines,
},
create: {
name: "honeyDue",
slug: "honeydue",
description: "Home maintenance tracking app",
primaryColor: "#0079FF",
accentColor: "#FF9400",
darkBg: "#1a1a2e",
brandIdentity,
productInfo,
platformGuidelines,
},
});
console.log(`App created: ${app.name} (${app.slug})`);
// Backfill existing campaigns with appId
const updated = await prisma.campaign.updateMany({
where: { appId: null },
data: { appId: app.id },
});
console.log(`Backfilled ${updated.count} campaigns with appId`);
// Copy assets to pipeline/apps/honeydue/
const pipelineRoot = path.join(__dirname, "..", "pipeline");
const appAssetsDir = path.join(pipelineRoot, "apps", "honeydue");
const screenshotsDir = path.join(appAssetsDir, "screenshots");
mkdirSync(screenshotsDir, { recursive: true });
const assetsToCopy = [
{ src: path.join(pipelineRoot, "assets", "icon.png"), dest: path.join(appAssetsDir, "icon.png") },
{ src: path.join(pipelineRoot, "assets", "phone.png"), dest: path.join(appAssetsDir, "phone.png") },
{ src: path.join(pipelineRoot, "assets", "screenshots", "tasks_overdue.png"), dest: path.join(screenshotsDir, "tasks_overdue.png") },
];
for (const { src, dest } of assetsToCopy) {
if (existsSync(src)) {
copyFileSync(src, dest);
console.log(`Copied ${path.basename(src)} → apps/honeydue/`);
} else {
console.warn(`Asset not found: ${src}`);
}
}
}
main()