apix/components/UploadHistory.tsx
Khoa.vo bec553fd76
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
v3.2.0: Fix CORS - add nginx reverse proxy for production
- Add nginx to Dockerfile as reverse proxy
- Route /api/* to FastAPI, / to Next.js on single port 80
- Update all frontend components to use /api prefix in production
- Simplify docker-compose to single port 80
- Fixes CORS errors when deployed to remote servers
2026-01-13 08:11:28 +07:00

386 lines
19 KiB
TypeScript

"use client";
import React from 'react';
import { useStore, ReferenceCategory } from '@/lib/store';
import { Clock, Upload, Trash2, CheckCircle, X, Film, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
// FastAPI backend URL - /api in production (nginx proxy), localhost in dev
const API_BASE = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? '/api' : 'http://localhost:8000');
export function UploadHistory() {
const {
history, setHistory,
selectionMode, setSelectionMode,
setCurrentView,
settings,
videos, addVideo, removeVideo,
removeFromHistory,
// Multi-select support
references,
addReference,
removeReference,
clearReferences
} = useStore();
const handleClear = () => {
if (confirm("Clear all upload history?")) {
setHistory([]);
}
};
// Check if an item is currently selected as a reference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSelected = (item: any) => {
if (!selectionMode) return false;
const categoryRefs = references[selectionMode as ReferenceCategory] || [];
const itemId = item.mediaId || item.id;
return categoryRefs.some(ref => ref.id === itemId);
};
// Toggle selection - add or remove from references
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleToggleSelect = (item: any) => {
if (!selectionMode) return;
const itemId = item.mediaId || item.id;
const ref = { id: itemId, thumbnail: item.url };
if (isSelected(item)) {
removeReference(selectionMode as ReferenceCategory, itemId);
} else {
addReference(selectionMode as ReferenceCategory, ref);
}
};
// Done - confirm selection and return to gallery
const handleDone = () => {
setSelectionMode(null);
setCurrentView('gallery');
};
const handleCancelSelection = () => {
// Clear selections for this category and go back
if (selectionMode) {
clearReferences(selectionMode as ReferenceCategory);
}
setSelectionMode(null);
setCurrentView('gallery');
};
// Get count of selected items for current category
const selectedCount = selectionMode
? (references[selectionMode as ReferenceCategory] || []).length
: 0;
const [filter, setFilter] = React.useState<string>('all');
const filteredHistory = history.filter(item => {
if (filter === 'all') return true;
return item.category === filter;
});
const [dragActive, setDragActive] = React.useState(false);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
if (!file.type.startsWith('image/')) return;
// Determine category
// 1. If selectionMode is active (e.g. "subject"), use that.
// 2. If filter is not 'all', use that.
// 3. Default to 'subject'.
let category: string = 'subject';
if (selectionMode) category = selectionMode;
else if (filter !== 'all') category = filter;
// Upload
try {
const reader = new FileReader();
reader.onload = async (ev) => {
const base64 = ev.target?.result as string;
if (!base64) return;
// Optimistic UI update could happen here
const res = await fetch(`${API_BASE}/references/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageBase64: base64,
mimeType: file.type,
category: category,
cookies: settings.whiskCookies
})
});
const data = await res.json();
if (data.id) {
const newItem = {
id: data.id,
url: base64,
category: category,
originalName: file.name
};
setHistory([newItem, ...history]);
// If in selection mode, auto-select it
if (selectionMode) {
handleToggleSelect(newItem);
}
} else {
alert(`Upload failed: ${data.error}`);
}
}
reader.readAsDataURL(file);
} catch (err) {
console.error(err);
}
}
};
return (
<div
className={cn("max-w-6xl mx-auto p-4 md:p-8 pb-32 min-h-[50vh]", dragActive && "bg-primary/5")}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{/* Selection Mode Header - Sticky */}
{selectionMode && (
<div className="sticky top-0 z-10 mb-6 -mx-4 px-4 py-4 bg-background/80 backdrop-blur-md border-b border-primary/20 animate-in slide-in-from-top-2">
<div className="flex items-center justify-between max-w-6xl mx-auto">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary text-primary-foreground rounded-full">
<CheckCircle className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-bold text-primary capitalize">Select {selectionMode}</h3>
<p className="text-xs text-muted-foreground">
{selectedCount > 0
? `${selectedCount} selected — click photos to add/remove`
: 'Click photos to select'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{selectedCount > 0 && (
<button
onClick={() => clearReferences(selectionMode as ReferenceCategory)}
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-secondary rounded-lg transition-colors"
>
Clear
</button>
)}
<button
onClick={handleCancelSelection}
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleDone}
className="px-4 py-1.5 text-sm bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg font-medium transition-colors"
>
Done{selectedCount > 0 ? ` (${selectedCount})` : ''}
</button>
</div>
</div>
</div>
)}
{!selectionMode && (
<div className="flex flex-col items-center text-center gap-6 mb-12">
<div className="flex flex-col items-center gap-3">
<div className="p-4 bg-secondary rounded-2xl text-primary shadow-sm">
<Clock className="h-8 w-8" />
</div>
<div>
<h2 className="text-3xl font-black tracking-tight">Uploads</h2>
<p className="text-muted-foreground text-sm font-medium">Your reference collection.</p>
</div>
</div>
{history.length > 0 && (
<button
onClick={handleClear}
className="flex items-center gap-2 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-destructive hover:bg-destructive/10 rounded-full border border-destructive/20 transition-all active:scale-95"
>
<Trash2 className="h-3.5 w-3.5" />
<span>Clear All History</span>
</button>
)}
</div>
)}
{/* Filter Tabs */}
<div className="flex justify-center mb-10 overflow-hidden">
<div className="flex flex-wrap items-center justify-center gap-1.5 bg-secondary/30 p-1.5 rounded-3xl border border-border/50 shadow-soft max-w-full">
{(['all', 'subject', 'scene', 'style', 'videos'] as const).map(cat => (
<button
key={cat}
onClick={() => setFilter(cat)}
className={cn(
"px-5 md:px-6 py-2 md:py-2.5 rounded-2xl text-[10px] md:text-xs font-black transition-all capitalize uppercase tracking-widest active:scale-95 whitespace-nowrap",
filter === cat
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
{cat}
</button>
))}
</div>
</div>
{/* Content Area */}
{filter === 'videos' ? (
// Video Grid
videos.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground p-16 bg-card/30 rounded-[2.5rem] border border-dashed border-border/50 max-w-2xl mx-auto">
<div className="p-6 bg-secondary/30 rounded-full mb-6">
<Film className="h-10 w-10 opacity-40 text-primary" />
</div>
<h3 className="text-xl font-black text-foreground mb-2">No videos yet</h3>
<p className="text-sm text-center font-medium opacity-60">
Generate videos from your gallery images using the primary generator.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{videos.map((vid) => (
<div key={vid.id} className="group relative aspect-video rounded-2xl overflow-hidden bg-black border border-border/50 shadow-lg">
<video
src={vid.url}
poster={`data:image/png;base64,${vid.thumbnail}`}
className="w-full h-full object-cover"
controls
preload="metadata"
/>
<div className="absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-all pointer-events-none group-hover:pointer-events-auto">
<button
onClick={() => removeVideo(vid.id)}
className="p-2 bg-black/60 hover:bg-destructive text-white rounded-xl backdrop-blur-md transition-all active:scale-90"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/95 via-black/40 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-all pointer-events-none">
<p className="text-white text-[10px] font-medium line-clamp-2 uppercase tracking-tight">{vid.prompt}</p>
</div>
</div>
))}
</div>
)
) : (
// Image/Uploads Grid (Existing Logic)
history.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground p-16 bg-card/30 rounded-[2.5rem] border border-dashed border-border/50 max-w-2xl mx-auto">
<div className="p-6 bg-secondary/30 rounded-full mb-6">
<Upload className="h-10 w-10 opacity-40 text-primary" />
</div>
<h3 className="text-xl font-black text-foreground mb-2">No uploads yet</h3>
<p className="text-sm text-center font-medium opacity-60">
Drag and drop images anywhere or use the plus buttons in the creator.
</p>
</div>
) : (
<>
{filteredHistory.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
No uploads in this category.
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredHistory.map((item) => {
const selected = isSelected(item);
return (
<div
key={item.id}
className={cn(
"group relative aspect-square rounded-xl overflow-hidden bg-card border-2 transition-all text-left",
selectionMode && selected
? "ring-4 ring-primary border-primary"
: selectionMode
? "hover:ring-2 hover:ring-primary/50 border-transparent"
: "border-transparent hover:border-primary/50"
)}
>
<img
src={item.url}
alt={item.originalName}
className="w-full h-full object-cover transition-transform group-hover:scale-105 pointer-events-none"
/>
{/* Selection Overlay - Handles Click */}
<div
onClick={() => handleToggleSelect(item)}
className="absolute inset-0 z-10 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleToggleSelect(item);
}
}}
/>
{/* Checkmark for selected items */}
{selectionMode && selected && (
<div className="absolute top-2 left-2 z-30 p-1 bg-primary rounded-full text-primary-foreground shadow-lg">
<Check className="h-4 w-4" strokeWidth={3} />
</div>
)}
{/* info overlay (z-20 inside, visual only) */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none z-20">
<p className="text-white text-xs truncate mb-2">{item.originalName}</p>
<div className="flex gap-2 justify-end">
<span className="text-[10px] px-2 py-1 bg-white/20 rounded-full text-white uppercase backdrop-blur-md">
{item.category}
</span>
</div>
</div>
{/* Delete Button - Isolated on Top (z-50) */}
{!selectionMode && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFromHistory(item.id);
}}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 p-2 bg-black/50 hover:bg-destructive text-white rounded-full opacity-0 group-hover:opacity-100 transition-all z-50 cursor-pointer pointer-events-auto"
title="Delete"
>
<Trash2 className="h-4 w-4 pointer-events-none" />
</button>
)}
</div>
);
})}
</div>
)}
</>
)
)}
</div>
);
}