apix/components/UploadHistory.tsx
Khoa.vo 8741e3b89f
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
feat: Initial commit with multi-provider image generation
2026-01-05 13:50:35 +07:00

379 lines
18 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';
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
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
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/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 items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="p-3 bg-secondary rounded-xl text-primary">
<Clock className="h-6 w-6" />
</div>
<div>
<h2 className="text-2xl font-bold">Uploads</h2>
<p className="text-muted-foreground">Your reference collection.</p>
</div>
</div>
{history.length > 0 && (
<button
onClick={handleClear}
className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
<span>Clear All</span>
</button>
)}
</div>
)}
{/* Filter Tabs */}
<div className="flex items-center gap-2 mb-6 bg-secondary/30 p-1 rounded-xl w-fit">
{(['all', 'subject', 'scene', 'style', 'videos'] as const).map(cat => (
<button
key={cat}
onClick={() => setFilter(cat)}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
filter === cat
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
{cat}
</button>
))}
</div>
{/* Content Area */}
{filter === 'videos' ? (
// Video Grid
videos.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground p-12 bg-card/50 rounded-3xl border border-dashed border-border">
<div className="p-4 bg-secondary/50 rounded-full mb-4">
<Film className="h-8 w-8 opacity-50" />
</div>
<h3 className="text-lg font-medium mb-1">No videos yet</h3>
<p className="text-sm text-center max-w-xs">
Generate videos from your gallery images.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{videos.map((vid) => (
<div key={vid.id} className="group relative aspect-video rounded-xl overflow-hidden bg-black border shadow-sm">
<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-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
<button
onClick={() => removeVideo(vid.id)}
className="p-1.5 bg-black/50 hover:bg-destructive text-white rounded-full transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<p className="text-white text-xs line-clamp-1">{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-12 bg-card/50 rounded-3xl border border-dashed border-border">
<div className="p-4 bg-secondary/50 rounded-full mb-4">
<Clock className="h-8 w-8 opacity-50" />
</div>
<h3 className="text-lg font-medium mb-1">No uploads yet</h3>
<p className="text-sm text-center max-w-xs">
Drag and drop images here to upload.
</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>
);
}