Files
ClaudeMarketing/components/asset-gallery.tsx
T
Trey t 66c2bbec8b 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>
2026-03-23 21:05:26 -05:00

191 lines
5.5 KiB
TypeScript

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