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
+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>
);
}