Files
ClaudeMarketing/components/asset-card.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

205 lines
6.4 KiB
TypeScript

"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;
createdAt: 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>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{asset.campaign && <span>{asset.campaign.name}</span>}
{asset.campaign && asset.createdAt && <span>·</span>}
{asset.createdAt && (
<span>
{new Date(asset.createdAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
)}
</div>
{/* 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>
);
}