File size: 7,051 Bytes
dea9ad9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | import { useMemo, useState, useCallback } from "react";
import { ArrowRight, Film, Image, Search, SlidersHorizontal, Video } from "lucide-react";
import { JobCard } from "./JobCard";
import { MediaTile } from "./MediaTile";
import { generateVideo } from "../api";
import type { JobItem, MediaItem } from "../types";
type Filter = "all" | "images" | "videos";
interface StudioViewProps {
items: MediaItem[];
jobs: JobItem[];
loading: boolean;
error: string | null;
onOpen: (url: string) => void;
onDelete: (item: MediaItem) => Promise<void> | void;
onOpenProjects?: () => void;
}
function isVideo(item: MediaItem) {
return item.type === "video" || item.url.endsWith(".mp4") || item.url.endsWith(".webm");
}
export function StudioView({ items, jobs, loading, error, onOpen, onDelete, onOpenProjects }: StudioViewProps) {
const [filter, setFilter] = useState<Filter>("all");
const [query, setQuery] = useState("");
const [generatingVideo, setGeneratingVideo] = useState<Set<string>>(new Set());
const handleGenerateVideo = useCallback(async (item: MediaItem, motionPrompt: string) => {
const key = item.filename || item.url;
setGeneratingVideo((prev) => new Set(prev).add(key));
try {
await generateVideo({
image: item.filename || item.url.split("/").pop() || "",
prompt: motionPrompt || undefined,
});
} catch (err) {
console.error("I2V failed:", err);
} finally {
setGeneratingVideo((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}
}, []);
const imageCount = items.filter((item) => !isVideo(item)).length;
const videoCount = items.length - imageCount;
const visibleItems = useMemo(() => {
const q = query.trim().toLowerCase();
return items.filter((item) => {
if (filter === "images" && isVideo(item)) return false;
if (filter === "videos" && !isVideo(item)) return false;
if (!q) return true;
return [item.name, item.filename, item.prompt].some((value) => String(value || "").toLowerCase().includes(q));
});
}, [filter, items, query]);
return (
<div className="p-5 lg:p-7 space-y-6">
<section className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-rose-400/70">Workspace</p>
<h1 className="text-2xl font-bold tracking-tight mt-1">Studio</h1>
<p className="text-sm text-gray-500 mt-2">Your gallery for quick image and video generation — freeform, no structure. Generate, browse, iterate.</p>
</div>
<div className="grid grid-cols-3 gap-2 text-xs min-w-[260px]">
<div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
<p className="text-gray-600">Total</p>
<p className="text-lg font-semibold text-gray-200">{items.length}</p>
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
<p className="text-gray-600">Images</p>
<p className="text-lg font-semibold text-gray-200">{imageCount}</p>
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900/40 px-3 py-2">
<p className="text-gray-600">Videos</p>
<p className="text-lg font-semibold text-gray-200">{videoCount}</p>
</div>
</div>
</section>
<button
onClick={onOpenProjects}
className="group w-full rounded-2xl border border-violet-600/30 bg-gradient-to-r from-violet-950/30 via-indigo-950/20 to-gray-900/30 hover:border-violet-500/50 hover:from-violet-950/50 transition flex items-center gap-4 px-5 py-4 text-left"
>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center shadow-lg shadow-violet-500/20 ring-1 ring-white/10 flex-shrink-0">
<Film className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-violet-100">Got a specific idea? Turn it into a Project.</p>
<p className="text-xs text-gray-400 mt-1 leading-relaxed">
Studio is great for quick shots. <span className="text-gray-300">Projects</span> are for finished pieces — outline scenes, plan shots, generate, animate, and stitch together a real video.
</p>
</div>
<ArrowRight className="w-5 h-5 text-violet-400 group-hover:translate-x-1 transition flex-shrink-0" />
</button>
<section className="rounded-2xl border border-gray-800/60 bg-gray-950/50 p-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex gap-2">
{[
["all", "All", SlidersHorizontal],
["images", "Images", Image],
["videos", "Videos", Video],
].map(([id, label, Icon]) => (
<button
key={id as string}
onClick={() => setFilter(id as Filter)}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-xs font-medium transition ${
filter === id ? "bg-rose-600 text-white" : "bg-gray-900 text-gray-500 hover:text-gray-200"
}`}
>
<Icon className="w-3.5 h-3.5" />
{label as string}
</button>
))}
</div>
<label className="relative w-full lg:w-80">
<Search className="w-4 h-4 text-gray-600 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search studio"
className="w-full rounded-xl bg-black/40 border border-gray-800 pl-9 pr-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-rose-600 placeholder:text-gray-700"
/>
</label>
</section>
{loading && items.length === 0 && jobs.length === 0 ? (
<p className="text-gray-500">Loading...</p>
) : error ? (
<div className="rounded-lg border border-red-900/60 bg-red-950/20 p-4 text-sm text-red-300">{error}</div>
) : jobs.length === 0 && visibleItems.length === 0 ? (
<div className="rounded-2xl border border-gray-800/60 bg-gray-900/20 p-10 text-center">
<Image className="w-8 h-8 text-gray-700 mx-auto mb-3" />
<p className="text-sm text-gray-500">No media found.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{jobs.map((job) => <JobCard key={job.prompt_id} job={job} />)}
{visibleItems.map((item) => (
<MediaTile
key={item.filename || item.url}
item={item}
onOpen={() => onOpen(item.url)}
onDelete={onDelete}
onGenerateVideo={handleGenerateVideo}
/>
))}
</div>
)}
</div>
);
}
|