80a1ffbe4d
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>
205 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|