feat: complete marketing command center with pipeline, UI, and asset generation

- Dashboard with campaign management, asset gallery, and publishing queue
- 7-agent pipeline: trend scout, research, scripts, ad creative, video, copy, distribution
- Campaign form with screenshot upload, goal picker, platform selection
- Campaign detail view with Details/Pipeline/Assets/Chat tabs
- Two-set image generation: Gemini AI (NanoBanana MCP) + Canvas Design posters
- Remotion video rendering with phone.png frame and real screenshot alignment
- honeyDue branding: blue #0079FF, orange #FF9400, Inter font, warm off-white
- Asset cards with source badges (Gemini/Canvas/Remotion/Playwright)
- Markdown/JSON render endpoint for viewing pipeline outputs as HTML
- Settings page with Tavily, Gemini, Postiz, Nextdoor integration management
- Claude Chat for campaign feedback loop with streaming SSE
- Postiz publishing modal with scheduling
- Auth with NextAuth credentials + JWT sessions
- SQLite via Prisma with better-sqlite3 adapter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-23 21:05:26 -05:00
parent 6b08cfb73a
commit 66c2bbec8b
113 changed files with 12741 additions and 138 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
prisma/data
.env.local
.env
.git
+24
View File
@@ -39,3 +39,27 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# prisma generated client & local db
/lib/generated/prisma
prisma/data/
data/
# pipeline build artifacts
pipeline/package-lock.json
pipeline/remotion-ad/package-lock.json
pipeline/node_modules/
pipeline/remotion-ad/node_modules/
pipeline/outputs/*
!pipeline/outputs/.gitkeep
pipeline/.next/
pipeline/remotion-ad/public/tasks_overdue.png
pipeline/remotion-ad/public/phone.png
pipeline/remotion-ad/public/icon.png
# claude settings (local/personal)
.claude/settings.local.json
# uploaded screenshots (user content)
pipeline/assets/screenshots/*.png
!pipeline/assets/screenshots/.gitkeep
+35
View File
@@ -0,0 +1,35 @@
FROM node:20-alpine AS base
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Install Playwright browsers (for ad creative generation)
RUN npx playwright install chromium --with-deps
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/lib/generated ./lib/generated
# Copy the marketing pipeline
COPY pipeline/ ./pipeline/
EXPOSE 3000
CMD ["node", "server.js"]
+11
View File
@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
{children}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const result = await signIn("credentials", {
email: formData.get("email") as string,
password: formData.get("password") as string,
redirect: false,
});
if (result?.error) {
setError("Invalid email or password");
setLoading(false);
} else {
router.push("/");
router.refresh();
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">honeyDue Marketing</CardTitle>
<CardDescription>Sign in to the command center</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="admin@localhost"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { useState } from "react";
import { AssetGallery } from "@/components/asset-gallery";
import { PostizPushModal } from "@/components/postiz-push-modal";
export default function GlobalAssetsPage() {
const [pushModalIds, setPushModalIds] = useState<string[] | null>(null);
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Asset Library</h1>
<AssetGallery onPushToPostiz={(ids) => setPushModalIds(ids)} />
{pushModalIds && (
<PostizPushModal
assetIds={pushModalIds}
onClose={() => setPushModalIds(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { use, useState } from "react";
import { AssetGallery } from "@/components/asset-gallery";
import { PostizPushModal } from "@/components/postiz-push-modal";
export default function CampaignAssetsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [pushModalIds, setPushModalIds] = useState<string[] | null>(null);
return (
<>
<AssetGallery
campaignId={id}
onPushToPostiz={(ids) => setPushModalIds(ids)}
/>
{pushModalIds && (
<PostizPushModal
assetIds={pushModalIds}
onClose={() => setPushModalIds(null)}
/>
)}
</>
);
}
@@ -0,0 +1,14 @@
"use client";
import { use } from "react";
import { ClaudeChat } from "@/components/claude-chat";
export default function CampaignChatPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <ClaudeChat campaignId={id} />;
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { use } from "react";
const tabs = [
{ label: "Details", href: "" },
{ label: "Pipeline", href: "/pipeline" },
{ label: "Assets", href: "/assets" },
{ label: "Claude Chat", href: "/chat" },
];
export default function CampaignDetailLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const pathname = usePathname();
const basePath = `/campaigns/${id}`;
return (
<div className="space-y-6">
<nav className="flex gap-1 border-b">
{tabs.map((tab) => {
const href = `${basePath}${tab.href}`;
const isActive =
tab.href === ""
? pathname === basePath
: pathname.startsWith(href);
return (
<Link
key={tab.label}
href={href}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
</Link>
);
})}
</nav>
{children}
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CampaignForm, type CampaignData } from "@/components/campaign-form";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Play } from "lucide-react";
interface CampaignResponse {
id: string;
name: string;
status: string;
platforms: string;
config: string | null;
}
export default function CampaignDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const [campaign, setCampaign] = useState<CampaignResponse | null>(null);
const [launching, setLaunching] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch(`/api/campaigns/${id}`)
.then((r) => r.json())
.then((data) => {
setCampaign(data);
setSaved(false);
})
.catch(() => {});
}, [id]);
async function handleLaunch() {
setLaunching(true);
await fetch(`/api/campaigns/${id}/launch`, { method: "POST" });
router.push(`/campaigns/${id}/pipeline`);
}
if (!campaign) {
return <p className="text-muted-foreground">Loading...</p>;
}
const config = campaign.config ? JSON.parse(campaign.config) : {};
const platforms: string[] = JSON.parse(campaign.platforms);
const isDraft = campaign.status === "draft";
const initialData: CampaignData = {
id: campaign.id,
name: campaign.name,
platforms,
config: {
goal: config.goal || "app_downloads",
keyMessage: config.keyMessage || "",
socialProof: config.socialProof || "",
targetAudience: config.targetAudience || "",
visualDirection: config.visualDirection || "clean",
competitorApps: config.competitorApps || "",
variations: config.variations ?? 5,
useTrendReport: config.useTrendReport || false,
screenshots: config.screenshots || [],
},
};
return (
<div className="space-y-4">
{isDraft && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 p-4">
<div>
<p className="font-medium">Ready to launch?</p>
<p className="text-sm text-muted-foreground">
Review your campaign details below, then launch the pipeline.
</p>
</div>
<Button onClick={handleLaunch} disabled={launching}>
<Play className="mr-2 h-4 w-4" />
{launching ? "Launching..." : "Launch Pipeline"}
</Button>
</div>
)}
{!isDraft && (
<div className="flex items-center gap-2">
<Badge variant="secondary">{campaign.status}</Badge>
<span className="text-sm text-muted-foreground">
Campaign is {campaign.status} fields are read-only.
</span>
</div>
)}
<div className="max-w-2xl">
{isDraft ? (
<CampaignForm initialData={initialData} mode="edit" />
) : (
<ReadOnlyDetails data={initialData} />
)}
</div>
</div>
);
}
function ReadOnlyDetails({ data }: { data: CampaignData }) {
const goalLabels: Record<string, string> = {
app_downloads: "App Downloads",
brand_awareness: "Brand Awareness",
engagement: "Engagement",
};
const visualLabels: Record<string, string> = {
clean: "Clean & Minimal",
bold: "Bold & Vibrant",
premium: "Premium & Dark",
warm: "Warm & Friendly",
tech: "Tech & Modern",
};
const fields = [
{ label: "Campaign Name", value: data.name },
{ label: "Platforms", value: data.platforms.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(", ") },
{ label: "Goal", value: goalLabels[data.config.goal] || data.config.goal },
{ label: "Key Message", value: data.config.keyMessage },
{ label: "Social Proof", value: data.config.socialProof },
{ label: "Target Audience", value: data.config.targetAudience },
{ label: "Visual Direction", value: visualLabels[data.config.visualDirection || ""] || data.config.visualDirection },
{ label: "Competitor Apps", value: data.config.competitorApps },
{ label: "Variations", value: String(data.config.variations ?? 5) },
];
return (
<div className="rounded-lg border">
<div className="divide-y">
{fields.map((field) => (
<div key={field.label} className="flex gap-4 px-4 py-3">
<span className="w-40 shrink-0 text-sm font-medium text-muted-foreground">
{field.label}
</span>
<span className="text-sm">{field.value || "—"}</span>
</div>
))}
{data.config.screenshots && data.config.screenshots.length > 0 && (
<div className="flex gap-4 px-4 py-3">
<span className="w-40 shrink-0 text-sm font-medium text-muted-foreground">
Screenshots
</span>
<div className="flex gap-2">
{data.config.screenshots.map((path, i) => (
<img
key={i}
src={`/api/files/${path}`}
alt={`Screenshot ${i + 1}`}
className="h-24 rounded-md border object-cover"
/>
))}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
"use client";
import { use, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PipelineProgress } from "@/components/pipeline-progress";
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
import { Play } from "lucide-react";
interface Campaign {
id: string;
name: string;
status: string;
platforms: string;
agentRuns: Array<{
id: string;
agentName: string;
status: string;
durationMs?: number;
outputSummary?: string;
error?: string;
}>;
}
export default function CampaignPipelinePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [launching, setLaunching] = useState(false);
const { agents, pipelineStatus } = usePipelineProgress(
campaign?.status === "running" ? id : null
);
useEffect(() => {
fetch(`/api/campaigns/${id}`)
.then((r) => r.json())
.then(setCampaign)
.catch(() => {});
}, [id, pipelineStatus]);
async function handleLaunch() {
setLaunching(true);
await fetch(`/api/campaigns/${id}/launch`, { method: "POST" });
setCampaign((prev) => (prev ? { ...prev, status: "running" } : prev));
setLaunching(false);
}
if (!campaign) {
return <p className="text-muted-foreground">Loading...</p>;
}
// Use SSE agents if pipeline is running, otherwise use stored agentRuns
const displayAgents =
campaign.status === "running"
? agents
: campaign.agentRuns.length > 0
? campaign.agentRuns.map((r) => ({
agentName: r.agentName,
status: r.status as "pending" | "running" | "completed" | "failed",
durationMs: r.durationMs ?? undefined,
outputSummary: r.outputSummary ?? undefined,
error: r.error ?? undefined,
}))
: agents;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{campaign.name}</h2>
<Badge variant="secondary" className="mt-1">
{campaign.status}
</Badge>
</div>
{campaign.status === "draft" && (
<Button onClick={handleLaunch} disabled={launching}>
<Play className="mr-2 h-4 w-4" />
{launching ? "Launching..." : "Launch Pipeline"}
</Button>
)}
</div>
<PipelineProgress agents={displayAgents} />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { CampaignForm } from "@/components/campaign-form";
export default function NewCampaignPage() {
return (
<div className="mx-auto max-w-2xl">
<CampaignForm />
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Plus } from "lucide-react";
interface Campaign {
id: string;
name: string;
status: string;
platforms: string;
createdAt: string;
_count: { assets: number; agentRuns: number };
}
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "secondary",
running: "default",
review: "outline",
approved: "default",
published: "default",
};
export default function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
useEffect(() => {
fetch("/api/campaigns")
.then((r) => r.json())
.then(setCampaigns)
.catch(() => {});
}, []);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Campaigns</h1>
<Link href="/campaigns/new" className={buttonVariants()}>
<Plus className="mr-2 h-4 w-4" />
New Campaign
</Link>
</div>
{campaigns.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No campaigns yet. Create your first one to get started.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{campaigns.map((campaign) => {
const platforms = JSON.parse(campaign.platforms) as string[];
return (
<Link key={campaign.id} href={`/campaigns/${campaign.id}`}>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{campaign.name}</CardTitle>
<Badge variant={statusColors[campaign.status] || "secondary"}>
{campaign.status}
</Badge>
</div>
<CardDescription>
{platforms.join(", ")} &middot;{" "}
{campaign._count.assets} assets &middot;{" "}
{new Date(campaign.createdAt).toLocaleDateString()}
</CardDescription>
</CardHeader>
</Card>
</Link>
);
})}
</div>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { AppSidebar } from "@/components/app-sidebar";
import { Header } from "@/components/header";
import { Providers } from "@/components/providers";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Providers>
<div className="flex min-h-screen w-full">
<AppSidebar />
<div className="flex flex-1 flex-col">
<Header />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
</Providers>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Megaphone, Image, Clock, Plus } from "lucide-react";
import { useEffect, useState } from "react";
interface Stats {
activeCampaigns: number;
pendingReview: number;
publishedThisWeek: number;
recentCampaigns: Array<{
id: string;
name: string;
status: string;
createdAt: string;
}>;
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
.then(setStats)
.catch(() => {});
}, []);
const cards = [
{
title: "Active Campaigns",
value: stats?.activeCampaigns ?? 0,
icon: Megaphone,
description: "Currently running",
},
{
title: "Pending Review",
value: stats?.pendingReview ?? 0,
icon: Clock,
description: "Assets awaiting approval",
},
{
title: "Published This Week",
value: stats?.publishedThisWeek ?? 0,
icon: Image,
description: "Assets sent to platforms",
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Dashboard</h1>
<Link href="/campaigns/new" className={buttonVariants()}>
<Plus className="mr-2 h-4 w-4" />
New Campaign
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{card.title}
</CardTitle>
<card.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
<p className="text-xs text-muted-foreground">
{card.description}
</p>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Recent Campaigns</CardTitle>
<CardDescription>Your latest marketing campaigns</CardDescription>
</CardHeader>
<CardContent>
{stats?.recentCampaigns && stats.recentCampaigns.length > 0 ? (
<div className="space-y-3">
{stats.recentCampaigns.map((campaign) => (
<Link
key={campaign.id}
href={`/campaigns/${campaign.id}`}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50 transition-colors"
>
<span className="font-medium">{campaign.name}</span>
<Badge variant="secondary">{campaign.status}</Badge>
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No campaigns yet. Create your first one to get started.
</p>
)}
</CardContent>
</Card>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface QueueAsset {
id: string;
fileName: string;
platform?: string | null;
status: string;
postizPostId?: string | null;
metadata?: string | null;
createdAt: string;
campaign?: { name: string };
}
export default function QueuePage() {
const [assets, setAssets] = useState<QueueAsset[]>([]);
useEffect(() => {
fetch("/api/assets?status=published")
.then((r) => r.json())
.then(setAssets)
.catch(() => {});
}, []);
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Publishing Queue</h1>
<Card>
<CardHeader>
<CardTitle>Scheduled & Published</CardTitle>
<CardDescription>
Assets pushed to Postiz for publishing
</CardDescription>
</CardHeader>
<CardContent>
{assets.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No published assets yet. Approve assets and push them to Postiz.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Asset</TableHead>
<TableHead>Campaign</TableHead>
<TableHead>Platform</TableHead>
<TableHead>Status</TableHead>
<TableHead>Post ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assets.map((asset) => {
const metadata = asset.metadata
? JSON.parse(asset.metadata)
: {};
return (
<TableRow key={asset.id}>
<TableCell className="font-medium">
{asset.fileName}
{metadata.caption && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-1">
{metadata.caption}
</p>
)}
</TableCell>
<TableCell>{asset.campaign?.name || "—"}</TableCell>
<TableCell>
{asset.platform && (
<Badge variant="outline">{asset.platform}</Badge>
)}
</TableCell>
<TableCell>
<Badge variant="default">{asset.status}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{asset.postizPostId || "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+281
View File
@@ -0,0 +1,281 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { CheckCircle2, XCircle, Loader2, ExternalLink } from "lucide-react";
interface SettingsGroup {
name: string;
description: string;
docsUrl: string;
keys: string[];
}
interface SettingConfig {
label: string;
placeholder: string;
secret?: boolean;
}
const SETTINGS_GROUPS: SettingsGroup[] = [
{
name: "Postiz",
description:
"Self-hosted social media scheduling. Handles Instagram and TikTok publishing.",
docsUrl: "https://postiz.com",
keys: ["POSTIZ_URL", "POSTIZ_API_KEY"],
},
{
name: "Tavily",
description:
"AI-powered web research. Used by the Trend Scout and Research agents.",
docsUrl: "https://tavily.com",
keys: ["TAVILY_API_KEY"],
},
{
name: "Gemini",
description:
"Google Gemini powers NanoBanana MCP for AI image generation in static ads. ~$0.04-0.13/image.",
docsUrl: "https://aistudio.google.com/apikey",
keys: ["GEMINI_API_KEY"],
},
{
name: "Nextdoor",
description:
"Direct Nextdoor Ads API integration for local advertising.",
docsUrl: "https://developer.nextdoor.com",
keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"],
},
];
const SETTINGS_CONFIG: Record<string, SettingConfig> = {
POSTIZ_URL: { label: "Postiz URL", placeholder: "http://localhost:5000" },
POSTIZ_API_KEY: {
label: "Postiz API Key",
placeholder: "your-postiz-api-key",
secret: true,
},
TAVILY_API_KEY: {
label: "Tavily API Key",
placeholder: "tvly-...",
secret: true,
},
GEMINI_API_KEY: {
label: "Google Gemini API Key",
placeholder: "AIza...",
secret: true,
},
NEXTDOOR_API_TOKEN: {
label: "Nextdoor API Token",
placeholder: "your-nextdoor-token",
secret: true,
},
NEXTDOOR_ADVERTISER_ID: {
label: "Nextdoor Advertiser ID",
placeholder: "your-advertiser-id",
},
};
type IntegrationStatus = Record<
string,
{ connected: boolean; error?: string }
>;
export default function SettingsPage() {
const [settings, setSettings] = useState<Record<string, string>>({});
const [status, setStatus] = useState<IntegrationStatus>({});
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/settings?status=true")
.then((r) => r.json())
.then((data) => {
setSettings(data.settings || {});
setStatus(data.status || {});
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
async function handleSave(key: string) {
const value = editValues[key];
if (value === undefined) return;
setSaving(key);
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
});
if (res.ok) {
setSaved(key);
// Refresh settings
const data = await fetch("/api/settings?status=true").then((r) =>
r.json()
);
setSettings(data.settings || {});
setStatus(data.status || {});
setEditValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
setTimeout(() => setSaved(null), 2000);
}
setSaving(null);
}
function getGroupStatus(group: SettingsGroup): {
connected: boolean;
error?: string;
} {
const key = group.name.toLowerCase();
return status[key] || { connected: false, error: "Unknown" };
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground mt-1">
Configure your third-party integrations. Values are stored securely in
the database and override environment variables.
</p>
</div>
{SETTINGS_GROUPS.map((group) => {
const groupStatus = getGroupStatus(group);
return (
<Card key={group.name}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle>{group.name}</CardTitle>
{groupStatus.connected ? (
<Badge
variant="outline"
className="text-green-600 border-green-200 bg-green-50"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Connected
</Badge>
) : (
<Badge
variant="outline"
className="text-amber-600 border-amber-200 bg-amber-50"
>
<XCircle className="h-3 w-3 mr-1" />
{groupStatus.error || "Not connected"}
</Badge>
)}
</div>
<a
href={group.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
Docs
<ExternalLink className="h-3 w-3" />
</a>
</div>
<CardDescription>{group.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{group.keys.map((key) => {
const config = SETTINGS_CONFIG[key];
const currentValue = settings[key] || "";
const isEditing = key in editValues;
const editValue = editValues[key] ?? "";
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{config.label}</Label>
<div className="flex gap-2">
<Input
id={key}
type={config.secret && !isEditing ? "password" : "text"}
placeholder={config.placeholder}
value={isEditing ? editValue : currentValue}
onChange={(e) =>
setEditValues((prev) => ({
...prev,
[key]: e.target.value,
}))
}
onFocus={() => {
if (!isEditing) {
// Clear masked value on focus for secret fields
setEditValues((prev) => ({
...prev,
[key]: "",
}));
}
}}
/>
{isEditing && (
<Button
onClick={() => handleSave(key)}
disabled={saving === key}
size="sm"
className="shrink-0"
>
{saving === key ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : saved === key ? (
<CheckCircle2 className="h-4 w-4" />
) : (
"Save"
)}
</Button>
)}
{isEditing && (
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() =>
setEditValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
})
}
>
Cancel
</Button>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
);
})}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface TrendReport {
id: string;
name: string;
filePath: string;
summary?: string | null;
createdAt: string;
}
export default function TrendsPage() {
const [reports, setReports] = useState<TrendReport[]>([]);
const [selectedReport, setSelectedReport] = useState<TrendReport | null>(null);
useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
.then((data) => {
if (data.trendReports) setReports(data.trendReports);
})
.catch(() => {});
}, []);
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Trend Reports</h1>
{reports.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No trend reports yet. Run the Trend Scout agent to generate
reports.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{reports.map((report) => (
<Card
key={report.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setSelectedReport(report)}
>
<CardHeader>
<CardTitle className="text-lg">{report.name}</CardTitle>
<CardDescription>
{new Date(report.createdAt).toLocaleDateString()}
</CardDescription>
</CardHeader>
{report.summary && (
<CardContent>
<p className="text-sm text-muted-foreground">
{report.summary}
</p>
</CardContent>
)}
<CardContent>
<Link
href={`/campaigns/new?trendReport=${report.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Create Campaign from This
</Link>
</CardContent>
</Card>
))}
</div>
)}
{/* Report Viewer */}
{selectedReport && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{selectedReport.name}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedReport(null)}
>
Close
</Button>
</div>
</CardHeader>
<CardContent>
<iframe
src={`/api/files/${selectedReport.filePath}`}
className="w-full h-[600px] border rounded-md"
title={selectedReport.name}
/>
</CardContent>
</Card>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const body = await request.json();
const asset = await prisma.asset.update({
where: { id },
data: body,
});
return Response.json(asset);
}
+34
View File
@@ -0,0 +1,34 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
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 campaignId = searchParams.get("campaignId");
const type = searchParams.get("type");
const platform = searchParams.get("platform");
const status = searchParams.get("status");
const search = searchParams.get("search");
const where: Record<string, unknown> = {};
if (campaignId) where.campaignId = campaignId;
if (type && type !== "all") where.type = type;
if (platform && platform !== "all") where.platform = platform;
if (status && status !== "all") where.status = status;
if (search) {
where.OR = [
{ fileName: { contains: search } },
{ metadata: { contains: search } },
];
}
const assets = await prisma.asset.findMany({
where,
orderBy: { createdAt: "desc" },
include: { campaign: { select: { name: true } } },
});
return Response.json(assets);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
+67
View File
@@ -0,0 +1,67 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { buildCampaignPrompt, launchPipeline } from "@/lib/claude";
import path from "path";
import { mkdirSync } from "fs";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const campaign = await prisma.campaign.findUnique({ where: { id } });
if (!campaign) {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (campaign.status !== "draft") {
return Response.json(
{ error: "Campaign is not in draft status" },
{ status: 400 }
);
}
const config = campaign.config ? JSON.parse(campaign.config) : {};
const pipelineRoot =
process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const taskName = campaign.name.replace(/\s+/g, "_").toLowerCase();
const outputPath = `outputs/${taskName}_${dateStr}`;
// Create output directories
const dirs = ["ads", "scripts", "video", "copy"];
for (const dir of dirs) {
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,
});
await prisma.campaign.update({
where: { id },
data: { prompt, outputPath },
});
// Launch pipeline asynchronously — don't await
launchPipeline(id, prompt, pipelineRoot).catch((err) =>
console.error(`Pipeline failed for campaign ${id}:`, err)
);
return Response.json({ status: "launched", outputPath });
}
+44
View File
@@ -0,0 +1,44 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const campaign = await prisma.campaign.findUnique({
where: { id },
include: {
agentRuns: { orderBy: { createdAt: "asc" } },
assets: { orderBy: { createdAt: "desc" } },
},
});
if (!campaign) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(campaign);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const body = await request.json();
const campaign = await prisma.campaign.update({
where: { id },
data: body,
});
return Response.json(campaign);
}
+30
View File
@@ -0,0 +1,30 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { scanOutputDirectory } from "@/lib/scanner";
import path from "path";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const campaign = await prisma.campaign.findUnique({ where: { id } });
if (!campaign) {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (!campaign.outputPath) {
return Response.json({ error: "No output path set" }, { status: 400 });
}
const pipelineRoot =
process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
const result = await scanOutputDirectory(id, campaign.outputPath, pipelineRoot);
return Response.json(result);
}
+44
View File
@@ -0,0 +1,44 @@
import { auth } from "@/lib/auth";
import { pipelineEvents } from "@/lib/claude";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const handler = (event: Record<string, unknown>) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
);
};
pipelineEvents.on(id, handler);
// Keep connection alive
const keepAlive = setInterval(() => {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
}, 15000);
request.signal.addEventListener("abort", () => {
pipelineEvents.off(id, handler);
clearInterval(keepAlive);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
+41
View File
@@ -0,0 +1,41 @@
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 campaigns = await prisma.campaign.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: { select: { assets: true, agentRuns: true } },
},
});
return Response.json(campaigns);
}
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, platforms, config } = body;
if (!name || !platforms) {
return Response.json(
{ error: "Name and platforms are required" },
{ status: 400 }
);
}
const campaign = await prisma.campaign.create({
data: {
name,
platforms: JSON.stringify(platforms),
config: config ? JSON.stringify(config) : null,
},
});
return Response.json(campaign, { status: 201 });
}
+66
View File
@@ -0,0 +1,66 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendChatMessage } from "@/lib/claude";
import path from "path";
export async function POST(request: Request) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { message, sessionId, campaignId } = await request.json();
if (!message || !campaignId) {
return Response.json(
{ error: "message and campaignId required" },
{ status: 400 }
);
}
const campaign = await prisma.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) {
return Response.json({ error: "Campaign not found" }, { status: 404 });
}
const pipelineRoot =
process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
// Get or create ClaudeSession
let claudeSession = await prisma.claudeSession.findFirst({
where: { campaignId },
orderBy: { updatedAt: "desc" },
});
if (!claudeSession) {
claudeSession = await prisma.claudeSession.create({
data: { campaignId },
});
}
// Save user message
const existingMessages = claudeSession.messages
? JSON.parse(claudeSession.messages)
: [];
existingMessages.push({ role: "user", content: message });
await prisma.claudeSession.update({
where: { id: claudeSession.id },
data: { messages: JSON.stringify(existingMessages) },
});
const stream = await sendChatMessage(
claudeSession.sessionId || sessionId,
message,
pipelineRoot
);
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
+83
View File
@@ -0,0 +1,83 @@
import { readFile, stat } from "fs/promises";
import path from "path";
import { auth } from "@/lib/auth";
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
const CONTENT_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".html": "text/html",
".json": "application/json",
".md": "text/markdown",
".txt": "text/plain",
".css": "text/css",
".js": "text/javascript",
".svg": "image/svg+xml",
};
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);
// Security: prevent path traversal
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(PIPELINE_ROOT))) {
return new Response("Forbidden", { status: 403 });
}
try {
const fileStat = await stat(resolved);
const ext = path.extname(resolved).toLowerCase();
const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
const fileSize = fileStat.size;
// Handle range requests (needed for video seeking/thumbnails)
const range = request.headers.get("range");
if (range) {
const match = range.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
const buffer = await readFile(resolved);
const chunk = buffer.subarray(start, end + 1);
return new Response(chunk, {
status: 206,
headers: {
"Content-Type": contentType,
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Content-Length": String(chunk.length),
"Accept-Ranges": "bytes",
"Cache-Control": "no-cache",
},
});
}
}
const buffer = await readFile(resolved);
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Content-Length": String(fileSize),
"Accept-Ranges": "bytes",
"Cache-Control": "no-cache",
},
});
} catch {
return new Response("Not found", { status: 404 });
}
}
+147
View File
@@ -0,0 +1,147 @@
import { readFile } from "fs/promises";
import path from "path";
import { auth } from "@/lib/auth";
const PIPELINE_ROOT =
process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
function markdownToHtml(md: string): string {
let html = md
// Headers
.replace(/^#{6}\s+(.+)$/gm, "<h6>$1</h6>")
.replace(/^#{5}\s+(.+)$/gm, "<h5>$1</h5>")
.replace(/^#{4}\s+(.+)$/gm, "<h4>$1</h4>")
.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>")
.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>")
.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>")
// Bold and italic
.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>")
// Inline code
.replace(/`([^`]+)`/g, "<code>$1</code>")
// Unordered lists
.replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>")
// Ordered lists
.replace(/^\d+\.\s+(.+)$/gm, "<li>$1</li>")
// Horizontal rules
.replace(/^---+$/gm, "<hr>")
// Line breaks to paragraphs
.replace(/\n\n+/g, "</p><p>")
// Single newlines in context
.replace(/\n/g, "<br>");
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li>.*?<\/li><br>?)+)/g, "<ul>$1</ul>");
// Tables
html = html.replace(
/\|(.+)\|\n\|[-| :]+\|\n((?:\|.+\|\n?)+)/g,
(_match, header: string, body: string) => {
const ths = header
.split("|")
.filter((c: string) => c.trim())
.map((c: string) => `<th>${c.trim()}</th>`)
.join("");
const rows = body
.trim()
.split("\n")
.map((row: string) => {
const tds = row
.split("|")
.filter((c: string) => c.trim())
.map((c: string) => `<td>${c.trim()}</td>`)
.join("");
return `<tr>${tds}</tr>`;
})
.join("");
return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
}
);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
color: #1a1a2e;
line-height: 1.7;
background: #f8f6f2;
}
h1 { font-size: 2em; border-bottom: 2px solid #0079FF; padding-bottom: 8px; color: #0079FF; }
h2 { font-size: 1.5em; margin-top: 2em; color: #1a1a2e; }
h3 { font-size: 1.2em; color: #555; }
code { background: #e8e8e8; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
pre { background: #1a1a2e; color: #e8e8e8; padding: 16px; border-radius: 8px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #0079FF; color: white; }
tr:nth-child(even) { background: #f0f0f0; }
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
ul { padding-left: 1.5em; }
li { margin: 4px 0; }
strong { color: #0079FF; }
a { color: #0079FF; }
</style>
</head>
<body><p>${html}</p></body>
</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(
`<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:monospace;max-width:900px;margin:40px auto;padding:0 20px;background:#1a1a2e;color:#e8e8e8;}</style></head><body><pre>${pretty.replace(/</g, "&lt;")}</pre></body></html>`,
{ 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 });
}
}
+83
View File
@@ -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 }
);
}
}
+61
View File
@@ -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 });
}
+57
View File
@@ -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<string, string> = {};
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<string, unknown> = { 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 });
}
+50
View File
@@ -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,
});
}
+42
View File
@@ -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 });
}
+59 -58
View File
@@ -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 {
+7 -7
View File
@@ -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 (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
-65
View File
@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+73
View File
@@ -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 (
<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>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
render={<Link href={item.href} />}
isActive={
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href)
}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}
+195
View File
@@ -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<string, string> = {
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 (
<div
className={`rounded-lg border overflow-hidden transition-colors ${
selected ? "ring-2 ring-primary" : ""
}`}
>
{/* Preview */}
<div
className="relative aspect-square bg-muted cursor-pointer"
onClick={() => onSelect?.(asset.id)}
>
{isImage && (
<a
href={fileSrc}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<img
src={fileSrc}
alt={asset.fileName}
className="h-full w-full object-cover"
/>
</a>
)}
{isVideo && (
<>
<video
src={`${fileSrc}#t=0.5`}
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
/>
<a
href={fileSrc}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/40 transition-colors group/play"
>
<div className="rounded-full bg-white/90 p-3 opacity-0 group-hover/play:opacity-100 transition-opacity shadow-lg">
<Play className="h-6 w-6 text-foreground fill-foreground" />
</div>
</a>
</>
)}
{!isImage && !isVideo && (
<a
href={`/api/files/render/${asset.filePath}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center hover:bg-muted/50 transition-colors"
>
<span className="text-xs font-medium text-muted-foreground/60 uppercase tracking-wider">
{asset.type}
</span>
<span className="text-sm font-medium text-muted-foreground line-clamp-2">
{asset.fileName}
</span>
</a>
)}
</div>
{/* Info */}
<div className="p-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-1.5 flex-wrap">
{source && (
<Badge variant="outline" className={`text-xs ${sourceColors[source] || ""}`}>
{source}
</Badge>
)}
{asset.platform && (
<Badge variant="outline" className="text-xs">
{asset.platform}
</Badge>
)}
{asset.dimensions && (
<Badge variant="secondary" className="text-xs">
{asset.dimensions}
</Badge>
)}
<Badge
variant={
asset.status === "approved"
? "default"
: asset.status === "rejected"
? "destructive"
: "secondary"
}
className="text-xs"
>
{asset.status}
</Badge>
</div>
{metadata.caption && (
<p className="text-xs text-muted-foreground line-clamp-2">
{metadata.caption}
</p>
)}
{asset.campaign && (
<p className="text-xs text-muted-foreground">
{asset.campaign.name}
</p>
)}
{/* Actions — only for images and videos */}
{(isImage || isVideo) ? (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={() => onStatusChange(asset.id, "approved")}
disabled={asset.status === "approved"}
>
<Check className="h-3 w-3 shrink-0" />
<span className="truncate">Approve</span>
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => onStatusChange(asset.id, "rejected")}
disabled={asset.status === "rejected"}
>
<X className="h-3 w-3 shrink-0" />
<span className="truncate">Reject</span>
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground text-center">Auto-accepted</p>
)}
</div>
</div>
);
}
+190
View File
@@ -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<Asset[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<select
className="h-9 rounded-md border px-3 text-sm"
value={filters.platform}
onChange={(e) =>
setFilters((f) => ({ ...f, platform: e.target.value }))
}
>
<option value="all">All Platforms</option>
<option value="instagram">Instagram</option>
<option value="tiktok">TikTok</option>
<option value="nextdoor">Nextdoor</option>
</select>
<select
className="h-9 rounded-md border px-3 text-sm"
value={filters.type}
onChange={(e) =>
setFilters((f) => ({ ...f, type: e.target.value }))
}
>
<option value="all">All Types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="copy">Copy</option>
<option value="script">Scripts</option>
</select>
<select
className="h-9 rounded-md border px-3 text-sm"
value={filters.status}
onChange={(e) =>
setFilters((f) => ({ ...f, status: e.target.value }))
}
>
<option value="all">All Status</option>
<option value="draft">Draft</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="published">Published</option>
</select>
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 w-48"
/>
{selectedIds.size > 0 && (
<div className="flex gap-2 ml-auto">
<Button
size="sm"
variant="outline"
onClick={() => bulkUpdateStatus("approved")}
>
Approve ({selectedIds.size})
</Button>
<Button
size="sm"
variant="outline"
onClick={() => bulkUpdateStatus("rejected")}
>
Reject ({selectedIds.size})
</Button>
{onPushToPostiz && (
<Button
size="sm"
onClick={() => onPushToPostiz(Array.from(selectedIds))}
>
Push to Postiz ({selectedIds.size})
</Button>
)}
</div>
)}
</div>
{/* Grid */}
{assets.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) => (
<AssetCard
key={asset.id}
asset={asset}
onStatusChange={handleStatusChange}
selected={selectedIds.has(asset.id)}
onSelect={toggleSelect}
/>
))}
</div>
)}
</div>
);
}
+450
View File
@@ -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<string[]>(
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<HTMLInputElement>(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<string[]> {
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<HTMLFormElement>) {
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 (
<Card>
<CardHeader>
<CardTitle>{mode === "edit" ? "Edit Campaign" : "New Campaign"}</CardTitle>
<CardDescription>
{mode === "edit"
? "Update campaign details before launching"
: "Configure your campaign and launch the AI pipeline"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Campaign Name</Label>
<Input
id="name"
name="name"
placeholder="Spring Launch Campaign"
defaultValue={initialData?.name || ""}
required
/>
</div>
<div className="space-y-2">
<Label>Platforms</Label>
<div className="flex gap-3">
{PLATFORMS.map((platform) => (
<button
key={platform}
type="button"
onClick={() => togglePlatform(platform)}
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
selectedPlatforms.includes(platform)
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-muted"
}`}
>
{platform.charAt(0).toUpperCase() + platform.slice(1)}
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Campaign Goal</Label>
<input type="hidden" name="goal" value={selectedGoal} />
<div className="grid gap-2">
{GOALS.map((goal) => (
<button
key={goal.value}
type="button"
onClick={() => setSelectedGoal(goal.value)}
className={`rounded-lg border px-4 py-3 text-left transition-colors ${
selectedGoal === goal.value
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<div className="text-sm font-medium">{goal.label}</div>
<div className="text-xs text-muted-foreground">{goal.description}</div>
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="keyMessage">Key Message</Label>
<Textarea
id="keyMessage"
name="keyMessage"
placeholder="What problem does your app solve? What's the main value proposition?"
defaultValue={initialData?.config.keyMessage || ""}
rows={3}
required
/>
</div>
<div className="space-y-2">
<Label>App Screenshots (optional)</Label>
<p className="text-xs text-muted-foreground">
Upload screenshots of the feature you want to showcase. These will
be incorporated into generated ads.
</p>
{screenshots.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{screenshots.map((s, i) => (
<div key={i} className="relative group">
<img
src={s.preview}
alt={s.file?.name || "Screenshot"}
className="w-full aspect-[9/19.5] object-cover rounded-md border"
/>
<button
type="button"
onClick={() => removeScreenshot(i)}
className="absolute top-1 right-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3.5 w-3.5" />
</button>
{s.uploadedPath && (
<div className="absolute bottom-1 left-1 rounded bg-green-600/80 px-1.5 py-0.5 text-[10px] text-white">
Uploaded
</div>
)}
</div>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFilesSelected(e.target.files)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add("border-primary", "bg-primary/5");
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove("border-primary", "bg-primary/5");
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove("border-primary", "bg-primary/5");
handleFilesSelected(e.dataTransfer.files);
}}
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border px-4 py-6 text-sm text-muted-foreground hover:border-primary hover:bg-primary/5 transition-colors"
>
<ImagePlus className="h-5 w-5" />
<span>Click or drag screenshots here</span>
</button>
</div>
<div className="space-y-2">
<Label htmlFor="socialProof">Social Proof</Label>
<Textarea
id="socialProof"
name="socialProof"
placeholder="50K+ downloads, 4.8 star rating, Featured in App Store"
defaultValue={initialData?.config.socialProof || ""}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="targetAudience">Target Audience</Label>
<Textarea
id="targetAudience"
name="targetAudience"
placeholder="25-35 year old professionals, busy parents, college students..."
defaultValue={initialData?.config.targetAudience || ""}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visualDirection">Visual Direction</Label>
<select
id="visualDirection"
name="visualDirection"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
defaultValue={initialData?.config.visualDirection || "clean"}
>
<option value="clean">Clean & Minimal</option>
<option value="bold">Bold & Vibrant</option>
<option value="premium">Premium & Dark</option>
<option value="warm">Warm & Friendly</option>
<option value="tech">Tech & Modern</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="competitorApps">Competitor Apps (optional)</Label>
<Input
id="competitorApps"
name="competitorApps"
placeholder="Todoist, Notion, TickTick..."
defaultValue={initialData?.config.competitorApps || ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="variations">Variations Per Platform</Label>
<Input
id="variations"
name="variations"
type="number"
defaultValue={initialData?.config.variations ?? 5}
min={1}
max={20}
/>
</div>
<div className="flex items-center gap-2">
<input
id="useTrendReport"
name="useTrendReport"
type="checkbox"
className="h-4 w-4 rounded border-input"
defaultChecked={initialData?.config.useTrendReport || false}
/>
<Label htmlFor="useTrendReport" className="font-normal">
Use latest trend report for hook inspiration
</Label>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading
? mode === "edit" ? "Saving..." : "Creating..."
: mode === "edit" ? "Save Changes" : "Create Campaign"}
</Button>
</form>
</CardContent>
</Card>
);
}
+82
View File
@@ -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<HTMLDivElement>(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 (
<div className="flex flex-col h-[calc(100vh-16rem)]">
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 p-4">
{messages.length === 0 && (
<p className="text-center text-muted-foreground py-12">
Chat with Claude about this campaign. Ask for edits, new variations,
or feedback on generated content.
</p>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${
msg.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 text-sm whitespace-pre-wrap ${
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
>
{msg.content}
{isStreaming && i === messages.length - 1 && msg.role === "assistant" && (
<Loader2 className="inline-block ml-1 h-3 w-3 animate-spin" />
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="border-t p-4 flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask Claude to modify content, regenerate hooks, give feedback..."
className="min-h-[2.5rem] max-h-32 resize-none"
rows={1}
/>
<Button type="submit" size="icon" disabled={!input.trim() || isStreaming}>
<Send className="h-4 w-4" />
</Button>
</form>
</div>
);
}
+47
View File
@@ -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 (
<header className="flex h-14 items-center gap-4 border-b bg-background px-6">
<SidebarTrigger />
<Separator orientation="vertical" className="h-6" />
{title && <h1 className="text-lg font-semibold">{title}</h1>}
<div className="ml-auto">
<DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer outline-none">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: "/login" })}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}
+67
View File
@@ -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<string, string> = {
"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 <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "running":
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
case "failed":
return <XCircle className="h-5 w-5 text-red-500" />;
default:
return <Circle className="h-5 w-5 text-muted-foreground" />;
}
}
export function PipelineProgress({ agents }: { agents: AgentStatus[] }) {
return (
<div className="space-y-3">
{agents.map((agent) => (
<div
key={agent.agentName}
className="flex items-center gap-3 rounded-lg border p-3"
>
<StatusIcon status={agent.status} />
<div className="flex-1">
<div className="font-medium">
{AGENT_LABELS[agent.agentName] || agent.agentName}
</div>
{agent.outputSummary && (
<p className="text-sm text-muted-foreground line-clamp-1">
{agent.outputSummary}
</p>
)}
{agent.error && (
<p className="text-sm text-red-500 line-clamp-1">{agent.error}</p>
)}
</div>
{agent.durationMs && (
<span className="text-sm text-muted-foreground">
{formatDuration(agent.durationMs)}
</span>
)}
</div>
))}
</div>
);
}
+100
View File
@@ -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<string | null>(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 (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Push to Postiz</DialogTitle>
<DialogDescription>
Schedule {assetIds.length} asset(s) for publishing via Postiz.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="schedule">Schedule Date & Time</Label>
<Input
id="schedule"
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
/>
</div>
{result && (
<p
className={`text-sm ${
result.startsWith("Error") ? "text-red-500" : "text-green-600"
}`}
>
{result}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handlePush} disabled={loading}>
{loading ? "Scheduling..." : "Schedule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+12
View File
@@ -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 (
<SessionProvider>
<SidebarProvider>{children}</SidebarProvider>
</SessionProvider>
);
}
+109
View File
@@ -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 (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>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 (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>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,
}
+52
View File
@@ -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<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+103
View File
@@ -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 (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>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 (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+160
View File
@@ -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 <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+268
View File
@@ -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 <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+20
View File
@@ -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 (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+25
View File
@@ -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 (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -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 <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+723
View File
@@ -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<SidebarContextProps | null>(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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
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 (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
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 (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
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<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
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 (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none 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 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
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 (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
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,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+49
View File
@@ -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 (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
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 }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+82
View File
@@ -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 (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
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<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+66
View File
@@ -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 (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
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 (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+64
View File
@@ -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:
+135
View File
@@ -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) |
+102
View File
@@ -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<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(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 };
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(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
}
+85
View File
@@ -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<AgentStatus[]>(
AGENT_NAMES.map((name) => ({ agentName: name, status: "pending" }))
);
const [pipelineStatus, setPipelineStatus] = useState<string>("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 };
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

+31
View File
@@ -0,0 +1,31 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "./prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.password
);
return valid ? { id: user.id, email: user.email, name: user.name } : null;
},
}),
],
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
secret: process.env.NEXTAUTH_SECRET,
});
+727
View File
@@ -0,0 +1,727 @@
import { spawn } from "child_process";
import { mkdirSync } from "fs";
import path from "path";
import { EventEmitter } from "events";
import { prisma } from "./prisma";
import { scanOutputDirectory } from "./scanner";
import { getAllSettings } from "./settings";
export const pipelineEvents = new EventEmitter();
pipelineEvents.setMaxListeners(50);
const AGENT_STEPS = [
"trend-scout",
"marketing-research-agent",
"script-writer",
"ad-creative-designer",
"video-ad-producer",
"copywriter-agent",
"distribution-agent",
] as const;
export type AgentName = (typeof AGENT_STEPS)[number];
interface CampaignConfig {
name: string;
platforms: string[];
goal: string;
keyMessage: string;
socialProof?: string;
variations?: number;
useTrendReport?: boolean;
targetAudience?: string;
visualDirection?: string;
competitorApps?: string;
screenshots?: string[];
}
/**
* Build a detailed campaign prompt that gives each agent enough context.
*/
export function buildCampaignPrompt(config: CampaignConfig): 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}`;
return `# Campaign Brief: "${config.name}"
## Goal
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
## Target Platforms
${platforms}
## Key Message
${config.keyMessage}
${config.socialProof ? `## Social Proof\n${config.socialProof}` : ""}
${config.targetAudience ? `## Target Audience\n${config.targetAudience}` : ""}
${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.` : ""}
## Content Requirements
- ${variations} hook variations per platform
- Static ads at exact platform dimensions (see knowledge/platform_guidelines.md)
- Video ads with platform-appropriate styles:
- Instagram: "polished" style — clean motion graphics
- TikTok: "authentic" style — raw, native feel
- Nextdoor: "local" style — warm, community-focused
- Platform-tuned captions with hashtags (except Nextdoor)
## Output Directory
All outputs MUST go to: ${outputDir}/
- ${outputDir}/ads/ — static PNG images
- ${outputDir}/scripts/ — ad scripts JSON + summary
- ${outputDir}/video/ — rendered MP4 files
- ${outputDir}/copy/ — platform caption JSON files
- ${outputDir}/trend_report.json — trend analysis
- ${outputDir}/research_results.json — research data
- ${outputDir}/research_brief.md — research summary
- ${outputDir}/Publish_${taskName}_${dateStr}.md — publish manifest
${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most recent trend_report.json and use those hooks as inspiration.\n" : ""}
## 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)
2. Run trend-scout skill
3. Run marketing-research-agent skill
4. Run script-writer skill
5. Run ad-creative-designer skill
6. Run video-ad-producer skill
7. Run copywriter-agent skill
8. Run distribution-agent skill
CRITICAL: Read each skill's SKILL.md before executing. Follow the skill instructions exactly.`;
}
/**
* Build a focused prompt for a single agent step.
*/
function buildAgentPrompt(
agentName: string,
campaignBrief: string,
outputDir: string,
screenshots?: string[]
): string {
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
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.
Save your output to: ${outputDir}/trend_report.json
${campaignBrief}`,
"marketing-research-agent": `You are the Marketing Research Agent.
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
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
Then execute 5 deep Tavily research queries. Write a Node.js script using @tavily/core to run the searches.
Save outputs to:
- ${outputDir}/research_results.json
- ${outputDir}/research_brief.md
- ${outputDir}/interactive_report.html
${campaignBrief}`,
"script-writer": `You are the Script Writer agent.
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
Read the upstream outputs:
- ${outputDir}/trend_report.json
- ${outputDir}/research_results.json
- ${outputDir}/research_brief.md
Write ad scripts with hook-body-CTA structure, timed for video.
Save outputs to: ${outputDir}/scripts/
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/scripts/scripts_summary.md
- Individual script files per platform
${campaignBrief}`,
"ad-creative-designer": `You are the Ad Creative Designer agent.
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
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
You MUST produce TWO SETS of image assets:
---
## SET 1: Gemini AI-Generated Ads (NanoBanana MCP)
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")}
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.
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 ? `
- 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/
4. Name files: gemini_{platform}_{hook}_{dimensions}.png
Generate exactly 4 Gemini ads with this mix:
- 2 WITHOUT people (product-focused, phone mockup + environment/abstract):
- 1x Instagram Feed 1080x1080
- 1x TikTok 1080x1920
- 2 WITH people (lifestyle, person interacting with the app):
- 1x Instagram Stories 1080x1920
- 1x TikTok 1080x1920
IMPORTANT: For ads WITH people, show real-looking people naturally using the app — not stock photo poses. For ads WITHOUT people, focus on the phone/app in an environment (floating over house, on a counter, etc.)
---
## SET 2: Canvas Design Posters (Museum-quality art)
Create poster ads using the /skill canvas-design approach. This is a TWO-STEP process:
### Step A: Design Philosophy
First, create a visual design philosophy (.md file) for this campaign's poster aesthetic. Save it to ${outputDir}/ads/posters/design_philosophy.md
The philosophy should:
- Name the aesthetic movement (1-2 words, e.g. "Domestic Geometry" or "Maintenance Modernism")
- 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
### Step B: Express the Philosophy as Poster Art
Using the philosophy, create each poster as a .png file. For each:
1. Write a Python or Node.js script that generates the poster programmatically (using canvas/PDF libraries, or build HTML and screenshot with Playwright)
2. Treat each poster as an ART OBJECT — 90% visual design, 10% essential text
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"}
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.
### 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.
- **Minimum font size: 44px** — NO text below 44px, ever. Clamp anything smaller up to 44px.
- **Text scale multiplier: 1.13x** — apply 113% to all font sizes before rendering to compensate for viewing distance.
- **Hero headline: 75-100px effective** (66-88px raw × 1.13)
- **Body/subheadline: 44px minimum** (even if design calls for smaller)
- **Stat labels, CTA secondary text: 44px minimum**
- **Brand name: ~50px effective** (44px × 1.13)
- **Minimum gap between stacked text:**
- Giant (100-140px) above + Large below: 50px gap
- Large above + Medium below: 40px gap
- Medium + Medium: 30px gap
- Absolute minimum gap: 20px
- **2x internal render recommended:** Render at 2160×3840 (for 9:16) or 2160×2160 (for 1:1), downsample to 1080 for export — produces cleaner anti-aliasing on text and curves.
- **Export as JPEG quality=95, subsampling=0** (4:4:4 chroma) — NOT PNG. Instagram converts PNG to JPEG anyway.
- **CTA block at fixed bottom position:** CTA_BOTTOM_MARGIN = 60px from bottom edge. All visual content centered between header bottom and CTA top.
- **Overflow check:** Always calculate total vertical extent before rendering. If content exceeds canvas, reduce phone width first (most compressible element).
Save posters to ${outputDir}/ads/posters/
Name files: poster_{platform}_{hook}_{dimensions}.png
Generate at least 4 posters:
- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
- 2x TikTok cover images (1080x1920)
---
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
Save ${outputDir}/ads/ad_manifest.json listing ALL generated ads from BOTH sets, with fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline.
${campaignBrief}`,
"video-ad-producer": `You are the Video Ad Producer agent.
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
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/ads/ad_manifest.json
- ${outputDir}/ads/gemini/manifest.json
- ${outputDir}/ads/posters/manifest.json
## Phone Frame Asset
A real iPhone frame PNG is at: assets/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
Screenshot files: ${screenshots.map((p) => `${p}`).join(", ")}
Also available in remotion-ad/public/tasks_overdue.png
For phone mockup scenes: layer the screenshot BEHIND phone.png so the dynamic islands align. The phone frame has a transparent screen area.
` : ""}
## Video Creation Rules
Create ONE video for EACH static ad in BOTH the Gemini and Canvas Poster sets. Read both manifests to get the full list.
Each video MUST match its corresponding static ad:
- Same hook text, same message, same CTA
- Same tone (if the ad is moody/dark, the video is moody/dark; if clean/minimal, the video is clean/minimal)
- The static ad image can appear as a scene in the video (e.g., the final frame)
For each video:
1. Create a Remotion composition in remotion-ad/src/ with a unique CompositionId
2. Use the phone.png frame + screenshot for the phone reveal scene
3. Structure: Hook text (2-3s) → Phone reveal with app screenshot (4-6s) → Social proof (2-3s) → CTA (2-3s)
4. Render using: cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../${outputDir}/video/filename.mp4
Video specs:
- Instagram Reels: 1080x1920, 15s, 30fps
- TikTok: 1080x1920, 12s, 30fps
Naming: video_{source}_{platform}_{hook}_{dimensions}.mp4
Where source is "gemini" or "poster"
Save scene_plans.json and all MP4s to: ${outputDir}/video/
${campaignBrief}`,
"copywriter-agent": `You are the Copywriter agent.
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
Read the upstream outputs:
- ${outputDir}/research_results.json
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/ads/ad_manifest.json
Write platform-tuned captions following the Hook → Value → CTA → Hashtags structure.
Save outputs to:
- ${outputDir}/copy/instagram_captions.json
- ${outputDir}/copy/tiktok_captions.json
- ${outputDir}/copy/nextdoor_posts.json
- ${outputDir}/copy/copy_matrix.json (maps captions to creatives)
${campaignBrief}`,
"distribution-agent": `You are the Distribution Agent.
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
Gather ALL outputs from the campaign:
- ${outputDir}/ads/ (all PNG files)
- ${outputDir}/video/ (all MP4 files)
- ${outputDir}/copy/ (all caption JSON files)
- ${outputDir}/scripts/ (scripts)
Create a publish manifest at: ${outputDir}/Publish_manifest.md
The manifest should include:
- List of all assets with file paths
- Recommended caption for each asset
- Recommended posting schedule
- Platform-specific notes
- A review checklist
IMPORTANT: Do NOT publish anything. Only create the manifest for human review.
${campaignBrief}`,
};
return agentInstructions[agentName] || `Execute the ${agentName} skill. ${campaignBrief}`;
}
const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per agent
const AGENT_LABELS: Record<string, string> = {
"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 humanizeAgentError(agentName: string, code: number | null, stderr: string): string {
const label = AGENT_LABELS[agentName] || agentName;
// Signal-based exits
if (code === 143 || code === 137) {
return `${label} was stopped (process terminated)`;
}
if (code === 130) {
return `${label} was interrupted`;
}
// Try to extract a meaningful error from stderr, ignoring noise
const meaningful = stderr
.split("\n")
.filter((line) => {
const l = line.trim().toLowerCase();
if (!l) return false;
if (l.includes("warning:")) return false;
if (l.includes("stdin")) return false;
if (l.includes("piping from")) return false;
if (l.includes("deprecation")) return false;
return true;
})
.slice(-3)
.join(" ")
.trim();
if (meaningful) {
// Cap length and clean up
const cleaned = meaningful.slice(0, 200).replace(/\s+/g, " ");
return `${label} failed: ${cleaned}`;
}
// Generic fallback
if (code !== null && code !== 0) {
return `${label} failed (exit code ${code})`;
}
return `${label} failed unexpectedly`;
}
export async function runAgentStep(
agentName: string,
prompt: string,
cwd: string,
env: Record<string, string>
): Promise<{ output: string }> {
return new Promise((resolve, reject) => {
const args = [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
];
const claude = spawn("claude", args, {
cwd,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
// Timeout watchdog
const timeout = setTimeout(() => {
claude.kill("SIGTERM");
reject(new Error(`${agentName} timed out after ${AGENT_TIMEOUT_MS / 60000} minutes`));
}, AGENT_TIMEOUT_MS);
claude.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
claude.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
claude.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
// Extract meaningful text from stream-json stdout
let summary = "";
const lines = stdout.split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.result?.text) summary = event.result.text;
else if (event.type === "text" && event.text) summary += event.text;
} catch {
// skip non-JSON lines
}
}
resolve({ output: summary || stdout.slice(-1000) });
} else {
reject(new Error(humanizeAgentError(agentName, code, stderr)));
}
});
claude.on("error", (err) => {
clearTimeout(timeout);
reject(new Error(`Failed to start ${agentName}: ${err.message}`));
});
});
}
/**
* Load API keys from settings DB to pass to Claude subprocess environment.
*/
async function loadPipelineEnv(): Promise<Record<string, string>> {
const settings = await getAllSettings();
const env: Record<string, string> = {};
if (settings.TAVILY_API_KEY) env.TAVILY_API_KEY = settings.TAVILY_API_KEY;
if (settings.POSTIZ_URL) env.POSTIZ_URL = settings.POSTIZ_URL;
if (settings.POSTIZ_API_KEY) env.POSTIZ_API_KEY = settings.POSTIZ_API_KEY;
if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY;
if (settings.NEXTDOOR_API_TOKEN) env.NEXTDOOR_API_TOKEN = settings.NEXTDOOR_API_TOKEN;
if (settings.NEXTDOOR_ADVERTISER_ID) env.NEXTDOOR_ADVERTISER_ID = settings.NEXTDOOR_ADVERTISER_ID;
return env;
}
export async function launchPipeline(
campaignId: string,
prompt: string,
cwd: string
) {
// Load API keys from settings
const pipelineEnv = await loadPipelineEnv();
// Get campaign for output path and config
const campaignData = await prisma.campaign.findUnique({ where: { id: campaignId } });
const outputDir = campaignData?.outputPath || "outputs/default";
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
const screenshots: string[] = campaignConfig.screenshots || [];
// Create output directories
const dirs = ["ads", "scripts", "video", "copy"];
for (const dir of dirs) {
mkdirSync(path.join(cwd, outputDir, dir), { recursive: true });
}
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "running" },
});
pipelineEvents.emit(campaignId, {
type: "pipeline_started",
campaignId,
});
for (const agentName of AGENT_STEPS) {
const agentRun = await prisma.agentRun.create({
data: {
campaignId,
agentName,
status: "running",
startedAt: new Date(),
},
});
pipelineEvents.emit(campaignId, {
type: "agent_started",
agentName,
agentRunId: agentRun.id,
});
const startTime = Date.now();
try {
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, screenshots);
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
const durationMs = Date.now() - startTime;
await prisma.agentRun.update({
where: { id: agentRun.id },
data: {
status: "completed",
completedAt: new Date(),
durationMs,
outputSummary: output.slice(0, 500),
outputPath: outputDir,
},
});
pipelineEvents.emit(campaignId, {
type: "agent_completed",
agentName,
agentRunId: agentRun.id,
durationMs,
outputSummary: output.slice(0, 200),
});
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
await prisma.agentRun.update({
where: { id: agentRun.id },
data: {
status: "failed",
completedAt: new Date(),
durationMs,
error: errorMessage,
},
});
pipelineEvents.emit(campaignId, {
type: "agent_failed",
agentName,
agentRunId: agentRun.id,
error: errorMessage.slice(0, 200),
});
// Continue to next agent even if one fails
}
}
// Scan output directory for generated assets
if (campaignData?.outputPath) {
try {
const result = await scanOutputDirectory(campaignId, campaignData.outputPath, cwd);
pipelineEvents.emit(campaignId, {
type: "scan_complete",
scanned: result.scanned,
created: result.created,
});
} catch {
// Scanner errors shouldn't block pipeline completion
}
}
await prisma.campaign.update({
where: { id: campaignId },
data: { status: "review" },
});
pipelineEvents.emit(campaignId, {
type: "pipeline_complete",
status: "review",
});
}
export async function sendChatMessage(
sessionId: string | null,
message: string,
cwd: string
): Promise<ReadableStream<Uint8Array>> {
const pipelineEnv = await loadPipelineEnv();
const args = [
"-p",
message,
"--output-format",
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
];
if (sessionId) args.push("--resume", sessionId);
const claude = spawn("claude", args, {
cwd,
env: { ...process.env, ...pipelineEnv },
});
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
claude.stdout.on("data", (chunk: Buffer) => {
const lines = chunk.toString().split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
// Handle various Claude CLI stream-json event types
let text = "";
if (event.type === "content_block_delta" && event.delta?.text) {
text = event.delta.text;
} else if (event.type === "assistant" && event.message) {
text = event.message;
} else if (event.type === "text" && event.text) {
text = event.text;
} else if (event.result?.text) {
text = event.result.text;
}
if (text) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)
);
}
// Capture session ID if provided
if (event.session_id || event.sessionId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ sessionId: event.session_id || event.sessionId })}\n\n`
)
);
}
} catch {
// Non-JSON line — skip (likely progress output)
}
}
});
claude.on("close", () => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
);
controller.close();
});
claude.on("error", () => {
controller.close();
});
},
});
}
+121
View File
@@ -0,0 +1,121 @@
import { getSetting } from "./settings";
const NEXTDOOR_API_URL = "https://ads.nextdoor.com/v1";
async function getNextdoorConfig() {
const token = await getSetting("NEXTDOOR_API_TOKEN");
const advertiserId = await getSetting("NEXTDOOR_ADVERTISER_ID");
return { token, advertiserId };
}
async function nextdoorFetch(query: string, variables: Record<string, unknown> = {}) {
const { token } = await getNextdoorConfig();
const res = await fetch(`${NEXTDOOR_API_URL}/graphql`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Nextdoor API error ${res.status}: ${text}`);
}
const data = await res.json();
if (data.errors?.length) {
throw new Error(`Nextdoor GraphQL error: ${data.errors[0].message}`);
}
return data.data;
}
export async function createNextdoorCampaign(
name: string,
budget: number,
schedule: { startDate: string; endDate: string }
) {
const mutation = `
mutation CreateCampaign($input: CreateCampaignInput!) {
createCampaign(input: $input) {
campaign { id name status }
}
}
`;
return nextdoorFetch(mutation, {
input: {
advertiserId: (await getNextdoorConfig()).advertiserId,
name,
objective: "WEBSITE_CONVERSION",
budget: { amount: budget, currency: "USD" },
schedule,
},
});
}
export async function createNextdoorAdGroup(
campaignId: string,
targeting: Record<string, unknown>
) {
const mutation = `
mutation CreateAdGroup($input: CreateAdGroupInput!) {
createAdGroup(input: $input) {
adGroup { id name status }
}
}
`;
return nextdoorFetch(mutation, {
input: {
campaignId,
name: "Auto-generated Ad Group",
targeting,
},
});
}
export async function uploadNextdoorCreative(imageUrl: string) {
const mutation = `
mutation CreateCreative($input: CreateCreativeInput!) {
createCreative(input: $input) {
creative { id status }
}
}
`;
return nextdoorFetch(mutation, {
input: {
advertiserId: (await getNextdoorConfig()).advertiserId,
imageUrl,
type: "IMAGE",
},
});
}
export async function createNextdoorAd(
adGroupId: string,
creativeId: string,
copy: { headline: string; body: string; ctaText: string; destinationUrl: string }
) {
const mutation = `
mutation CreateAd($input: CreateAdInput!) {
createAd(input: $input) {
ad { id name status }
}
}
`;
return nextdoorFetch(mutation, {
input: {
adGroupId,
creativeId,
headline: copy.headline,
body: copy.body,
callToAction: copy.ctaText,
destinationUrl: copy.destinationUrl,
},
});
}
+148
View File
@@ -0,0 +1,148 @@
import fs from "fs";
import path from "path";
import { getSetting } from "./settings";
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
async function getPostizConfig() {
const url = (await getSetting("POSTIZ_URL")) || "http://localhost:5000";
const apiKey = await getSetting("POSTIZ_API_KEY");
return { url, apiKey };
}
async function postizFetch(endpoint: string, options: RequestInit = {}) {
const { url, apiKey } = await getPostizConfig();
const res = await fetch(`${url}/public/v1${endpoint}`, {
...options,
headers: {
Authorization: apiKey,
...options.headers,
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Postiz API error ${res.status}: ${text}`);
}
return res.json();
}
/**
* Resolve an asset's relative filePath to an absolute path.
*/
function resolveAssetPath(filePath: string): string {
if (path.isAbsolute(filePath)) return filePath;
return path.join(PIPELINE_ROOT, filePath);
}
export async function uploadToPostiz(filePath: string) {
const absolutePath = resolveAssetPath(filePath);
const fileBuffer = fs.readFileSync(absolutePath);
const fileName = path.basename(absolutePath);
const formData = new FormData();
formData.append("file", new Blob([fileBuffer]), fileName);
const { url, apiKey } = await getPostizConfig();
const res = await fetch(`${url}/public/v1/upload`, {
method: "POST",
headers: { Authorization: apiKey },
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Postiz upload error ${res.status}: ${text}`);
}
const data = await res.json();
if (!data?.id || !data?.path) {
throw new Error(`Postiz upload returned unexpected shape: ${JSON.stringify(data)}`);
}
return { mediaId: data.id, publicUrl: data.path };
}
// Map our internal platform names to Postiz provider identifiers
const PLATFORM_ALIASES: Record<string, string[]> = {
instagram: ["instagram", "instagram-standalone", "ig"],
tiktok: ["tiktok", "tt"],
nextdoor: ["nextdoor"],
};
export async function pushToPostiz(
asset: {
filePath: string;
platform?: string | null;
metadata?: string | null;
},
scheduledAt: string
) {
const { mediaId, publicUrl } = await uploadToPostiz(asset.filePath);
const integrations = await getPostizIntegrations();
const platform = (asset.platform || "").toLowerCase();
const aliases = PLATFORM_ALIASES[platform] || [platform];
const integration = integrations.find(
(i: { identifier?: string; providerIdentifier?: string }) => {
const id = (i.identifier || i.providerIdentifier || "").toLowerCase();
return aliases.includes(id);
}
);
if (!integration) {
const available = integrations
.map((i: { identifier?: string }) => i.identifier)
.join(", ");
throw new Error(
`No Postiz channel for "${platform}". Available: ${available || "none"}`
);
}
const metadata = JSON.parse(asset.metadata || "{}");
// Postiz v1 API post structure
const platformSettings: Record<string, unknown> = { __type: platform };
if (platform === "instagram") {
platformSettings.post_type = "post";
} else if (platform === "tiktok") {
platformSettings.privacy_level = "PUBLIC_TO_EVERYONE";
platformSettings.comment = true;
platformSettings.duet = false;
platformSettings.stitch = false;
platformSettings.content_posting_method = "DIRECT_POST";
platformSettings.autoAddMusic = "no";
platformSettings.brand_content_toggle = false;
platformSettings.brand_organic_toggle = false;
}
const post = await postizFetch("/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "schedule",
date: scheduledAt,
shortLink: false,
tags: [],
posts: [
{
integration: { id: integration.id },
value: [
{
content: metadata.caption || "",
image: [{ id: mediaId, path: publicUrl }],
},
],
settings: platformSettings,
},
],
}),
});
return post;
}
export async function getPostizIntegrations() {
return postizFetch("/integrations");
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from "./generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createPrismaClient() {
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL || "file:./prisma/data/marketing.db",
});
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma || createPrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+184
View File
@@ -0,0 +1,184 @@
import { readdirSync, readFileSync, statSync, existsSync } from "fs";
import path from "path";
import { prisma } from "./prisma";
interface ScannedFile {
filePath: string;
fileName: string;
type: string;
platform: string | null;
format: string;
dimensions: string | null;
metadata: string | null;
}
const FORMAT_TO_TYPE: Record<string, string> = {
png: "image",
jpg: "image",
jpeg: "image",
webp: "image",
gif: "image",
mp4: "video",
webm: "video",
json: "copy",
txt: "copy",
md: "research",
html: "research",
};
function inferPlatform(fileName: string, filePath: string): string | null {
const lower = (fileName + filePath).toLowerCase();
if (lower.includes("instagram") || lower.includes("ig_") || lower.includes("ig ")) return "instagram";
if (lower.includes("tiktok") || lower.includes("tt_") || lower.includes("tik_tok")) return "tiktok";
if (lower.includes("nextdoor") || lower.includes("nd_")) return "nextdoor";
return null;
}
function inferDimensions(fileName: string): string | null {
const match = fileName.match(/(\d{3,4})x(\d{3,4})/);
return match ? `${match[1]}x${match[2]}` : null;
}
function inferTypeFromPath(filePath: string, format: string): string {
const lower = filePath.toLowerCase();
if (lower.includes("/ads/")) return "image";
if (lower.includes("/video/")) return "video";
if (lower.includes("/copy/")) return "copy";
if (lower.includes("/scripts/")) return "script";
return FORMAT_TO_TYPE[format] || "research";
}
/**
* Try to read metadata from adjacent JSON files or manifest.
* For an image "instagram_feed_hook_a_1080x1080.png", look for:
* - "instagram_feed_hook_a_1080x1080.json" (same name, .json ext)
* - "ad_manifest.json" in same directory
* For copy JSON files, read the file itself as metadata.
*/
function loadMetadata(fullPath: string, format: string): string | null {
try {
// For JSON files, read the content as metadata
if (format === "json") {
const content = readFileSync(fullPath, "utf-8");
const parsed = JSON.parse(content);
// Extract caption/summary if it's an array of captions
if (Array.isArray(parsed)) {
return JSON.stringify({ captions: parsed.slice(0, 3), totalVariations: parsed.length });
}
return content.slice(0, 2000);
}
// For media files, look for adjacent JSON with same name
const jsonPath = fullPath.replace(/\.[^.]+$/, ".json");
if (existsSync(jsonPath)) {
return readFileSync(jsonPath, "utf-8").slice(0, 2000);
}
// Look for manifest in same directory
const dir = path.dirname(fullPath);
const manifestPath = path.join(dir, "ad_manifest.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
const fileName = path.basename(fullPath);
// Find this file's entry in the manifest
if (Array.isArray(manifest)) {
const entry = manifest.find((e: { fileName?: string; file?: string }) =>
e.fileName === fileName || e.file === fileName
);
if (entry) return JSON.stringify(entry);
}
}
// Look for scene_plans.json for video files
if (format === "mp4" || format === "webm") {
const scenePlansPath = path.join(dir, "scene_plans.json");
if (existsSync(scenePlansPath)) {
const plans = JSON.parse(readFileSync(scenePlansPath, "utf-8"));
const fileName = path.basename(fullPath);
if (plans[fileName]) return JSON.stringify(plans[fileName]);
}
}
} catch {
// Metadata loading is best-effort
}
return null;
}
function scanDirectory(dir: string, baseDir: string): ScannedFile[] {
const files: ScannedFile[] = [];
try {
const entries = readdirSync(dir);
for (const entry of entries) {
if (entry.startsWith(".")) continue;
const fullPath = path.join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...scanDirectory(fullPath, baseDir));
} else {
const ext = path.extname(entry).toLowerCase().slice(1);
if (!ext || ext === "gitkeep") continue;
const relativePath = path.relative(baseDir, fullPath);
const type = inferTypeFromPath(relativePath, ext);
const metadata = loadMetadata(fullPath, ext);
files.push({
filePath: relativePath,
fileName: entry,
type,
platform: inferPlatform(entry, relativePath),
format: ext,
dimensions: inferDimensions(entry),
metadata,
});
}
}
} catch {
// Directory doesn't exist or can't be read
}
return files;
}
export async function scanOutputDirectory(
campaignId: string,
outputPath: string,
pipelineRoot: string
) {
const fullOutputPath = path.join(pipelineRoot, outputPath);
const files = scanDirectory(fullOutputPath, pipelineRoot);
let created = 0;
for (const file of files) {
const existing = await prisma.asset.findFirst({
where: {
campaignId,
filePath: file.filePath,
},
});
if (!existing) {
await prisma.asset.create({
data: {
campaignId,
type: file.type,
platform: file.platform,
format: file.format,
filePath: file.filePath,
fileName: file.fileName,
dimensions: file.dimensions,
metadata: file.metadata,
status: "draft",
},
});
created++;
}
}
return { scanned: files.length, created };
}
+141
View File
@@ -0,0 +1,141 @@
import { prisma } from "./prisma";
// Settings keys and their env var fallbacks
const SETTINGS_KEYS = {
// Postiz
POSTIZ_URL: { envVar: "POSTIZ_URL", label: "Postiz URL", placeholder: "http://localhost:5000" },
POSTIZ_API_KEY: { envVar: "POSTIZ_API_KEY", label: "Postiz API Key", placeholder: "your-postiz-api-key", secret: true },
// Tavily (Research)
TAVILY_API_KEY: { envVar: "TAVILY_API_KEY", label: "Tavily API Key", placeholder: "tvly-...", secret: true },
// Google Gemini (NanoBanana image generation)
GEMINI_API_KEY: { envVar: "GEMINI_API_KEY", label: "Google Gemini API Key", placeholder: "AIza...", secret: true },
// Nextdoor
NEXTDOOR_API_TOKEN: { envVar: "NEXTDOOR_API_TOKEN", label: "Nextdoor API Token", placeholder: "your-nextdoor-token", secret: true },
NEXTDOOR_ADVERTISER_ID: { envVar: "NEXTDOOR_ADVERTISER_ID", label: "Nextdoor Advertiser ID", placeholder: "your-advertiser-id" },
} as const;
export type SettingKey = keyof typeof SETTINGS_KEYS;
export const SETTINGS_CONFIG = SETTINGS_KEYS;
// Grouped for UI
export const SETTINGS_GROUPS = [
{
name: "Postiz",
description: "Self-hosted social media scheduling. Handles Instagram and TikTok publishing.",
docsUrl: "https://postiz.com",
keys: ["POSTIZ_URL", "POSTIZ_API_KEY"] as SettingKey[],
},
{
name: "Tavily",
description: "AI-powered web research. Used by the Trend Scout and Research agents. Free tier: 1,000 searches/month.",
docsUrl: "https://tavily.com",
keys: ["TAVILY_API_KEY"] as SettingKey[],
},
{
name: "Gemini",
description: "Google Gemini powers NanoBanana MCP for AI image generation in static ads. ~$0.04-0.13/image.",
docsUrl: "https://aistudio.google.com/apikey",
keys: ["GEMINI_API_KEY"] as SettingKey[],
},
{
name: "Nextdoor",
description: "Direct Nextdoor Ads API integration for local advertising.",
docsUrl: "https://developer.nextdoor.com",
keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"] as SettingKey[],
},
];
/**
* Get a setting value. Checks DB first, falls back to env var.
*/
export async function getSetting(key: SettingKey): Promise<string> {
try {
const setting = await prisma.setting.findUnique({ where: { key } });
if (setting?.value) return setting.value;
} catch {
// DB not available, fall through to env
}
const config = SETTINGS_KEYS[key];
return process.env[config.envVar] || "";
}
/**
* Get all settings as a map.
*/
export async function getAllSettings(): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (const [key, config] of Object.entries(SETTINGS_KEYS)) {
try {
const setting = await prisma.setting.findUnique({ where: { key } });
result[key] = setting?.value || process.env[config.envVar] || "";
} catch {
result[key] = process.env[config.envVar] || "";
}
}
return result;
}
/**
* Save a setting to the database.
*/
export async function saveSetting(key: SettingKey, value: string) {
await prisma.setting.upsert({
where: { key },
update: { value },
create: { key, value },
});
}
/**
* Check connectivity status for each integration.
*/
export async function checkIntegrationStatus(): Promise<Record<string, { connected: boolean; error?: string }>> {
const settings = await getAllSettings();
const status: Record<string, { connected: boolean; error?: string }> = {};
// Postiz
if (settings.POSTIZ_URL && settings.POSTIZ_API_KEY) {
try {
const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, {
headers: { Authorization: `Bearer ${settings.POSTIZ_API_KEY}` },
signal: AbortSignal.timeout(5000),
});
status.postiz = { connected: res.ok };
if (!res.ok) status.postiz.error = `HTTP ${res.status}`;
} catch (e) {
status.postiz = { connected: false, error: e instanceof Error ? e.message : "Connection failed" };
}
} else {
status.postiz = { connected: false, error: "Not configured" };
}
// Tavily
if (settings.TAVILY_API_KEY) {
status.tavily = { connected: true }; // No ping endpoint, just check if key exists
} else {
status.tavily = { connected: false, error: "Not configured" };
}
// Gemini
if (settings.GEMINI_API_KEY) {
status.gemini = { connected: true };
} else {
status.gemini = { connected: false, error: "Not configured" };
}
// Nextdoor
if (settings.NEXTDOOR_API_TOKEN && settings.NEXTDOOR_ADVERTISER_ID) {
status.nextdoor = { connected: true };
} else {
status.nextdoor = { connected: false, error: "Not configured" };
}
return status;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token =
request.cookies.get("authjs.session-token") ||
request.cookies.get("__Secure-authjs.session-token");
if (!token) {
// API routes return 401; pages redirect to login
if (request.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!login|api/auth|_next/static|_next/image|favicon.ico).*)",
],
};
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;
+2118 -6
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -8,26 +8,44 @@
"start": "next start",
"lint": "eslint"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@base-ui/react": "^1.3.0",
"@prisma/adapter-better-sqlite3": "^7.5.0",
"@prisma/client": "^7.5.0",
"@tavily/core": "^0.7.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.8.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.0.1",
"next": "16.2.1",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"playwright": "^1.58.2",
"prisma": "^7.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.3.1",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"nanobanana": {
"command": "npx",
"args": ["-y", "@ycse/nanobanana-mcp"],
"env": {
"GOOGLE_AI_API_KEY": "AIzaSyBp6a4aEhZsakgcjn19T1aqJBrJ4NKzvUM"
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
# Marketing Content Pipeline
This project implements an AI-powered Social Media Content Automation System.
Seven specialized agents research, generate, render, and distribute marketing content.
# System Architecture
Seven agents running in sequence:
1. **Trend Scout** — trending content monitoring via Tavily
2. **Marketing Research Agent** — deep market research via Tavily
3. **Script Writer** — ad scripts from research output
4. **Ad Creative Designer** — static ads via NanoBanana MCP + Playwright
5. **Video Ad Producer** — video ads via Remotion
6. **Copywriter Agent** — platform-specific copy
7. **Distribution Agent** — publish manifest creation (gate-protected)
# Folder Structure
- `assets/` — brand images, logos, product shots (mood board)
- `knowledge/` — brand identity, platform guidelines, product/campaign info
- `skills/` — all 7 agent skills (each has SKILL.md)
- `outputs/` — generated content per campaign
- `remotion-ad/` — Remotion video project with compositions
# 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
# Available Tools
## Tavily (Web Research)
Use `@tavily/core` npm package. Write a Node.js script to run searches:
```javascript
import { tavily } from "@tavily/core";
const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
const result = await client.search("query", { maxResults: 10 });
```
The TAVILY_API_KEY is available in the environment.
## NanoBanana MCP (Image Generation)
Available as MCP tool: `mcp__nanobanana__generate_image`
Uses Google Gemini to generate images. Call it with a detailed prompt describing the desired image.
The GEMINI_API_KEY is configured in .mcp.json.
## Playwright (HTML to PNG)
Use Playwright to render HTML/CSS layouts to pixel-perfect PNG screenshots:
```javascript
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setViewportSize({ width: 1080, height: 1080 });
await page.setContent(htmlString);
await page.screenshot({ path: "output.png" });
await browser.close();
```
## Remotion (Video Rendering)
Video project is in `remotion-ad/`. Compositions are defined in `remotion-ad/src/`.
To render a video:
```bash
cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../outputs/campaign/video/filename.mp4
```
You can modify or create new compositions in `remotion-ad/src/` before rendering.
# Pipeline Execution Order
trend-scout → research → script-writer → ad-creative → video-producer → copywriter → distribution
Each agent reads its SKILL.md from `skills/{agent-name}/SKILL.md` and follows it exactly.
# Output Convention
```
outputs/{task_name}_{YYYYMMDD}/
├── trend_report.json
├── research_results.json
├── research_brief.md
├── interactive_report.html
├── ads/
│ ├── instagram_feed_*.png (1080x1080)
│ ├── instagram_stories_*.png (1080x1920)
│ ├── nextdoor_spotlight_*.png (1200x1200)
│ ├── nextdoor_display_*.png (1200x628)
│ └── ad_manifest.json
├── scripts/
│ ├── scripts_all.json
│ └── scripts_summary.md
├── video/
│ ├── instagram_reel_*.mp4 (1080x1920)
│ ├── tiktok_ad_*.mp4 (1080x1920)
│ ├── nextdoor_video_*.mp4 (1080x1080)
│ └── scene_plans.json
├── copy/
│ ├── instagram_captions.json
│ ├── tiktok_captions.json
│ ├── nextdoor_posts.json
│ └── copy_matrix.json
└── Publish_manifest.md
```
# File Naming Convention
`{platform}_{format}_{hook_variant}_{dimensions}.{ext}`
Example: `instagram_feed_hook_a_1080x1080.png`
# Safety Rules
- No live API posting without explicit user approval
- Distribution agent creates a publish manifest — NEVER auto-publishes
- All media files must exist locally before creating the manifest
- Always save outputs to the specified output directory
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

+44
View File
@@ -0,0 +1,44 @@
# Brand Identity: honeyDue
## 1. Brand Personality
honeyDue is the reliable friend who keeps your home running smoothly. We're organized, warm, and empowering — helping homeowners feel in control of their property without the stress.
Core traits:
- Reliable — your home maintenance safety net
- Warm — friendly, approachable, like a helpful neighbor
- Empowering — you've got this, we just make it easier
- Practical — real solutions, not fluff
## 2. Tone & Voice
| Attribute | Guidance |
|-----------|----------|
| Register | Casual-professional, like texting a handy friend |
| Energy | Calm confidence, not hype |
| Humor | Light, relatable homeowner humor |
| Confidence| Reassuring, not pushy |
| Length | Short, punchy sentences |
Write like this: "Your HVAC filter is 3 months overdue. We caught it so you don't have to."
Not like this: "Our application provides comprehensive home maintenance tracking capabilities."
## 3. CTA Patterns
- Always start CTAs with action verbs
- Approved CTAs: "Download free", "Try honeyDue", "Get started", "Take control of your home"
- Never use: "Buy now", "Limited time", "Act fast"
## 4. Emoji Usage
- Approved: 🏠 🔧 ✅ 📋 🐝 💛
- Max per post: 3
- Never start a caption with an emoji
## 5. Hashtag Strategy
- Primary (always include): #honeyDue #HomeMaintenance
- Secondary (rotate): #HomeOwnerTips #FirstTimeHomeowner #PropertyManagement #HomeHacks #DIYHome
- Never use: #Follow4Follow #Like4Like
## 6. Brand Colors
- Primary: #0079FF (bright blue)
- Accent: #FF9400 (orange)
- Background: warm off-white
- Cards: clean white with rounded corners
- Status colors: red (overdue), green (on track), blue (upcoming)
+45
View File
@@ -0,0 +1,45 @@
# Platform Guidelines
## 1. Platform Overview
| Platform | Content Type | Primary Tone | Hashtags |
|----------|-------------|--------------|----------|
| Instagram | Feed posts, Stories, Reels | Polished, aspirational | Required (3-5) |
| TikTok | Short video ads | Raw, authentic, trend-driven | Required (3-5 trending) |
| Nextdoor | Neighborhood posts, display ads | Warm, local, neighborly | None |
## 2. Instagram
### Specs
| Format | Dimensions | Aspect Ratio |
|--------|-----------|--------------|
| Feed Post | 1080x1080 px | 1:1 |
| Story/Reel | 1080x1920 px | 9:16 |
### Caption Guidelines
- Hook in first line (before "more" truncation)
- Structure: Hook → Value → CTA → line break → Hashtags
- Max 2200 chars, aim for 150-300
## 3. TikTok
### Specs
| Format | Dimensions | Length |
|--------|-----------|--------|
| Video Ad | 1080x1920 px (9:16) | 9-15s sweet spot |
### Style Rules
- Raw, authentic feel (not polished brand ads)
- Bold text overlays, max 6 words per frame
- Hook in first 1-2 seconds
- Trending sounds when possible
## 4. Nextdoor
### Specs
| Format | Dimensions |
|--------|-----------|
| Spotlight Ad | 1200x1200 px |
| Display Ad | 1200x628 px |
### Style Rules
- Warm, community-focused tone
- No hashtags
- Reference local/neighborhood context
- CTA: "Learn More" or "Visit Us"
+44
View File
@@ -0,0 +1,44 @@
# Product & Campaign Knowledge: honeyDue
## 1. Product Overview
| Attribute | Details |
|-----------|---------|
| Product Name | honeyDue |
| Category | Home Maintenance & Property Management |
| Target Audience | Homeowners 25-65, landlords, first-time buyers |
| Brand Positioning | The app that makes sure you never miss home maintenance again |
| Platforms | iOS (App Store), Android (Google Play) |
## 2. Key Features
| Feature | Benefit | Proof Point |
|---------|---------|-------------|
| Smart Task Tracking | Never forget HVAC filters, gutter cleaning, or any recurring task | Overdue alerts with priority levels (High/Medium/Low) |
| Multi-Property Support | Manage multiple homes from one dashboard | Property cards with task summaries at a glance |
| Household Sharing | Share maintenance duties with family or housemates | Join via share code, assign tasks |
| Contractor Management | Save plumber, electrician, HVAC tech contacts in one place | Link contractors to completed tasks |
| Task Completion History | Photo-document every repair with cost tracking | Generate PDF reports for insurance or home sales |
| Document & Warranty Storage | Store warranties, manuals, receipts | Track expiration dates, never lose a warranty |
| Smart Reminders | Push notifications before tasks are due | Customizable frequency: weekly, monthly, seasonal, custom |
## 3. Campaign Direction — Task Management Feature Launch
- Goal: Drive app downloads on iOS and Android
- Key message: Never miss home maintenance again. honeyDue tracks every task with smart reminders so nothing slips through the cracks.
- Social proof: Thousands of homeowners already trust honeyDue
- Visual direction: Clean & minimal — matches the app's bright, iOS-native design
- Hero screenshot: Tasks screen showing overdue items (HVAC filters, trash, Roomba care) with priority badges and action buttons
- Target: Homeowners 25-45 who forget tasks, busy parents, first-time buyers overwhelmed by upkeep
## 4. Competitive Advantages
- Beautiful, intuitive UI that feels native (not a clunky web wrapper)
- Multi-property + household sharing (competitors are single-user)
- Contractor management built in (competitors send you to Thumbtack)
- Photo documentation + PDF reports (for insurance claims and home sales)
- Free to use — no paywall for core features
## 5. Competitors
- Centriq — focuses on appliance manuals, not task management
- HomeZada — bloated, tries to do everything, confusing UI
- Thumbtack — finds contractors but doesn't track your maintenance schedule
## 6. Available Visual Assets
- assets/screenshots/tasks_overdue.png — Tasks screen showing overdue maintenance items with priority badges, action buttons, and completion counts
View File
+7
View File
@@ -0,0 +1,7 @@
{
"name": "marketing-pipeline",
"private": true,
"dependencies": {
"@tavily/core": "^0.7.2"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "remotion-ad",
"private": true,
"scripts": {
"studio": "remotion studio",
"render": "remotion render",
"upgrade": "remotion upgrade"
},
"dependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/google-fonts": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"remotion": "^4.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"typescript": "^5.0.0"
}
}
+175
View File
@@ -0,0 +1,175 @@
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
interface AdProps {
style: "polished" | "authentic" | "local";
hookText: string;
bodyText: string;
ctaText: string;
proofText: string;
}
const STYLE_CONFIG = {
polished: {
bg: "#0f0f0f",
text: "#ffffff",
accent: "#6366f1",
fontFamily: "Inter, sans-serif",
},
authentic: {
bg: "#1a1a2e",
text: "#eee",
accent: "#e94560",
fontFamily: "system-ui, sans-serif",
},
local: {
bg: "#fef9ef",
text: "#2d3436",
accent: "#00b894",
fontFamily: "Georgia, serif",
},
};
export const AdComposition: React.FC<AdProps> = ({
style,
hookText,
bodyText,
ctaText,
proofText,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const config = STYLE_CONFIG[style];
// Scene timing (in frames)
const hookEnd = Math.floor(durationInFrames * 0.2);
const bodyStart = hookEnd;
const bodyEnd = Math.floor(durationInFrames * 0.6);
const proofStart = bodyEnd;
const proofEnd = Math.floor(durationInFrames * 0.8);
const ctaStart = proofEnd;
// Animations
const hookOpacity = interpolate(frame, [0, 15, hookEnd - 10, hookEnd], [0, 1, 1, 0], {
extrapolateRight: "clamp",
});
const hookScale = spring({ frame, fps, config: { damping: 12 } });
const bodyOpacity = interpolate(
frame,
[bodyStart, bodyStart + 15, bodyEnd - 10, bodyEnd],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const proofOpacity = interpolate(
frame,
[proofStart, proofStart + 15, proofEnd - 10, proofEnd],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const ctaOpacity = interpolate(frame, [ctaStart, ctaStart + 15], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const ctaScale = spring({
frame: Math.max(0, frame - ctaStart),
fps,
config: { damping: 10, stiffness: 100 },
});
return (
<AbsoluteFill
style={{
backgroundColor: config.bg,
fontFamily: config.fontFamily,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 80,
}}
>
{/* Hook */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${hookScale})`,
opacity: hookOpacity,
fontSize: 72,
fontWeight: 800,
color: config.text,
textAlign: "center",
lineHeight: 1.2,
maxWidth: "80%",
}}
>
{hookText}
</div>
{/* Body */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: bodyOpacity,
fontSize: 48,
fontWeight: 600,
color: config.text,
textAlign: "center",
lineHeight: 1.3,
maxWidth: "80%",
}}
>
{bodyText}
</div>
{/* Proof */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: proofOpacity,
fontSize: 36,
fontWeight: 500,
color: config.accent,
textAlign: "center",
}}
>
{proofText}
</div>
{/* CTA */}
<div
style={{
position: "absolute",
bottom: "15%",
left: "50%",
transform: `translateX(-50%) scale(${ctaScale})`,
opacity: ctaOpacity,
backgroundColor: config.accent,
color: "#fff",
fontSize: 32,
fontWeight: 700,
padding: "20px 60px",
borderRadius: 16,
textAlign: "center",
}}
>
{ctaText}
</div>
</AbsoluteFill>
);
};
+318
View File
@@ -0,0 +1,318 @@
import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
staticFile,
} from "remotion";
export interface HoneyDueAdProps {
platform: "instagram" | "tiktok";
hookText: string;
bodyText: string;
ctaText: string;
proofText: string;
screenshotSrc: string;
}
const COLORS = {
primary: "#0079FF",
accent: "#FF9400",
dark: "#1a1a2e",
light: "#f8f6f2",
white: "#ffffff",
red: "#FF3B30",
};
export const HoneyDueAd: React.FC<HoneyDueAdProps> = ({
platform,
hookText,
bodyText,
ctaText,
proofText,
screenshotSrc,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
const isPolished = platform === "instagram";
const bg = isPolished ? COLORS.dark : "#0d0d0d";
// Scene boundaries
const hookEnd = Math.floor(durationInFrames * 0.22);
const phoneStart = hookEnd;
const phoneEnd = Math.floor(durationInFrames * 0.6);
const proofStart = phoneEnd;
const proofEnd = Math.floor(durationInFrames * 0.78);
const ctaStart = proofEnd;
// === HOOK SCENE ===
const hookOpacity = interpolate(
frame,
[0, 10, hookEnd - 8, hookEnd],
[0, 1, 1, 0],
{ extrapolateRight: "clamp" }
);
const hookY = interpolate(frame, [0, 15], [40, 0], {
extrapolateRight: "clamp",
});
// === PHONE SCENE ===
const phoneScale = spring({
frame: Math.max(0, frame - phoneStart),
fps,
config: { damping: 14, stiffness: 80 },
});
const phoneOpacity = interpolate(
frame,
[phoneStart, phoneStart + 10, phoneEnd - 8, phoneEnd],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const bodyTextOpacity = interpolate(
frame,
[phoneStart + 20, phoneStart + 35],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// === PROOF SCENE ===
const proofOpacity = interpolate(
frame,
[proofStart, proofStart + 12, proofEnd - 8, proofEnd],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const proofScale = spring({
frame: Math.max(0, frame - proofStart),
fps,
config: { damping: 12 },
});
// === CTA SCENE ===
const ctaOpacity = interpolate(frame, [ctaStart, ctaStart + 12], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const ctaScale = spring({
frame: Math.max(0, frame - ctaStart),
fps,
config: { damping: 10, stiffness: 100 },
});
// Pulse the CTA button
const ctaPulse =
frame > ctaStart + 20
? 1 + 0.03 * Math.sin((frame - ctaStart - 20) * 0.15)
: 1;
const phoneWidth = width * 0.55;
const phoneHeight = phoneWidth * 2.05;
return (
<AbsoluteFill
style={{
backgroundColor: bg,
fontFamily:
'-apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif',
}}
>
{/* Subtle gradient overlay */}
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse at 50% 30%, ${COLORS.primary}15 0%, transparent 60%)`,
}}
/>
{/* === HOOK === */}
<div
style={{
position: "absolute",
top: "38%",
left: "50%",
transform: `translate(-50%, -50%) translateY(${hookY}px)`,
opacity: hookOpacity,
width: "85%",
textAlign: "center",
}}
>
<div
style={{
fontSize: 68,
fontWeight: 800,
color: COLORS.white,
lineHeight: 1.15,
letterSpacing: -1,
}}
>
{hookText}
</div>
</div>
{/* === PHONE + BODY TEXT === */}
<div
style={{
position: "absolute",
top: "8%",
left: "50%",
transform: `translateX(-50%) scale(${phoneScale})`,
opacity: phoneOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 30,
}}
>
{/* Body text above phone */}
<div
style={{
opacity: bodyTextOpacity,
fontSize: 36,
fontWeight: 600,
color: COLORS.white,
textAlign: "center",
maxWidth: width * 0.8,
lineHeight: 1.3,
}}
>
{bodyText}
</div>
{/* Phone mockup — real phone.png frame over screenshot */}
<div
style={{
width: phoneWidth,
height: phoneHeight,
position: "relative",
filter: "drop-shadow(0 30px 60px rgba(0,0,0,0.5))",
}}
>
{/* Screenshot behind the frame — clipped to screen area */}
<div
style={{
position: "absolute",
top: "3.2%",
left: "4.2%",
width: "91.6%",
height: "93.6%",
borderRadius: phoneWidth * 0.065,
overflow: "hidden",
}}
>
<Img
src={screenshotSrc}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "top center",
}}
/>
</div>
{/* Phone frame on top */}
<Img
src={staticFile("phone.png")}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</div>
</div>
{/* === PROOF === */}
<div
style={{
position: "absolute",
top: "42%",
left: "50%",
transform: `translate(-50%, -50%) scale(${proofScale})`,
opacity: proofOpacity,
textAlign: "center",
}}
>
<div
style={{
fontSize: 44,
fontWeight: 700,
color: COLORS.accent,
marginBottom: 16,
}}
>
{proofText}
</div>
<div
style={{
fontSize: 28,
fontWeight: 500,
color: "rgba(255,255,255,0.7)",
}}
>
Join thousands of homeowners
</div>
</div>
{/* === CTA === */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
opacity: ctaOpacity,
transform: `scale(${ctaScale})`,
gap: 40,
}}
>
{/* Brand name header */}
<div
style={{
fontSize: 56,
fontWeight: 800,
color: COLORS.white,
letterSpacing: -1,
textAlign: "center",
}}
>
honeyDue
</div>
{/* Icon — 50% of canvas width, centered */}
<Img
src={staticFile("icon.png")}
style={{
width: width * 0.5,
height: "auto",
borderRadius: 32,
}}
/>
{/* CTA button */}
<div
style={{
backgroundColor: COLORS.primary,
color: COLORS.white,
fontSize: 36,
fontWeight: 700,
padding: "24px 72px",
borderRadius: 20,
textAlign: "center",
boxShadow: `0 8px 32px ${COLORS.primary}80`,
transform: `scale(${ctaPulse})`,
}}
>
{ctaText}
</div>
</div>
</AbsoluteFill>
);
};
+146
View File
@@ -0,0 +1,146 @@
import { Composition, staticFile } from "remotion";
import { HoneyDueAd } from "./HoneyDueAd";
const SCREENSHOT = staticFile("tasks_overdue.png");
// 15s @ 30fps for Instagram, 12s for TikTok
const IG_FRAMES = 450;
const TT_FRAMES = 360;
export const RemotionRoot: React.FC = () => {
return (
<>
{/* === GEMINI AD VIDEOS === */}
<Composition
id="Gemini-IG-Feed-Cost"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "A $10 filter.\nA $4,200 repair.",
bodyText: "honeyDue tracks every task so you never skip the small stuff.",
ctaText: "Download Free",
proofText: "Trusted by thousands of homeowners",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Gemini-IG-Stories-FirstTimer"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "Just bought my first house.\nNobody told me about all this.",
bodyText: "This app tells you what to fix and when.",
ctaText: "Try honeyDue",
proofText: "First-time homeowners love this",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Gemini-TT-SilentTodo"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "Your house has a\nhidden to-do list.",
bodyText: "HVAC filters. Gutters. Water heater. honeyDue sees it all.",
ctaText: "Get Started Free",
proofText: "Never miss maintenance again",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Gemini-TT-Forgetter"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "I forgot for 2 years.",
bodyText: "honeyDue would have reminded me 8 times by now.",
ctaText: "Download Free",
proofText: "Your home maintenance safety net",
screenshotSrc: SCREENSHOT,
}}
/>
{/* === CANVAS POSTER VIDEOS === */}
<Composition
id="Poster-IG-Feed-Cost"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "A $10 filter.\nA $4,200 repair.",
bodyText: "The difference between a reminder and a disaster.",
ctaText: "Download Free",
proofText: "Join thousands of organized homeowners",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Poster-IG-Stories-FirstHouse"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "Just bought my first house.",
bodyText: "100+ maintenance tasks. One app to track them all.",
ctaText: "Try honeyDue",
proofText: "Built for first-time homeowners",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Poster-TT-HiddenTodo"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "Your house has a\nhidden to-do list.",
bodyText: "Filters. Gutters. Drains. Vents. honeyDue tracks all of it.",
ctaText: "Get Started Free",
proofText: "See what you've been missing",
screenshotSrc: SCREENSHOT,
}}
/>
<Composition
id="Poster-TT-HVAC2Years"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "I forgot my HVAC filter\nfor 2 years.",
bodyText: "That's 8 missed reminders honeyDue would have sent.",
ctaText: "Download Free",
proofText: "Never forget again",
screenshotSrc: SCREENSHOT,
}}
/>
</>
);
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}
+70
View File
@@ -0,0 +1,70 @@
import { tavily } from "@tavily/core";
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
const OUTPUT_DIR = join(
import.meta.dirname,
"..",
"outputs",
"task_management_feature_launch_20260323"
);
const queries = [
"home maintenance app download conversion strategies app store optimization 2026",
"homeowner forgot maintenance costly repair stories real examples",
"mobile app Instagram Reels ad creative best practices high CTR 2026",
"TikTok home improvement property management app ads that convert",
"millennial first time homeowner maintenance anxiety solutions apps",
];
async function runResearch() {
const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
const results = [];
for (const query of queries) {
console.log(`Researching: "${query}"`);
try {
const response = await client.search(query, {
searchDepth: "advanced",
maxResults: 10,
});
results.push({
query,
timestamp: new Date().toISOString(),
resultCount: response.results.length,
results: response.results.map((r) => ({
title: r.title,
url: r.url,
content: r.content,
score: r.score,
})),
});
console.log(`${response.results.length} results`);
} catch (err) {
console.error(` ✗ Error for "${query}": ${err.message}`);
results.push({
query,
timestamp: new Date().toISOString(),
resultCount: 0,
results: [],
error: err.message,
});
}
}
const output = {
generatedAt: new Date().toISOString(),
agent: "marketing-research",
campaign: "task_management_feature_launch",
queryCount: queries.length,
queries,
results,
};
mkdirSync(OUTPUT_DIR, { recursive: true });
const outPath = join(OUTPUT_DIR, "research_results.json");
writeFileSync(outPath, JSON.stringify(output, null, 2));
console.log(`\nSaved: ${outPath}`);
}
runResearch();
+103
View File
@@ -0,0 +1,103 @@
import { tavily } from "@tavily/core";
import { writeFileSync } from "fs";
const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
const OUTPUT_DIR = "/Users/treyt/Desktop/code/claude_marketing/pipeline/outputs/task_management_feature_launch_20260323";
const queries = [
{
query_id: 1,
query_name: "Industry Trends & Market Landscape",
search_terms: "home maintenance app market trends 2025 2026 property management software homeowners smart home task tracking reminders growth",
options: {
searchDepth: "advanced",
maxResults: 10,
topic: "news",
days: 30,
excludeDomains: ["pinterest.com", "etsy.com"]
}
},
{
query_id: 2,
query_name: "Competitor Analysis",
search_terms: "Centriq app vs HomeZada vs Thumbtack home maintenance tracking app features pricing reviews marketing 2025 2026",
options: {
searchDepth: "advanced",
maxResults: 10,
topic: "general",
includeDomains: ["centriq.com", "homezada.com", "thumbtack.com", "techcrunch.com", "producthunt.com", "g2.com", "capterra.com"],
excludeDomains: ["pinterest.com"]
}
},
{
query_id: 3,
query_name: "Audience Pain Points & Conversations",
search_terms: "homeowner forgot HVAC filter change maintenance tasks overwhelmed home upkeep checklist first time homebuyer maintenance schedule busy parents household chores",
options: {
searchDepth: "advanced",
maxResults: 10,
topic: "general",
includeDomains: ["reddit.com", "twitter.com", "quora.com", "houzz.com"],
excludeDomains: ["pinterest.com"]
}
},
{
query_id: 4,
query_name: "High-Performing Hooks & Ad Copy",
search_terms: "best performing mobile app ad hooks 2025 2026 home productivity app Instagram TikTok ad copy examples high engagement task management app marketing download conversion",
options: {
searchDepth: "advanced",
maxResults: 10,
topic: "general",
excludeDomains: ["pinterest.com", "etsy.com"]
}
},
{
query_id: 5,
query_name: "Viral Content & Cultural Moments",
search_terms: "viral home maintenance TikTok homeowner hack spring cleaning 2026 home organization trending content adulting homeownership meme cleaning routine",
options: {
searchDepth: "advanced",
maxResults: 10,
topic: "news",
days: 14,
excludeDomains: ["pinterest.com"]
}
}
];
async function runQueries() {
const results = [];
for (const q of queries) {
console.log(`Executing query ${q.query_id}: ${q.query_name}...`);
try {
const response = await client.search(q.search_terms, q.options);
results.push({
query_id: q.query_id,
query_name: q.query_name,
search_terms: q.search_terms,
results_count: response.results ? response.results.length : 0,
results: response.results || [],
answer: response.answer || null
});
console.log(` -> Got ${response.results ? response.results.length : 0} results`);
} catch (err) {
console.error(` -> Error on query ${q.query_id}: ${err.message}`);
results.push({
query_id: q.query_id,
query_name: q.query_name,
search_terms: q.search_terms,
results_count: 0,
results: [],
error: err.message
});
}
}
writeFileSync(`${OUTPUT_DIR}/raw_research_results.json`, JSON.stringify(results, null, 2));
console.log(`\nRaw results saved to ${OUTPUT_DIR}/raw_research_results.json`);
}
runQueries();
+54
View File
@@ -0,0 +1,54 @@
import { tavily } from "@tavily/core";
import { writeFileSync } from "fs";
const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
const queries = [
{
name: "trending_hooks",
query: "trending social media hooks productivity apps 2026 viral opening lines scroll-stopping techniques instagram tiktok",
options: { topic: "news", days: 7, maxResults: 10 }
},
{
name: "competitor_ads",
query: "productivity app ad campaigns 2026 Notion Todoist TickTick Any.do advertising strategy messaging",
options: { topic: "news", days: 7, maxResults: 10, searchDepth: "advanced" }
},
{
name: "viral_formats",
query: "viral content formats instagram reels tiktok 2026 trending templates transitions app promotion",
options: { topic: "news", days: 7, maxResults: 10, searchDepth: "advanced" }
},
{
name: "audience_pain_points",
query: "productivity app complaints wishlist 2026 professionals time management frustrations reddit reviews",
options: { topic: "news", days: 7, maxResults: 10 }
},
{
name: "seasonal_timely",
query: "upcoming events March April 2026 productivity awareness days professional development seasonal marketing moments",
options: { topic: "news", days: 14, maxResults: 10 }
}
];
async function runSearches() {
const results = {};
for (const q of queries) {
console.log(`Searching: ${q.name}...`);
try {
const res = await client.search(q.query, q.options);
results[q.name] = res.results || [];
console.log(`${results[q.name].length} results`);
} catch (err) {
console.error(` → Error: ${err.message}`);
results[q.name] = [];
}
}
writeFileSync(
"/Users/treyt/Desktop/code/claude_marketing/pipeline/outputs/test_campaign_e2e_20260323/raw_search_results.json",
JSON.stringify(results, null, 2)
);
console.log("Done. Raw results saved.");
}
runSearches();

Some files were not shown because too many files have changed in this diff Show More