Files
ClaudeMarketing/components/asset-gallery.tsx
T
Trey t 80a1ffbe4d feat: add multi-app support with app switcher, per-app branding, and filtered queries
Apps share the same backend, API keys, and publishing flow but each gets its own
branding (name, colors, icon, app URL), knowledge files (brand identity, product
info, platform guidelines), and campaigns. The pipeline dynamically writes
_knowledge/ files and copies app assets before each run.

- Add App model with slug, colors, appUrl, and knowledge markdown fields
- Add appId FK to Campaign, seed honeyDue as first app with existing knowledge
- App switcher dropdown in sidebar with icon previews
- Filter campaigns, stats, and assets by active app (cookie-based)
- De-hardcode lib/claude.ts: AppConfig interface, templated prompts, dynamic
  _knowledge/ and Remotion asset copying
- App management pages (list, create, edit) with icon upload and color pickers
- Asset library sort options (newest, oldest, name, platform, type)
- Asset cards show creation date
- Remotion HoneyDueAd accepts colors/appName props

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:21:45 -05:00

224 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
createdAt: 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 [sort, setSort] = useState("newest");
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());
}
const sortedAssets = [...assets].sort((a, b) => {
switch (sort) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case "name-asc":
return a.fileName.localeCompare(b.fileName);
case "name-desc":
return b.fileName.localeCompare(a.fileName);
case "platform":
return (a.platform || "").localeCompare(b.platform || "");
case "type":
return a.type.localeCompare(b.type);
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
});
return (
<div className="space-y-4">
{/* Filters */}
<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>
<select
className="h-9 rounded-md border px-3 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value)}
>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
<option value="name-asc">Name AZ</option>
<option value="name-desc">Name ZA</option>
<option value="platform">Platform</option>
<option value="type">Type</option>
</select>
<Input
placeholder="Search..."
value={search}
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 */}
{sortedAssets.length === 0 ? (
<p className="text-center text-muted-foreground py-12">
No assets yet. Launch a pipeline to generate content.
</p>
) : (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{sortedAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
onStatusChange={handleStatusChange}
selected={selectedIds.has(asset.id)}
onSelect={toggleSelect}
/>
))}
</div>
)}
</div>
);
}