From 66c2bbec8b54a7a22a1c94721b39b199822eebc9 Mon Sep 17 00:00:00 2001
From: Trey t Loading... Ready to launch?
+ Review your campaign details below, then launch the pipeline.
+ Loading...
+ No campaigns yet. Create your first one to get started.
+
+ {card.description}
+
+ No campaigns yet. Create your first one to get started.
+
+ No published assets yet. Approve assets and push them to Postiz.
+
+ {metadata.caption}
+
+ Configure your third-party integrations. Values are stored securely in
+ the database and override environment variables.
+
+ No trend reports yet. Run the Trend Scout agent to generate
+ reports.
+
+ {report.summary}
+ Asset Library
+
+ ))}
+
{campaign.name}
+ Campaigns
+
+ Dashboard
+
+ Publishing Queue
+
+
+
+ )}
+ Settings
+ Trend Reports
+
+ {reports.length === 0 ? (
+ $1
")
+ .replace(/^#{5}\s+(.+)$/gm, "$1
")
+ .replace(/^#{4}\s+(.+)$/gm, "$1
")
+ .replace(/^###\s+(.+)$/gm, "$1
")
+ .replace(/^##\s+(.+)$/gm, "$1
")
+ .replace(/^#\s+(.+)$/gm, "$1
")
+ // Bold and italic
+ .replace(/\*\*\*(.+?)\*\*\*/g, "$1")
+ .replace(/\*\*(.+?)\*\*/g, "$1")
+ .replace(/\*(.+?)\*/g, "$1")
+ // Code blocks
+ .replace(/```(\w*)\n([\s\S]*?)```/g, "
")
+ // Inline code
+ .replace(/`([^`]+)`/g, "$2$1")
+ // Unordered lists
+ .replace(/^[-*]\s+(.+)$/gm, "
")
+ // Line breaks to paragraphs
+ .replace(/\n\n+/g, "
")
+ // Single newlines in context
+ .replace(/\n/g, "
");
+
+ // Wrap consecutive
${html}
+`; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ path: string[] }> } +) { + const session = await auth(); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const { path: segments } = await params; + const filePath = path.join(PIPELINE_ROOT, ...segments); + + const resolved = path.resolve(filePath); + if (!resolved.startsWith(path.resolve(PIPELINE_ROOT))) { + return new Response("Forbidden", { status: 403 }); + } + + try { + const content = await readFile(resolved, "utf-8"); + const ext = path.extname(resolved).toLowerCase(); + + if (ext === ".md") { + return new Response(markdownToHtml(content), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + if (ext === ".json") { + const pretty = JSON.stringify(JSON.parse(content), null, 2); + return new Response( + `${pretty.replace(/`,
+ { headers: { "Content-Type": "text/html; charset=utf-8" } }
+ );
+ }
+
+ // HTML files served as-is
+ if (ext === ".html") {
+ return new Response(content, {
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ }
+
+ // Fallback: plain text
+ return new Response(content, {
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+}
diff --git a/app/api/nextdoor/route.ts b/app/api/nextdoor/route.ts
new file mode 100644
index 0000000..f9ddca7
--- /dev/null
+++ b/app/api/nextdoor/route.ts
@@ -0,0 +1,83 @@
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import {
+ createNextdoorCampaign,
+ createNextdoorAdGroup,
+ uploadNextdoorCreative,
+ createNextdoorAd,
+} from "@/lib/nextdoor";
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const { assetIds, budget, schedule, targeting, copy } = await request.json();
+
+ if (!assetIds?.length) {
+ return Response.json({ error: "assetIds required" }, { status: 400 });
+ }
+
+ try {
+ // Create campaign
+ const campaignResult = await createNextdoorCampaign(
+ `Auto Campaign ${new Date().toISOString().slice(0, 10)}`,
+ budget || 50,
+ schedule || {
+ startDate: new Date().toISOString(),
+ endDate: new Date(Date.now() + 7 * 86400000).toISOString(),
+ }
+ );
+ const ndCampaignId = campaignResult.createCampaign.campaign.id;
+
+ // Create ad group
+ const adGroupResult = await createNextdoorAdGroup(
+ ndCampaignId,
+ targeting || {}
+ );
+ const adGroupId = adGroupResult.createAdGroup.adGroup.id;
+
+ const results = [];
+
+ for (const id of assetIds) {
+ const asset = await prisma.asset.findUnique({ where: { id } });
+ if (!asset) continue;
+
+ try {
+ // Upload creative (requires public URL — would need Postiz upload first)
+ const creativeResult = await uploadNextdoorCreative(
+ `/api/files/${asset.filePath}`
+ );
+ const creativeId = creativeResult.createCreative.creative.id;
+
+ // Create ad
+ const metadata = asset.metadata ? JSON.parse(asset.metadata) : {};
+ await createNextdoorAd(adGroupId, creativeId, {
+ headline: metadata.headline || "Check this out",
+ body: metadata.caption || "",
+ ctaText: copy?.ctaText || "Learn More",
+ destinationUrl: copy?.destinationUrl || "https://example.com",
+ });
+
+ await prisma.asset.update({
+ where: { id },
+ data: { status: "published", publishedTo: JSON.stringify(["nextdoor"]) },
+ });
+
+ results.push({ id, status: "published" });
+ } catch (error) {
+ results.push({
+ id,
+ status: "failed",
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ return Response.json({ campaignId: ndCampaignId, adGroupId, results });
+ } catch (error) {
+ return Response.json(
+ { error: error instanceof Error ? error.message : "Failed" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/postiz/route.ts b/app/api/postiz/route.ts
new file mode 100644
index 0000000..08ea2ef
--- /dev/null
+++ b/app/api/postiz/route.ts
@@ -0,0 +1,61 @@
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { pushToPostiz, getPostizIntegrations } from "@/lib/postiz";
+
+export async function GET() {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ try {
+ const integrations = await getPostizIntegrations();
+ return Response.json(integrations);
+ } catch (error) {
+ return Response.json(
+ { error: error instanceof Error ? error.message : "Failed to fetch integrations" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const { assetIds, scheduledAt } = await request.json();
+
+ if (!assetIds?.length || !scheduledAt) {
+ return Response.json(
+ { error: "assetIds and scheduledAt required" },
+ { status: 400 }
+ );
+ }
+
+ const results = [];
+
+ for (const id of assetIds) {
+ const asset = await prisma.asset.findUnique({ where: { id } });
+ if (!asset) continue;
+
+ try {
+ const post = await pushToPostiz(asset, scheduledAt);
+
+ await prisma.asset.update({
+ where: { id },
+ data: {
+ status: "published",
+ postizPostId: post.id,
+ },
+ });
+
+ results.push({ id, status: "scheduled", postId: post.id });
+ } catch (error) {
+ results.push({
+ id,
+ status: "failed",
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ return Response.json({ results });
+}
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
new file mode 100644
index 0000000..d73dd84
--- /dev/null
+++ b/app/api/settings/route.ts
@@ -0,0 +1,57 @@
+import { auth } from "@/lib/auth";
+import {
+ getAllSettings,
+ saveSetting,
+ checkIntegrationStatus,
+ SETTINGS_CONFIG,
+ type SettingKey,
+} from "@/lib/settings";
+
+export async function GET(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const { searchParams } = new URL(request.url);
+ const includeStatus = searchParams.get("status") === "true";
+
+ const settings = await getAllSettings();
+
+ // Mask secret values for display
+ const masked: Record = {};
+ for (const [key, value] of Object.entries(settings)) {
+ const config = SETTINGS_CONFIG[key as SettingKey];
+ if (config && "secret" in config && config.secret && value) {
+ masked[key] = value.slice(0, 4) + "..." + value.slice(-4);
+ } else {
+ masked[key] = value;
+ }
+ }
+
+ const result: Record = { settings: masked };
+
+ if (includeStatus) {
+ result.status = await checkIntegrationStatus();
+ }
+
+ return Response.json(result);
+}
+
+export async function PUT(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const body = await request.json();
+ const { key, value } = body;
+
+ if (!key || typeof value !== "string") {
+ return Response.json({ error: "key and value required" }, { status: 400 });
+ }
+
+ if (!(key in SETTINGS_CONFIG)) {
+ return Response.json({ error: "Invalid setting key" }, { status: 400 });
+ }
+
+ await saveSetting(key as SettingKey, value);
+
+ return Response.json({ ok: true });
+}
diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts
new file mode 100644
index 0000000..c2bfe4b
--- /dev/null
+++ b/app/api/stats/route.ts
@@ -0,0 +1,50 @@
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+export async function GET() {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+ const [
+ activeCampaigns,
+ pendingReview,
+ publishedThisWeek,
+ recentCampaigns,
+ trendReports,
+ ] = await Promise.all([
+ prisma.campaign.count({
+ where: { status: { in: ["running", "review"] } },
+ }),
+ prisma.asset.count({
+ where: {
+ status: "draft",
+ campaign: { status: { in: ["review", "running"] } },
+ },
+ }),
+ prisma.asset.count({
+ where: {
+ status: "published",
+ createdAt: { gte: oneWeekAgo },
+ },
+ }),
+ prisma.campaign.findMany({
+ take: 5,
+ orderBy: { createdAt: "desc" },
+ select: { id: true, name: true, status: true, createdAt: true },
+ }),
+ prisma.trendReport.findMany({
+ orderBy: { createdAt: "desc" },
+ take: 10,
+ }),
+ ]);
+
+ return Response.json({
+ activeCampaigns,
+ pendingReview,
+ publishedThisWeek,
+ recentCampaigns,
+ trendReports,
+ });
+}
diff --git a/app/api/uploads/route.ts b/app/api/uploads/route.ts
new file mode 100644
index 0000000..be8c018
--- /dev/null
+++ b/app/api/uploads/route.ts
@@ -0,0 +1,42 @@
+import { auth } from "@/lib/auth";
+import { writeFile, mkdir } from "fs/promises";
+import path from "path";
+import { randomUUID } from "crypto";
+
+const PIPELINE_ROOT =
+ process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session) return new Response("Unauthorized", { status: 401 });
+
+ const formData = await request.formData();
+ const files = formData.getAll("files") as File[];
+
+ if (files.length === 0) {
+ return Response.json({ error: "No files provided" }, { status: 400 });
+ }
+
+ const screenshotsDir = path.join(PIPELINE_ROOT, "assets", "screenshots");
+ await mkdir(screenshotsDir, { recursive: true });
+
+ const uploaded: { fileName: string; path: string }[] = [];
+
+ for (const file of files) {
+ if (!file.type.startsWith("image/")) continue;
+
+ const ext = path.extname(file.name) || ".png";
+ const uniqueName = `${randomUUID()}${ext}`;
+ const filePath = path.join(screenshotsDir, uniqueName);
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ await writeFile(filePath, buffer);
+
+ uploaded.push({
+ fileName: file.name,
+ path: `assets/screenshots/${uniqueName}`,
+ });
+ }
+
+ return Response.json({ uploaded });
+}
diff --git a/app/globals.css b/app/globals.css
index c56032b..694c50b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -48,73 +48,74 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
+/* honeyDue palette — #0079FF blue primary, #FF9400 orange accent */
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
+ --background: oklch(0.98 0.002 90);
+ --foreground: oklch(0.17 0.02 260);
--card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
+ --card-foreground: oklch(0.17 0.02 260);
--popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.17 0.02 260);
+ --primary: oklch(0.55 0.22 255);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.95 0.01 90);
+ --secondary-foreground: oklch(0.17 0.02 260);
+ --muted: oklch(0.955 0.008 90);
+ --muted-foreground: oklch(0.50 0.02 260);
+ --accent: oklch(0.72 0.17 62);
+ --accent-foreground: oklch(1 0 0);
--destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --radius: 0.625rem;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --border: oklch(0.91 0.008 90);
+ --input: oklch(0.91 0.008 90);
+ --ring: oklch(0.55 0.22 255);
+ --chart-1: oklch(0.55 0.22 255);
+ --chart-2: oklch(0.72 0.17 62);
+ --chart-3: oklch(0.65 0.20 145);
+ --chart-4: oklch(0.55 0.17 27);
+ --chart-5: oklch(0.60 0.18 290);
+ --radius: 0.75rem;
+ --sidebar: oklch(0.99 0.002 90);
+ --sidebar-foreground: oklch(0.17 0.02 260);
+ --sidebar-primary: oklch(0.55 0.22 255);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.94 0.03 255);
+ --sidebar-accent-foreground: oklch(0.35 0.15 255);
+ --sidebar-border: oklch(0.91 0.008 90);
+ --sidebar-ring: oklch(0.55 0.22 255);
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
+ --background: oklch(0.16 0.02 260);
+ --foreground: oklch(0.96 0.005 90);
+ --card: oklch(0.21 0.02 260);
+ --card-foreground: oklch(0.96 0.005 90);
+ --popover: oklch(0.21 0.02 260);
+ --popover-foreground: oklch(0.96 0.005 90);
+ --primary: oklch(0.62 0.22 255);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.26 0.02 260);
+ --secondary-foreground: oklch(0.96 0.005 90);
+ --muted: oklch(0.26 0.02 260);
+ --muted-foreground: oklch(0.65 0.02 260);
+ --accent: oklch(0.72 0.17 62);
+ --accent-foreground: oklch(1 0 0);
--destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
+ --border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --ring: oklch(0.62 0.22 255);
+ --chart-1: oklch(0.62 0.22 255);
+ --chart-2: oklch(0.72 0.17 62);
+ --chart-3: oklch(0.65 0.20 145);
+ --chart-4: oklch(0.55 0.17 27);
+ --chart-5: oklch(0.60 0.18 290);
+ --sidebar: oklch(0.19 0.02 260);
+ --sidebar-foreground: oklch(0.96 0.005 90);
+ --sidebar-primary: oklch(0.62 0.22 255);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.28 0.04 255);
+ --sidebar-accent-foreground: oklch(0.85 0.10 255);
+ --sidebar-border: oklch(1 0 0 / 12%);
+ --sidebar-ring: oklch(0.62 0.22 255);
}
@layer base {
diff --git a/app/layout.tsx b/app/layout.tsx
index 976eb90..a2cc882 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,20 +1,20 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const inter = Inter({
+ variable: "--font-sans",
subsets: ["latin"],
});
-const geistMono = Geist_Mono({
+const jetbrainsMono = JetBrains_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "honeyDue — Marketing Command Center",
+ description: "AI-powered marketing pipeline for honeyDue",
};
export default function RootLayout({
@@ -25,7 +25,7 @@ export default function RootLayout({
return (
{children}
diff --git a/app/page.tsx b/app/page.tsx
deleted file mode 100644
index 3f36f7c..0000000
--- a/app/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import Image from "next/image";
-
-export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
-}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
new file mode 100644
index 0000000..290cb68
--- /dev/null
+++ b/components/app-sidebar.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import {
+ LayoutDashboard,
+ Megaphone,
+ Image,
+ TrendingUp,
+ Calendar,
+ Settings,
+} from "lucide-react";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarHeader,
+} from "@/components/ui/sidebar";
+
+const navItems = [
+ { title: "Dashboard", href: "/", icon: LayoutDashboard },
+ { title: "Campaigns", href: "/campaigns", icon: Megaphone },
+ { title: "Assets", href: "/assets", icon: Image },
+ { title: "Trends", href: "/trends", icon: TrendingUp },
+ { title: "Queue", href: "/queue", icon: Calendar },
+ { title: "Settings", href: "/settings", icon: Settings },
+];
+
+export function AppSidebar() {
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+
+ honeyDue Marketing
+
+
+
+
+ Navigation
+
+
+ {navItems.map((item) => (
+
+ }
+ isActive={
+ item.href === "/"
+ ? pathname === "/"
+ : pathname.startsWith(item.href)
+ }
+ >
+
+ {item.title}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/components/asset-card.tsx b/components/asset-card.tsx
new file mode 100644
index 0000000..208c7d5
--- /dev/null
+++ b/components/asset-card.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Check, X, Play } from "lucide-react";
+
+interface Asset {
+ id: string;
+ type: string;
+ platform?: string | null;
+ format?: string | null;
+ fileName: string;
+ filePath: string;
+ dimensions?: string | null;
+ metadata?: string | null;
+ status: string;
+ campaign?: { name: string };
+}
+
+interface AssetCardProps {
+ asset: Asset;
+ onStatusChange: (id: string, status: string) => void;
+ selected?: boolean;
+ onSelect?: (id: string) => void;
+}
+
+export function AssetCard({
+ asset,
+ onStatusChange,
+ selected,
+ onSelect,
+}: AssetCardProps) {
+ const metadata = asset.metadata ? JSON.parse(asset.metadata) : {};
+ const isImage = asset.type === "image" || asset.format === "png" || asset.format === "jpg";
+ const isVideo = asset.type === "video" || asset.format === "mp4";
+ const fileSrc = `/api/files/${asset.filePath}`;
+
+ const pathLower = asset.filePath.toLowerCase();
+ const source = pathLower.includes("/gemini/")
+ ? "Gemini"
+ : pathLower.includes("/posters/")
+ ? "Canvas Design"
+ : isVideo
+ ? "Remotion"
+ : isImage
+ ? "Playwright"
+ : null;
+
+ const sourceColors: Record = {
+ Gemini: "text-purple-600 border-purple-200 bg-purple-50",
+ "Canvas Design": "text-amber-600 border-amber-200 bg-amber-50",
+ Remotion: "text-blue-600 border-blue-200 bg-blue-50",
+ Playwright: "text-emerald-600 border-emerald-200 bg-emerald-50",
+ };
+
+ return (
+
+ {/* Preview */}
+ onSelect?.(asset.id)}
+ >
+ {isImage && (
+ e.stopPropagation()}
+ >
+
+
+ )}
+ {isVideo && (
+ <>
+
+ e.stopPropagation()}
+ className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/40 transition-colors group/play"
+ >
+
+
+
+
+ >
+ )}
+ {!isImage && !isVideo && (
+ e.stopPropagation()}
+ className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center hover:bg-muted/50 transition-colors"
+ >
+
+ {asset.type}
+
+
+ {asset.fileName}
+
+
+ )}
+
+
+ {/* Info */}
+
+
+ {source && (
+
+ {source}
+
+ )}
+ {asset.platform && (
+
+ {asset.platform}
+
+ )}
+ {asset.dimensions && (
+
+ {asset.dimensions}
+
+ )}
+
+ {asset.status}
+
+
+
+ {metadata.caption && (
+
+ {metadata.caption}
+
+ )}
+
+ {asset.campaign && (
+
+ {asset.campaign.name}
+
+ )}
+
+ {/* Actions — only for images and videos */}
+ {(isImage || isVideo) ? (
+
+
+
+
+ ) : (
+ Auto-accepted
+ )}
+
+
+ );
+}
diff --git a/components/asset-gallery.tsx b/components/asset-gallery.tsx
new file mode 100644
index 0000000..195683a
--- /dev/null
+++ b/components/asset-gallery.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { AssetCard } from "@/components/asset-card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+interface Asset {
+ id: string;
+ type: string;
+ platform?: string | null;
+ format?: string | null;
+ fileName: string;
+ filePath: string;
+ dimensions?: string | null;
+ metadata?: string | null;
+ status: string;
+ campaign?: { name: string };
+}
+
+interface AssetGalleryProps {
+ campaignId?: string;
+ onPushToPostiz?: (assetIds: string[]) => void;
+}
+
+export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps) {
+ const [assets, setAssets] = useState([]);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [filters, setFilters] = useState({
+ platform: "all",
+ type: "all",
+ status: "all",
+ });
+ const [search, setSearch] = useState("");
+
+ const fetchAssets = useCallback(() => {
+ const params = new URLSearchParams();
+ if (campaignId) params.set("campaignId", campaignId);
+ if (filters.platform !== "all") params.set("platform", filters.platform);
+ if (filters.type !== "all") params.set("type", filters.type);
+ if (filters.status !== "all") params.set("status", filters.status);
+ if (search) params.set("search", search);
+
+ fetch(`/api/assets?${params}`)
+ .then((r) => r.json())
+ .then(setAssets)
+ .catch(() => {});
+ }, [campaignId, filters, search]);
+
+ useEffect(() => {
+ fetchAssets();
+ }, [fetchAssets]);
+
+ async function handleStatusChange(id: string, status: string) {
+ await fetch(`/api/assets/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status }),
+ });
+ setAssets((prev) =>
+ prev.map((a) => (a.id === id ? { ...a, status } : a))
+ );
+ }
+
+ function toggleSelect(id: string) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
+ async function bulkUpdateStatus(status: string) {
+ await Promise.all(
+ Array.from(selectedIds).map((id) =>
+ fetch(`/api/assets/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status }),
+ })
+ )
+ );
+ setAssets((prev) =>
+ prev.map((a) => (selectedIds.has(a.id) ? { ...a, status } : a))
+ );
+ setSelectedIds(new Set());
+ }
+
+ return (
+
+ {/* Filters */}
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="h-9 w-48"
+ />
+
+ {selectedIds.size > 0 && (
+
+
+
+ {onPushToPostiz && (
+
+ )}
+
+ )}
+
+
+ {/* Grid */}
+ {assets.length === 0 ? (
+
+ No assets yet. Launch a pipeline to generate content.
+
+ ) : (
+
+ {assets.map((asset) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/campaign-form.tsx b/components/campaign-form.tsx
new file mode 100644
index 0000000..6bf67d1
--- /dev/null
+++ b/components/campaign-form.tsx
@@ -0,0 +1,450 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+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";
+import { ImagePlus, X, Loader2 } from "lucide-react";
+
+export const PLATFORMS = ["instagram", "tiktok", "nextdoor"] as const;
+export const GOALS = [
+ { value: "app_downloads", label: "App Downloads", description: "Drive installs from App Store and Google Play" },
+ { value: "brand_awareness", label: "Brand Awareness", description: "Maximize reach and impressions across platforms" },
+ { value: "engagement", label: "Engagement", description: "Boost likes, comments, shares, and saves" },
+] as const;
+
+export interface CampaignData {
+ id?: string;
+ name: string;
+ platforms: string[];
+ config: {
+ goal: string;
+ keyMessage: string;
+ socialProof?: string;
+ targetAudience?: string;
+ visualDirection?: string;
+ competitorApps?: string;
+ variations?: number;
+ useTrendReport?: boolean;
+ screenshots?: string[];
+ };
+}
+
+interface CampaignFormProps {
+ initialData?: CampaignData;
+ mode?: "create" | "edit";
+}
+
+export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps) {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [selectedPlatforms, setSelectedPlatforms] = useState(
+ initialData?.platforms || ["instagram"]
+ );
+ const [selectedGoal, setSelectedGoal] = useState(
+ initialData?.config.goal || "app_downloads"
+ );
+ const [screenshots, setScreenshots] = useState<
+ { file?: File; preview: string; uploadedPath?: string }[]
+ >(() => {
+ // Pre-populate from existing campaign screenshots
+ const existing = initialData?.config.screenshots || [];
+ return existing.map((p) => ({
+ preview: `/api/files/${p}`,
+ uploadedPath: p,
+ }));
+ });
+ const [uploading, setUploading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ function handleFilesSelected(files: FileList | null) {
+ if (!files) return;
+ const newScreenshots = Array.from(files)
+ .filter((f) => f.type.startsWith("image/"))
+ .map((file) => ({
+ file,
+ preview: URL.createObjectURL(file),
+ }));
+ setScreenshots((prev) => [...prev, ...newScreenshots]);
+ }
+
+ function removeScreenshot(index: number) {
+ setScreenshots((prev) => {
+ const next = [...prev];
+ if (next[index].preview.startsWith("blob:")) {
+ URL.revokeObjectURL(next[index].preview);
+ }
+ next.splice(index, 1);
+ return next;
+ });
+ }
+
+ async function uploadScreenshots(): Promise {
+ if (screenshots.length === 0) return [];
+
+ const needUpload = screenshots.filter((s) => !s.uploadedPath && s.file);
+ if (needUpload.length === 0) {
+ return screenshots.filter((s) => s.uploadedPath).map((s) => s.uploadedPath!);
+ }
+
+ setUploading(true);
+ const formData = new FormData();
+ for (const s of needUpload) {
+ formData.append("files", s.file!);
+ }
+
+ const res = await fetch("/api/uploads", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!res.ok) {
+ setUploading(false);
+ throw new Error("Failed to upload screenshots");
+ }
+
+ const data = await res.json();
+ const uploadedPaths: string[] = data.uploaded.map(
+ (u: { path: string }) => u.path
+ );
+
+ // Map uploaded paths back to screenshots
+ let uploadIdx = 0;
+ setScreenshots((prev) =>
+ prev.map((s) => {
+ if (!s.uploadedPath) {
+ return { ...s, uploadedPath: uploadedPaths[uploadIdx++] };
+ }
+ return s;
+ })
+ );
+
+ setUploading(false);
+
+ // Return all paths (previously uploaded + newly uploaded)
+ const allPaths: string[] = [];
+ let newIdx = 0;
+ for (const s of screenshots) {
+ if (s.uploadedPath) {
+ allPaths.push(s.uploadedPath);
+ } else {
+ allPaths.push(uploadedPaths[newIdx++]);
+ }
+ }
+ return allPaths;
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError("");
+
+ if (selectedPlatforms.length === 0) {
+ setError("Select at least one platform.");
+ return;
+ }
+
+ setLoading(true);
+
+ let screenshotPaths: string[] = [];
+ try {
+ screenshotPaths = await uploadScreenshots();
+ } catch {
+ setError("Failed to upload screenshots. Please try again.");
+ setLoading(false);
+ return;
+ }
+
+ const formData = new FormData(e.currentTarget);
+
+ const body = {
+ name: formData.get("name") as string,
+ platforms: selectedPlatforms,
+ config: {
+ goal: formData.get("goal") as string,
+ keyMessage: formData.get("keyMessage") as string,
+ socialProof: formData.get("socialProof") as string,
+ targetAudience: formData.get("targetAudience") as string,
+ visualDirection: formData.get("visualDirection") as string,
+ competitorApps: formData.get("competitorApps") as string,
+ variations: Number(formData.get("variations")) || 5,
+ useTrendReport: formData.get("useTrendReport") === "on",
+ screenshots: screenshotPaths,
+ },
+ };
+
+ const isEdit = mode === "edit" && initialData?.id;
+ const url = isEdit
+ ? `/api/campaigns/${initialData.id}`
+ : "/api/campaigns";
+ const method = isEdit ? "PATCH" : "POST";
+
+ const res = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(
+ isEdit
+ ? { name: body.name, platforms: JSON.stringify(body.platforms), config: JSON.stringify(body.config) }
+ : body
+ ),
+ });
+
+ if (res.ok) {
+ if (isEdit) {
+ router.refresh();
+ } else {
+ const campaign = await res.json();
+ router.push(`/campaigns/${campaign.id}`);
+ }
+ } else {
+ const data = await res.json().catch(() => null);
+ setError(data?.error || "Failed to save campaign. Please try again.");
+ setLoading(false);
+ }
+ }
+
+ function togglePlatform(platform: string) {
+ setSelectedPlatforms((prev) =>
+ prev.includes(platform)
+ ? prev.filter((p) => p !== platform)
+ : [...prev, platform]
+ );
+ }
+
+ return (
+
+
+ {mode === "edit" ? "Edit Campaign" : "New Campaign"}
+
+ {mode === "edit"
+ ? "Update campaign details before launching"
+ : "Configure your campaign and launch the AI pipeline"}
+
+
+
+
+
+
+ );
+}
diff --git a/components/claude-chat.tsx b/components/claude-chat.tsx
new file mode 100644
index 0000000..de81c3b
--- /dev/null
+++ b/components/claude-chat.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useRef, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { useClaudeChat } from "@/hooks/use-claude-chat";
+import { Send, Loader2 } from "lucide-react";
+
+export function ClaudeChat({ campaignId }: { campaignId: string }) {
+ const { messages, sendMessage, isStreaming } = useClaudeChat(campaignId);
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef(null);
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!input.trim() || isStreaming) return;
+ sendMessage(input.trim());
+ setInput("");
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit(e);
+ }
+ }
+
+ return (
+
+ {/* Messages */}
+
+ {messages.length === 0 && (
+
+ Chat with Claude about this campaign. Ask for edits, new variations,
+ or feedback on generated content.
+
+ )}
+ {messages.map((msg, i) => (
+
+
+ {msg.content}
+ {isStreaming && i === messages.length - 1 && msg.role === "assistant" && (
+
+ )}
+
+
+ ))}
+
+
+
+ {/* Input */}
+
+
+ );
+}
diff --git a/components/header.tsx b/components/header.tsx
new file mode 100644
index 0000000..c0cdbdc
--- /dev/null
+++ b/components/header.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { signOut, useSession } from "next-auth/react";
+import { SidebarTrigger } from "@/components/ui/sidebar";
+import { Separator } from "@/components/ui/separator";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+
+export function Header({ title }: { title?: string }) {
+ const { data: session } = useSession();
+ const initials = session?.user?.name
+ ? session.user.name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ : "U";
+
+ return (
+
+
+
+ {title && {title}
}
+
+
+
+
+ {initials}
+
+
+
+ signOut({ callbackUrl: "/login" })}
+ >
+ Sign out
+
+
+
+
+
+ );
+}
diff --git a/components/pipeline-progress.tsx b/components/pipeline-progress.tsx
new file mode 100644
index 0000000..2f54e3b
--- /dev/null
+++ b/components/pipeline-progress.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react";
+import type { AgentStatus } from "@/hooks/use-pipeline-progress";
+
+const AGENT_LABELS: Record = {
+ "trend-scout": "Trend Scout",
+ "marketing-research-agent": "Research Agent",
+ "script-writer": "Script Writer",
+ "ad-creative-designer": "Ad Creative Designer",
+ "video-ad-producer": "Video Ad Producer",
+ "copywriter-agent": "Copywriter",
+ "distribution-agent": "Distribution Agent",
+};
+
+function formatDuration(ms?: number) {
+ if (!ms) return "";
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
+ return `${Math.round(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
+}
+
+function StatusIcon({ status }: { status: AgentStatus["status"] }) {
+ switch (status) {
+ case "completed":
+ return ;
+ case "running":
+ return ;
+ case "failed":
+ return ;
+ default:
+ return ;
+ }
+}
+
+export function PipelineProgress({ agents }: { agents: AgentStatus[] }) {
+ return (
+
+ {agents.map((agent) => (
+
+
+
+
+ {AGENT_LABELS[agent.agentName] || agent.agentName}
+
+ {agent.outputSummary && (
+
+ {agent.outputSummary}
+
+ )}
+ {agent.error && (
+ {agent.error}
+ )}
+
+ {agent.durationMs && (
+
+ {formatDuration(agent.durationMs)}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/components/postiz-push-modal.tsx b/components/postiz-push-modal.tsx
new file mode 100644
index 0000000..550d013
--- /dev/null
+++ b/components/postiz-push-modal.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+interface PostizPushModalProps {
+ assetIds: string[];
+ onClose: () => void;
+}
+
+export function PostizPushModal({ assetIds, onClose }: PostizPushModalProps) {
+ const [scheduledAt, setScheduledAt] = useState(() => {
+ const d = new Date();
+ d.setHours(d.getHours() + 1);
+ return d.toISOString().slice(0, 16);
+ });
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+
+ async function handlePush() {
+ setLoading(true);
+
+ const res = await fetch("/api/postiz", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ assetIds,
+ scheduledAt: new Date(scheduledAt).toISOString(),
+ }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ const scheduled = data.results?.filter(
+ (r: { status: string }) => r.status === "scheduled"
+ ).length;
+ setResult(`${scheduled} asset(s) scheduled successfully.`);
+ setTimeout(onClose, 2000);
+ } else {
+ setResult(`Error: ${data.error || "Failed to push"}`);
+ }
+
+ setLoading(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/components/providers.tsx b/components/providers.tsx
new file mode 100644
index 0000000..8f6c237
--- /dev/null
+++ b/components/providers.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { SessionProvider } from "next-auth/react";
+import { SidebarProvider } from "@/components/ui/sidebar";
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..e4fed86
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,109 @@
+"use client"
+
+import * as React from "react"
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..40cac5f
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000..0e91f97
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..9d5ebbd
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,268 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..74da65c
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..6e1369e
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx
new file mode 100644
index 0000000..428a091
--- /dev/null
+++ b/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx
new file mode 100644
index 0000000..e2a75a6
--- /dev/null
+++ b/components/ui/sidebar.tsx
@@ -0,0 +1,723 @@
+"use client"
+
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { PanelLeftIcon } from "lucide-react"
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state"
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH_MOBILE = "18rem"
+const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = React.createContext(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}) {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = React.useState(false)
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen)
+ const open = openProp ?? _open
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
+ },
+ [setOpenProp, open]
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ dir,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-label",
+ sidebar: "group-label",
+ },
+ })
+}
+
+function SidebarGroupAction({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-action",
+ sidebar: "group-action",
+ },
+ })
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function SidebarMenuButton({
+ render,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+ } & VariantProps) {
+ const { isMobile, state } = useSidebar()
+ const comp = useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(sidebarMenuButtonVariants({ variant, size }), className),
+ },
+ props
+ ),
+ render: !tooltip ? render : ,
+ state: {
+ slot: "sidebar-menu-button",
+ sidebar: "menu-button",
+ size,
+ active: isActive,
+ },
+ })
+
+ if (!tooltip) {
+ return comp
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {comp}
+
+
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ render,
+ showOnHover = false,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ showOnHover?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-action",
+ sidebar: "menu-action",
+ },
+ })
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ })
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubButton({
+ render,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: useRender.ComponentProps<"a"> &
+ React.ComponentProps<"a"> & {
+ size?: "sm" | "md"
+ isActive?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn(
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-sub-button",
+ sidebar: "menu-sub-button",
+ size,
+ active: isActive,
+ },
+ })
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx
new file mode 100644
index 0000000..9280ee5
--- /dev/null
+++ b/components/ui/sonner.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/components/ui/table.tsx b/components/ui/table.tsx
new file mode 100644
index 0000000..8dc13ae
--- /dev/null
+++ b/components/ui/table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..56c4288
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
new file mode 100644
index 0000000..69e8a82
--- /dev/null
+++ b/components/ui/tooltip.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..225de58
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,64 @@
+version: "3.8"
+
+services:
+ app:
+ build: .
+ ports:
+ - "3000:3000"
+ environment:
+ - NEXTAUTH_URL=http://localhost:3000
+ - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
+ - ADMIN_EMAIL=${ADMIN_EMAIL}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD}
+ - DATABASE_URL=file:./prisma/data/marketing.db
+ - TAVILY_API_KEY=${TAVILY_API_KEY}
+ - POSTIZ_URL=http://postiz:5000
+ - POSTIZ_API_KEY=${POSTIZ_API_KEY}
+ - NEXTDOOR_API_TOKEN=${NEXTDOOR_API_TOKEN}
+ - NEXTDOOR_ADVERTISER_ID=${NEXTDOOR_ADVERTISER_ID}
+ - PIPELINE_ROOT=/app/pipeline
+ volumes:
+ - app-data:/app/prisma/data
+ - pipeline-outputs:/app/pipeline/outputs
+ - pipeline-knowledge:/app/pipeline/knowledge
+ depends_on:
+ - postiz
+
+ postiz:
+ image: ghcr.io/gitroomhq/postiz-app:latest
+ ports:
+ - "5000:5000"
+ environment:
+ - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postiz-db:5432/postiz
+ - REDIS_URL=redis://redis:6379
+ - NEXT_PUBLIC_BACKEND_URL=http://postiz:5000
+ - STORAGE_PROVIDER=local
+ - UPLOAD_DIRECTORY=/uploads
+ volumes:
+ - postiz-uploads:/uploads
+ - postiz-config:/config
+ depends_on:
+ - postiz-db
+ - redis
+
+ postiz-db:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - POSTGRES_DB=postiz
+ volumes:
+ - postiz-pgdata:/var/lib/postgresql/data
+
+ redis:
+ image: redis:7-alpine
+ volumes:
+ - redis-data:/data
+
+volumes:
+ app-data:
+ pipeline-outputs:
+ pipeline-knowledge:
+ postiz-uploads:
+ postiz-config:
+ postiz-pgdata:
+ redis-data:
diff --git a/docs/plans/2026-03-23-end-to-end-flow.md b/docs/plans/2026-03-23-end-to-end-flow.md
new file mode 100644
index 0000000..b231321
--- /dev/null
+++ b/docs/plans/2026-03-23-end-to-end-flow.md
@@ -0,0 +1,135 @@
+# End-to-End Flow: Marketing Command Center
+
+## Complete Pipeline Flow
+
+### 1. Setup (One-time)
+- User goes to **Settings** page
+- Configures API keys:
+ - **Tavily** — for trend/research agents (free: 1000 searches/mo)
+ - **Gemini** — for NanoBanana MCP image generation (~$0.04-0.13/image)
+ - **Postiz** — URL + API key for IG/TikTok publishing
+ - **Nextdoor** — API token + advertiser ID (optional)
+- User fills in knowledge files:
+ - `pipeline/knowledge/brand_identity.md` — brand voice, CTAs, personality
+ - `pipeline/knowledge/platform_guidelines.md` — platform specs
+ - `pipeline/knowledge/product_campaign.md` — product details, features
+
+### 2. Campaign Creation
+User fills out campaign form:
+- **Name** — campaign identifier
+- **Platforms** — Instagram, TikTok, Nextdoor (checkboxes)
+- **Goal** — app downloads, brand awareness, engagement
+- **Key Message** — the core value proposition
+- **Social Proof** — download count, rating, press
+- **Target Audience** — who we're reaching
+- **Visual Direction** — clean/bold/premium/warm/tech
+- **Competitor Apps** — for research context
+- **Variations** — hook count per platform (default 5)
+- **Use Trend Report** — incorporate latest trends
+
+### 3. Pipeline Launch
+User clicks "Launch Pipeline" → system:
+1. Creates output directory: `pipeline/outputs/{name}_{date}/`
+2. Creates subdirectories: `ads/`, `scripts/`, `video/`, `copy/`
+3. Loads API keys from Settings DB into environment
+4. Spawns 7 Claude subprocess agents sequentially
+5. Streams progress via SSE to the browser
+
+### 4. Agent Execution (7 Steps)
+
+#### Step 1: Trend Scout (~12s)
+- Reads knowledge files
+- Runs 5 Tavily searches (hooks, competitors, viral formats, pain points, timely angles)
+- Outputs: `trend_report.json`
+
+#### Step 2: Marketing Research Agent (~2min)
+- Reads knowledge files + trend_report.json
+- Runs 5 deep Tavily research queries
+- Outputs: `research_results.json`, `research_brief.md`, `interactive_report.html`
+
+#### Step 3: Script Writer (~1.5min)
+- Reads knowledge + research + trends
+- Writes 5 hook variations × 3 platform styles = 15 scripts
+- Hook-Body-CTA structure with timing cues for video
+- Outputs: `scripts/scripts_all.json`, `scripts/scripts_summary.md`
+
+#### Step 4: Ad Creative Designer (~3min)
+- Reads knowledge + scripts + research
+- For each ad variation:
+ 1. Calls NanoBanana MCP `generate_image` tool with campaign-aware prompt
+ 2. Builds HTML/CSS layout at exact dimensions with generated image + headline + CTA
+ 3. Launches Playwright → screenshots to PNG
+- Outputs: `ads/*.png` + `ads/ad_manifest.json`
+- Dimensions: IG Feed 1080x1080, IG Stories 1080x1920, ND Spotlight 1200x1200, ND Display 1200x628
+
+#### Step 5: Video Ad Producer (~5min)
+- Reads knowledge + scripts + ad manifest
+- Creates scene_plan.json per video (hook/problem/solution/proof/CTA scenes)
+- Creates/modifies Remotion compositions in `remotion-ad/src/`
+- Renders via `npx remotion render` → MP4 files
+- Outputs: `video/*.mp4` + `video/scene_plans.json`
+- Styles: polished (IG), authentic (TikTok), local (Nextdoor)
+
+#### Step 6: Copywriter Agent (~1min)
+- Reads knowledge + research + scripts
+- Writes platform-tuned captions: Hook → Value → CTA → Hashtags
+- Outputs: `copy/instagram_captions.json`, `copy/tiktok_captions.json`, `copy/nextdoor_posts.json`, `copy/copy_matrix.json`
+
+#### Step 7: Distribution Agent (~30s)
+- Gathers all outputs
+- Creates `Publish_manifest.md` with:
+ - All assets with file paths
+ - Recommended caption per asset
+ - Recommended posting schedule
+ - Review checklist
+- DOES NOT publish — waits for human approval
+
+### 5. Asset Scanning
+After pipeline completes:
+- Scanner reads all files in output directory
+- Creates Asset records in database
+- Infers: platform (from filename), dimensions, type
+- Loads metadata from adjacent JSON files
+- All assets start in "draft" status
+
+### 6. Review
+User goes to campaign Assets tab:
+- Grid view of all generated content
+- Images shown inline, videos play on hover
+- Filter by: platform, type, status
+- Each asset shows: platform badge, dimensions, caption preview
+- User clicks Approve or Reject per asset
+- Bulk select → Approve/Reject multiple
+
+### 7. Claude Chat (Feedback Loop)
+User goes to Claude Chat tab:
+- Chat with Claude about the campaign
+- "Make the hooks snarkier"
+- "Regenerate the TikTok ad with darker colors"
+- Claude has access to all pipeline files
+- New/modified assets appear after re-scan
+
+### 8. Publishing
+User selects approved assets → Push to Postiz:
+- Modal shows selected assets with schedule date/time picker
+- Postiz uploads media, creates scheduled posts
+- Assets marked as "published" with Postiz post ID
+
+For Nextdoor:
+- Separate "Push to Nextdoor" flow
+- Creates campaign → ad group → creative → ad via GraphQL API
+
+### 9. Queue
+Queue page shows all published/scheduled assets with status tracking.
+
+## Integration Requirements
+
+| Integration | Required? | API Key Source | What It Does |
+|------------|-----------|----------------|-------------|
+| Tavily | Yes | Settings page | Web research for trends + market analysis |
+| Google Gemini | Yes | Settings page | Powers NanoBanana MCP image generation |
+| Playwright | Built-in | npm package | HTML-to-PNG rendering for static ads |
+| Remotion | Built-in | npm package | React-to-MP4 video rendering |
+| Postiz | For publishing | Settings page | Instagram + TikTok scheduling |
+| Nextdoor | Optional | Settings page | Direct Nextdoor Ads API |
+| Claude Code | Required | Max plan OAuth | Pipeline engine (subprocess spawning) |
diff --git a/hooks/use-claude-chat.ts b/hooks/use-claude-chat.ts
new file mode 100644
index 0000000..588a8a5
--- /dev/null
+++ b/hooks/use-claude-chat.ts
@@ -0,0 +1,102 @@
+"use client";
+
+import { useState, useCallback } from "react";
+
+interface Message {
+ role: "user" | "assistant";
+ content: string;
+}
+
+export function useClaudeChat(campaignId: string) {
+ const [messages, setMessages] = useState([]);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [sessionId, setSessionId] = useState(null);
+
+ const sendMessage = useCallback(
+ async (content: string) => {
+ const userMessage: Message = { role: "user", content };
+ setMessages((prev) => [...prev, userMessage]);
+ setIsStreaming(true);
+
+ const assistantMessage: Message = { role: "assistant", content: "" };
+ setMessages((prev) => [...prev, assistantMessage]);
+
+ try {
+ const res = await fetch("/api/claude", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: content,
+ sessionId,
+ campaignId,
+ }),
+ });
+
+ if (!res.ok || !res.body) {
+ setMessages((prev) => {
+ const updated = [...prev];
+ updated[updated.length - 1] = {
+ role: "assistant",
+ content: "Failed to get response from Claude.",
+ };
+ return updated;
+ });
+ setIsStreaming(false);
+ return;
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.done) continue;
+ if (data.text) {
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ updated[updated.length - 1] = {
+ ...last,
+ content: last.content + data.text,
+ };
+ return updated;
+ });
+ }
+ if (data.sessionId) {
+ setSessionId(data.sessionId);
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+ }
+ } catch {
+ setMessages((prev) => {
+ const updated = [...prev];
+ updated[updated.length - 1] = {
+ role: "assistant",
+ content: "Connection error. Please try again.",
+ };
+ return updated;
+ });
+ }
+
+ setIsStreaming(false);
+ },
+ [campaignId, sessionId]
+ );
+
+ return { messages, sendMessage, isStreaming };
+}
diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts
new file mode 100644
index 0000000..2b0fe1d
--- /dev/null
+++ b/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/hooks/use-pipeline-progress.ts b/hooks/use-pipeline-progress.ts
new file mode 100644
index 0000000..e62e4d1
--- /dev/null
+++ b/hooks/use-pipeline-progress.ts
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export interface AgentStatus {
+ agentName: string;
+ status: "pending" | "running" | "completed" | "failed";
+ durationMs?: number;
+ outputSummary?: string;
+ error?: string;
+}
+
+const AGENT_NAMES = [
+ "trend-scout",
+ "marketing-research-agent",
+ "script-writer",
+ "ad-creative-designer",
+ "video-ad-producer",
+ "copywriter-agent",
+ "distribution-agent",
+];
+
+export function usePipelineProgress(campaignId: string | null) {
+ const [agents, setAgents] = useState(
+ AGENT_NAMES.map((name) => ({ agentName: name, status: "pending" }))
+ );
+ const [pipelineStatus, setPipelineStatus] = useState("idle");
+
+ useEffect(() => {
+ if (!campaignId) return;
+
+ const source = new EventSource(`/api/campaigns/${campaignId}/stream`);
+
+ source.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+
+ if (data.type === "pipeline_started") {
+ setPipelineStatus("running");
+ } else if (data.type === "agent_started") {
+ setAgents((prev) =>
+ prev.map((a) =>
+ a.agentName === data.agentName
+ ? { ...a, status: "running" }
+ : a
+ )
+ );
+ } else if (data.type === "agent_completed") {
+ setAgents((prev) =>
+ prev.map((a) =>
+ a.agentName === data.agentName
+ ? {
+ ...a,
+ status: "completed",
+ durationMs: data.durationMs,
+ outputSummary: data.outputSummary,
+ }
+ : a
+ )
+ );
+ } else if (data.type === "agent_failed") {
+ setAgents((prev) =>
+ prev.map((a) =>
+ a.agentName === data.agentName
+ ? { ...a, status: "failed", error: data.error }
+ : a
+ )
+ );
+ } else if (data.type === "pipeline_complete") {
+ setPipelineStatus(data.status || "complete");
+ }
+ } catch {
+ // ignore parse errors
+ }
+ };
+
+ source.onerror = () => {
+ source.close();
+ };
+
+ return () => source.close();
+ }, [campaignId]);
+
+ return { agents, pipelineStatus };
+}
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..eb5c2fc723d361759b6f333879cf8580bd1d8f3a
GIT binary patch
literal 337272
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