| 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"; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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"; |
|
|
| |
| 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_ }; |
| } |
|
|
| |
| 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> |
|
|
| {} |
| <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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| 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"); |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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 }); |
| } |
| }; |
|
|
| |
| 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; |
| }); |
|
|
| |
| 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> |
| ); |
| } |
|
|