- 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
386 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|