kioai / artifacts /image-gen /src /pages /History.tsx
kinaiok
Initial deployment setup for Hugging Face Spaces
5ef6e9d
import { useState, useEffect } from "react";
import { useGetImageHistory, useDeleteImage, getGetImageHistoryQueryKey } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import {
Download, Trash2, ImageIcon, Loader2, Lock, Globe,
Film, Play, Clapperboard,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import { useLang } from "@/contexts/LanguageContext";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
// ── Types ─────────────────────────────────────────────────────────────────────
interface VideoRecord {
id: number;
videoUrl: string;
thumbnailUrl: string | null;
prompt: string;
negativePrompt: string | null;
model: string;
aspectRatio: string;
resolution: string;
duration: number;
hasRefImage: boolean;
isPrivate: boolean;
userId: number | null;
createdAt: string;
}
// ── Constants ─────────────────────────────────────────────────────────────────
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
const styleLabels: Record<string, string> = {
none: "η„‘ι’¨ζ Ό", realistic: "ε―«ε―¦", anime: "ε‹•ζΌ«", artistic: "藝葓",
cartoon: "ε‘ι€š", sketch: "素描", oil_painting: "ζ²Ήη•«",
watercolor: "水彩", digital_art: "數位藝葓",
};
const modelLabels: Record<string, string> = {
grok: "Grok", meta: "Meta",
"imagen-pro": "Imagen Pro", "imagen-4": "Imagen 4",
"imagen-flash": "Imagen Flash",
"nano-banana-pro": "Nano Banana Pro", "nano-banana-2": "Nano Banana 2",
};
const videoModelLabels: Record<string, string> = {
"grok-3": "Grok-3", "veo-3-fast": "Veo 3.1 Fast",
};
type FilterTab = "all" | "public" | "private";
type MediaTab = "images" | "videos";
// ── useVideoHistory hook ───────────────────────────────────────────────────────
function useVideoHistory() {
const [videos, setVideos] = useState<VideoRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetch_ = async () => {
setIsLoading(true);
setError(null);
try {
const resp = await fetch(`${BASE}/api/videos/history?limit=50`, { credentials: "include" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as { videos: VideoRecord[] };
setVideos(data.videos || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setIsLoading(false);
}
};
useEffect(() => { fetch_(); }, []);
return { videos, isLoading, error, refetch: fetch_ };
}
// ── VideoCard ─────────────────────────────────────────────────────────────────
function VideoCard({ video, onDelete }: { video: VideoRecord; onDelete: (id: number) => void }) {
const { t } = useLang();
const [playing, setPlaying] = useState(false);
const resolveUrl = (url: string) =>
url.startsWith("/") ? `${BASE}${url}` : url;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="bg-card border border-border rounded-xl overflow-hidden group shadow-sm hover:shadow-md transition-shadow"
>
{/* Media area */}
<div className="relative overflow-hidden bg-black" style={{ aspectRatio: video.aspectRatio === "9:16" ? "9/16" : video.aspectRatio === "1:1" ? "1/1" : "16/9" }}>
{playing ? (
<video
src={resolveUrl(video.videoUrl)}
autoPlay
controls
className="w-full h-full object-contain"
onEnded={() => setPlaying(false)}
/>
) : (
<>
{video.thumbnailUrl ? (
<img
src={resolveUrl(video.thumbnailUrl)}
alt={video.prompt}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-secondary/30">
<Clapperboard className="w-12 h-12 text-muted-foreground/40" />
</div>
)}
{/* Play overlay */}
<button
onClick={() => setPlaying(true)}
className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity"
>
<div className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-sm border border-white/40 flex items-center justify-center">
<Play className="w-6 h-6 text-white fill-white ml-1" />
</div>
</button>
{/* Privacy badge */}
<div className="absolute top-2 left-2">
{video.isPrivate ? (
<span className="flex items-center gap-1 px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded-full text-amber-400 text-xs font-medium">
<Lock className="w-3 h-3" /> {t.historyTabPrivate}
</span>
) : (
<span className="flex items-center gap-1 px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded-full text-sky-400 text-xs font-medium">
<Globe className="w-3 h-3" /> {t.historyTabPublic}
</span>
)}
</div>
{/* Model badge */}
<div className="absolute top-2 right-2">
<span className="px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded-full text-violet-300 text-xs font-medium">
{videoModelLabels[video.model] || video.model}
</span>
</div>
{/* Duration badge */}
<div className="absolute bottom-2 right-2">
<span className="px-2 py-0.5 bg-black/70 backdrop-blur-sm rounded-full text-white text-xs">
{video.duration}s
</span>
</div>
{/* Ref image badge */}
{video.hasRefImage && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded-full text-green-300 text-xs">
i2v
</span>
</div>
)}
</>
)}
</div>
{/* Info + actions */}
<div className="p-4">
<p className="text-sm font-medium line-clamp-2 mb-3 h-10 text-foreground" title={video.prompt}>
{video.prompt}
</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground mb-3">
<span className="px-2 py-1 bg-secondary rounded-md">{video.aspectRatio}</span>
<span className="px-2 py-1 bg-secondary rounded-md">{video.resolution}</span>
<span className="px-2 py-1 bg-secondary rounded-md">{video.duration}s</span>
</div>
<div className="flex items-center gap-2">
<a
href={resolveUrl(video.videoUrl)}
download={`video-${video.id}.mp4`}
target="_blank"
rel="noopener noreferrer"
className="flex-1"
>
<Button variant="outline" size="sm" className="w-full text-xs h-8 gap-1.5">
<Download className="w-3.5 h-3.5" />
{t.historyDownload}
</Button>
</a>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8 px-2 text-destructive border-destructive/30 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.historyDeleteVidTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.historyDeleteVidDesc}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.poolCancel}</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(video.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t.historyConfirmDelete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</motion.div>
);
}
// ── ImageCard ─────────────────────────────────────────────────────────────────
function ImageCard({ img, onDelete, onDownload }: {
img: any;
onDelete: (id: number) => void;
onDownload: (url: string) => void;
}) {
const { t } = useLang();
const imgPrivate = (img as any).isPrivate === true;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="bg-card border border-border rounded-xl overflow-hidden group shadow-sm hover:shadow-md transition-shadow"
>
<div className="relative aspect-square overflow-hidden bg-secondary/50">
<img
src={img.imageUrl}
alt={img.prompt}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute top-2 left-2">
{imgPrivate ? (
<span className="flex items-center gap-1 px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded-full text-amber-400 text-xs font-medium">
<Lock className="w-3 h-3" /> {t.historyTabPrivate}
</span>
) : (
<span className="flex items-center gap-1 px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded-full text-sky-400 text-xs font-medium">
<Globe className="w-3 h-3" /> {t.historyTabPublic}
</span>
)}
</div>
<div className="absolute inset-0 bg-background/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4 backdrop-blur-sm">
<Button variant="secondary" size="icon" className="h-12 w-12 rounded-full" onClick={() => onDownload(img.imageUrl)}>
<Download className="h-5 w-5" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="icon" className="h-12 w-12 rounded-full">
<Trash2 className="h-5 w-5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.historyDeleteImgTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.historyDeleteImgDesc}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.poolCancel}</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(img.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t.historyConfirmDelete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="p-4">
<p className="text-sm font-medium line-clamp-2 mb-3 h-10" title={img.prompt}>
{img.prompt}
</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="px-2 py-1 bg-secondary rounded-md">{styleLabels[img.style] || img.style}</span>
<span className="px-2 py-1 bg-secondary rounded-md">{img.aspectRatio}</span>
<span className="px-2 py-1 bg-secondary rounded-md">{modelLabels[img.model] || img.model}</span>
</div>
</div>
</motion.div>
);
}
// ── History page ───────────────────────────────────────────────────────────────
export function History() {
const { toast } = useToast();
const { t } = useLang();
const queryClient = useQueryClient();
const { isSignedIn } = useAuth();
const [mediaTab, setMediaTab] = useState<MediaTab>("images");
const [filter, setFilter] = useState<FilterTab>("all");
// ── Image history ──────────────────────────────────────────────────────────
const { data: imgData, isLoading: imgLoading } = useGetImageHistory(undefined, {
query: { enabled: true, queryKey: getGetImageHistoryQueryKey() },
});
const { mutate: deleteImage } = useDeleteImage();
const handleDeleteImage = (id: number) => {
deleteImage({ id }, {
onSuccess: () => {
toast({ title: t.historyDeletedImg });
queryClient.invalidateQueries({ queryKey: getGetImageHistoryQueryKey() });
},
onError: () => toast({ variant: "destructive", title: t.historyDeleteFailed }),
});
};
const handleDownloadImage = (url: string) => {
const a = document.createElement("a");
a.href = url;
a.download = "generated-image.png";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ── Video history ──────────────────────────────────────────────────────────
const { videos, isLoading: vidLoading, refetch: refetchVideos } = useVideoHistory();
const handleDeleteVideo = async (id: number) => {
try {
const resp = await fetch(`${BASE}/api/videos/${id}`, { method: "DELETE", credentials: "include" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
toast({ title: t.historyDeletedVid });
refetchVideos();
} catch {
toast({ variant: "destructive", title: t.historyDeleteFailed });
}
};
// ── Derived lists with filter ──────────────────────────────────────────────
const allImages = imgData?.images || [];
const filteredImages = allImages.filter((img) => {
if (filter === "private") return (img as any).isPrivate === true;
if (filter === "public") return !(img as any).isPrivate;
return true;
});
const filteredVideos = videos.filter((vid) => {
if (!isSignedIn) return !vid.isPrivate;
if (filter === "private") return vid.isPrivate === true;
if (filter === "public") return !vid.isPrivate;
return true;
});
// ── Filter tabs ────────────────────────────────────────────────────────────
const filterTabs: { key: FilterTab; label: string }[] = [
{ key: "all", label: t.historyTabAll },
{ key: "public", label: t.historyTabPublic },
{ key: "private", label: t.historyTabPrivate },
];
const isLoading = mediaTab === "images" ? imgLoading : vidLoading;
const isEmpty = mediaTab === "images" ? allImages.length === 0 : videos.length === 0;
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* Page header */}
<div className="mb-6">
<h1 className="text-3xl font-bold mb-1">{t.navHistory}</h1>
<p className="text-muted-foreground text-sm">ε›žι‘§ζ‚¨ιŽεΎ€ηš„ιˆζ„Ÿε‰΅δ½œ</p>
</div>
{/* ── Media type tabs ── */}
<div className="flex gap-1 mb-5 bg-secondary/50 rounded-xl p-1 w-fit">
<button
onClick={() => setMediaTab("images")}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
mediaTab === "images"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<ImageIcon className="w-4 h-4" />
{t.historyImages}
{!imgLoading && allImages.length > 0 && (
<span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded-full">
{allImages.length}
</span>
)}
</button>
<button
onClick={() => setMediaTab("videos")}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
mediaTab === "videos"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Film className="w-4 h-4" />
{t.historyVideos}
{!vidLoading && videos.length > 0 && (
<span className="text-xs bg-violet-500/20 text-violet-400 px-1.5 py-0.5 rounded-full">
{videos.length}
</span>
)}
</button>
</div>
{/* ── Visibility filter tabs (only when signed in & not empty) ── */}
{isSignedIn && !isEmpty && (
<div className="flex gap-2 mb-5">
{filterTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors border ${
filter === tab.key
? "bg-primary text-primary-foreground border-primary"
: "bg-secondary text-muted-foreground border-border hover:border-primary/40"
}`}
>
{tab.label}
{tab.key === "private" && <Lock className="inline ml-1.5 w-3 h-3" />}
</button>
))}
</div>
)}
{/* ── Content ── */}
<AnimatePresence mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="py-20 flex flex-col items-center justify-center"
>
<Loader2 className="w-10 h-10 text-primary animate-spin mb-4" />
<p className="text-muted-foreground text-sm">
{mediaTab === "images" ? "θΌ‰ε…₯εœ–η‰‡θ¨˜ιŒ„δΈ­..." : t.historyLoadingVideos}
</p>
</motion.div>
) : isEmpty ? (
/* Global empty state */
<motion.div
key="empty"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}
className="py-24 flex flex-col items-center justify-center text-center"
>
<div className="w-24 h-24 bg-secondary rounded-full flex items-center justify-center mb-6">
{mediaTab === "images"
? <ImageIcon className="w-12 h-12 text-muted-foreground" />
: <Film className="w-12 h-12 text-muted-foreground" />
}
</div>
<h2 className="text-2xl font-semibold mb-2">
{mediaTab === "images" ? t.historyEmptyImages : t.historyEmptyVideos}
</h2>
<p className="text-muted-foreground max-w-md text-sm">
{mediaTab === "images" ? t.historyEmptyImagesDesc : t.historyEmptyVideosDesc}
</p>
</motion.div>
) : mediaTab === "images" ? (
/* ── Images grid ── */
<motion.div key="images-grid" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
{filteredImages.length === 0 ? (
<div className="py-20 flex flex-col items-center text-center text-muted-foreground">
<ImageIcon className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">{t.historyNoItemsInFilter}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence>
{filteredImages.map((img) => (
<ImageCard
key={img.id}
img={img}
onDelete={handleDeleteImage}
onDownload={handleDownloadImage}
/>
))}
</AnimatePresence>
</div>
)}
</motion.div>
) : (
/* ── Videos grid ── */
<motion.div key="videos-grid" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
{filteredVideos.length === 0 ? (
<div className="py-20 flex flex-col items-center text-center text-muted-foreground">
<Film className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">{t.historyNoItemsInFilter}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence>
{filteredVideos.map((vid) => (
<VideoCard
key={vid.id}
video={vid}
onDelete={handleDeleteVideo}
/>
))}
</AnimatePresence>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}