- Add tooltip explaining what data improves persona accuracy - Add "What data should I upload?" section with bullet list - Update "What's included in the personas?" section with revised copy - Add PowerPoint support to allowed file types - Make FieldTooltip example prop optional for simpler tooltips 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
802 lines
No EOL
28 KiB
TypeScript
802 lines
No EOL
28 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { Upload, UploadCloud, X, FileText, Image as ImageIcon, FileVideo, Loader2, RefreshCw, Edit3, Check, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { focusGroupsApi } from '@/lib/api';
|
|
|
|
// Backend asset interface (matches what we get from the API)
|
|
interface BackendAsset {
|
|
filename: string;
|
|
original_name: string;
|
|
mime_type: string;
|
|
size: number;
|
|
user_assigned_name?: string;
|
|
upload_date: string;
|
|
}
|
|
|
|
// Local asset state for managing uploads
|
|
interface LocalAsset {
|
|
id: string;
|
|
file: File;
|
|
previewUrl?: string;
|
|
status: 'uploading' | 'uploaded' | 'failed' | 'retry';
|
|
backendAsset?: BackendAsset;
|
|
error?: string;
|
|
progress?: number;
|
|
}
|
|
|
|
interface AssetUploaderProps {
|
|
onAssetsChange?: (assets: BackendAsset[]) => void;
|
|
onUploadComplete?: (assets: BackendAsset[]) => void;
|
|
onUploadError?: (error: unknown) => void;
|
|
onFilesChange?: (files: File[]) => void; // for non-backend file tracking
|
|
focusGroupId?: string;
|
|
disabled?: boolean;
|
|
maxAssets?: number;
|
|
allowedTypes?: string[];
|
|
label?: string;
|
|
description?: string;
|
|
maxFileSize?: number; // in MB
|
|
enableRenaming?: boolean; // whether to show file renaming functionality
|
|
}
|
|
|
|
export default function AssetUploader({
|
|
onAssetsChange,
|
|
onUploadComplete,
|
|
onUploadError,
|
|
onFilesChange,
|
|
focusGroupId,
|
|
disabled = false,
|
|
maxAssets = 10,
|
|
allowedTypes = ['image/*', 'application/pdf', 'video/*'],
|
|
label = 'Upload Assets',
|
|
description = 'Upload creative assets for testing',
|
|
maxFileSize = 10, // 10MB default
|
|
enableRenaming = true
|
|
}: AssetUploaderProps) {
|
|
const [localAssets, setLocalAssets] = useState<LocalAsset[]>([]);
|
|
const [backendAssets, setBackendAssets] = useState<BackendAsset[]>([]);
|
|
const [editingAsset, setEditingAsset] = useState<string | null>(null);
|
|
const [editingName, setEditingName] = useState<string>('');
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const dropzoneRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Fetch existing backend assets when focusGroupId changes
|
|
useEffect(() => {
|
|
if (focusGroupId) {
|
|
fetchBackendAssets();
|
|
}
|
|
}, [focusGroupId]); // fetchBackendAssets is stable and doesn't need to be in deps
|
|
|
|
// Notify parent component of file changes for non-backend usage
|
|
useEffect(() => {
|
|
if (onFilesChange) {
|
|
const currentFiles = localAssets.map(asset => asset.file);
|
|
onFilesChange(currentFiles);
|
|
}
|
|
}, [localAssets, onFilesChange]);
|
|
|
|
const fetchBackendAssets = async () => {
|
|
if (!focusGroupId) return;
|
|
|
|
try {
|
|
const response = await focusGroupsApi.getAssets(focusGroupId);
|
|
const assets = response.data.assets || [];
|
|
setBackendAssets(assets);
|
|
|
|
if (onAssetsChange) {
|
|
onAssetsChange(assets);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching backend assets:", error);
|
|
// Don't show error toast for initial fetch failures
|
|
}
|
|
};
|
|
|
|
// Validate file type and size
|
|
const validateFile = (file: File): string | null => {
|
|
// Check file type
|
|
const isValidType = allowedTypes.some(type => {
|
|
if (type.includes('*')) {
|
|
const baseType = type.split('/')[0];
|
|
return file.type.startsWith(baseType + '/');
|
|
}
|
|
return file.type === type;
|
|
});
|
|
|
|
if (!isValidType) {
|
|
return `File "${file.name}" is not a supported file type. Supported types: ${getFileTypeNames().join(', ')}.`;
|
|
}
|
|
|
|
// Check file size (convert MB to bytes)
|
|
const maxSizeInBytes = maxFileSize * 1024 * 1024;
|
|
if (file.size > maxSizeInBytes) {
|
|
return `File "${file.name}" is too large. Maximum file size: ${maxFileSize}MB.`;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Get friendly file type names
|
|
const getFileTypeNames = () => {
|
|
const typeMap: { [key: string]: string } = {
|
|
'image/*': 'Images',
|
|
'application/pdf': 'PDF',
|
|
'video/*': 'Videos',
|
|
'text/*': 'Text files',
|
|
'application/msword': 'Word docs',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word docs',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel files',
|
|
'application/vnd.ms-powerpoint': 'PowerPoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint'
|
|
};
|
|
|
|
return allowedTypes.map(type => typeMap[type] || type).filter((v, i, a) => a.indexOf(v) === i);
|
|
};
|
|
|
|
const handleFileUpload = async (files: FileList | null) => {
|
|
if (!files || files.length === 0) return;
|
|
|
|
// Check if adding these files would exceed the limit
|
|
const totalAssets = localAssets.length + backendAssets.length;
|
|
if (totalAssets + files.length > maxAssets) {
|
|
toast.error(`You can only upload up to ${maxAssets} assets`);
|
|
return;
|
|
}
|
|
|
|
// Validate each file
|
|
const validFiles: File[] = [];
|
|
const errors: string[] = [];
|
|
|
|
Array.from(files).forEach(file => {
|
|
const validationError = validateFile(file);
|
|
if (validationError) {
|
|
errors.push(validationError);
|
|
} else {
|
|
validFiles.push(file);
|
|
}
|
|
});
|
|
|
|
// Show validation errors
|
|
if (errors.length > 0) {
|
|
errors.forEach(error => toast.error(error));
|
|
if (validFiles.length === 0) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create local asset objects for immediate UI feedback (only for valid files)
|
|
const newLocalAssets: LocalAsset[] = validFiles.map(file => {
|
|
const previewUrl = file.type.startsWith('image/')
|
|
? URL.createObjectURL(file)
|
|
: undefined;
|
|
|
|
return {
|
|
id: `local-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
file,
|
|
previewUrl,
|
|
status: 'uploading',
|
|
progress: 0
|
|
};
|
|
});
|
|
|
|
// Add to local state for immediate UI feedback
|
|
setLocalAssets(prev => [...prev, ...newLocalAssets]);
|
|
|
|
// Upload each file to backend (only if focusGroupId exists for backend uploads)
|
|
for (const localAsset of newLocalAssets) {
|
|
if (!focusGroupId) {
|
|
// For generic file upload without backend (like persona creation), just mark as complete
|
|
setLocalAssets(prev => prev.map(asset =>
|
|
asset.id === localAsset.id
|
|
? { ...asset, status: 'uploaded', progress: 100 }
|
|
: asset
|
|
));
|
|
continue;
|
|
}
|
|
|
|
// Simulate progress for better UX
|
|
const updateProgress = (progress: number) => {
|
|
setLocalAssets(prev => prev.map(asset =>
|
|
asset.id === localAsset.id
|
|
? { ...asset, progress }
|
|
: asset
|
|
));
|
|
};
|
|
|
|
try {
|
|
updateProgress(10);
|
|
const formData = new FormData();
|
|
formData.append('assets', localAsset.file);
|
|
|
|
updateProgress(50);
|
|
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
|
|
const uploadResult = uploadResponse.data;
|
|
|
|
if (uploadResult.uploaded_assets > 0) {
|
|
updateProgress(100);
|
|
// Remove local asset from the list since it's now a backend asset
|
|
setTimeout(() => {
|
|
setLocalAssets(prev => prev.filter(asset => asset.id !== localAsset.id));
|
|
}, 500); // Small delay to show 100% progress
|
|
|
|
// Fetch updated backend assets
|
|
await fetchBackendAssets();
|
|
|
|
toast.success(`${localAsset.file.name} uploaded successfully`);
|
|
} else {
|
|
throw new Error('Upload failed');
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error(`Upload failed for ${localAsset.file.name}:`, error);
|
|
|
|
// Update local asset status to failed
|
|
setLocalAssets(prev => prev.map(asset =>
|
|
asset.id === localAsset.id
|
|
? {
|
|
...asset,
|
|
status: 'failed',
|
|
progress: 0,
|
|
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
|
|
}
|
|
: asset
|
|
));
|
|
|
|
if (onUploadError) {
|
|
onUploadError(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trigger upload complete callback
|
|
if (onUploadComplete) {
|
|
// Wait a bit for fetchBackendAssets to complete
|
|
setTimeout(() => {
|
|
onUploadComplete(backendAssets);
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
const handleRemoveAsset = async (filename: string) => {
|
|
if (!focusGroupId) return;
|
|
|
|
try {
|
|
await focusGroupsApi.deleteAsset(focusGroupId, filename);
|
|
await fetchBackendAssets();
|
|
toast.info('Asset removed');
|
|
} catch (error) {
|
|
console.error("Error removing asset:", error);
|
|
toast.error("Failed to remove asset");
|
|
}
|
|
};
|
|
|
|
const handleRemoveLocalAsset = (assetId: string) => {
|
|
const assetToRemove = localAssets.find(asset => asset.id === assetId);
|
|
if (assetToRemove?.previewUrl) {
|
|
URL.revokeObjectURL(assetToRemove.previewUrl);
|
|
}
|
|
|
|
setLocalAssets(prev => prev.filter(asset => asset.id !== assetId));
|
|
};
|
|
|
|
// Handle drag and drop events
|
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Only set drag over to false if we're leaving the dropzone entirely
|
|
if (!dropzoneRef.current?.contains(e.relatedTarget as Node)) {
|
|
setIsDragOver(false);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragOver(false);
|
|
|
|
if (disabled) return;
|
|
|
|
const files = e.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
handleFileUpload(files);
|
|
}
|
|
};
|
|
|
|
// Clear all files functionality
|
|
const handleClearAll = () => {
|
|
if (localAssets.length === 0 && backendAssets.length === 0) return;
|
|
|
|
// Clean up preview URLs
|
|
localAssets.forEach(asset => {
|
|
if (asset.previewUrl) {
|
|
URL.revokeObjectURL(asset.previewUrl);
|
|
}
|
|
});
|
|
|
|
setLocalAssets([]);
|
|
|
|
// Remove backend assets if focusGroupId exists
|
|
if (focusGroupId && backendAssets.length > 0) {
|
|
Promise.all(backendAssets.map(asset =>
|
|
focusGroupsApi.deleteAsset(focusGroupId, asset.filename).catch(console.error)
|
|
)).then(() => {
|
|
setBackendAssets([]);
|
|
if (onAssetsChange) {
|
|
onAssetsChange([]);
|
|
}
|
|
toast.info('All assets cleared');
|
|
});
|
|
} else {
|
|
setBackendAssets([]);
|
|
if (onAssetsChange) {
|
|
onAssetsChange([]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRetryUpload = async (localAsset: LocalAsset) => {
|
|
if (!focusGroupId) return;
|
|
|
|
// Update status to uploading
|
|
setLocalAssets(prev => prev.map(asset =>
|
|
asset.id === localAsset.id
|
|
? { ...asset, status: 'uploading', error: undefined, progress: 0 }
|
|
: asset
|
|
));
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('assets', localAsset.file);
|
|
|
|
// Update progress during retry
|
|
setLocalAssets(prev => prev.map(asset =>
|
|
asset.id === localAsset.id
|
|
? { ...asset, progress: 50 }
|
|
: asset
|
|
));
|
|
|
|
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
|
|
const uploadResult = uploadResponse.data;
|
|
|
|
if (uploadResult.uploaded_assets > 0) {
|
|
setLocalAssets(prev => prev.map(asset =>
|
|
asset.id === localAsset.id
|
|
? { ...asset, progress: 100 }
|
|
: asset
|
|
));
|
|
|
|
// Remove local asset from the list since it's now a backend asset
|
|
setTimeout(() => {
|
|
setLocalAssets(prev => prev.filter(asset => asset.id !== localAsset.id));
|
|
}, 500);
|
|
|
|
await fetchBackendAssets();
|
|
toast.success(`${localAsset.file.name} uploaded successfully`);
|
|
} else {
|
|
throw new Error('Upload failed');
|
|
}
|
|
} catch (error: unknown) {
|
|
setLocalAssets(prev => prev.map(asset =>
|
|
asset.id === localAsset.id
|
|
? {
|
|
...asset,
|
|
status: 'failed',
|
|
progress: 0,
|
|
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
|
|
}
|
|
: asset
|
|
));
|
|
toast.error(`Failed to upload ${localAsset.file.name}`);
|
|
}
|
|
};
|
|
|
|
const startEditingAssetName = (asset: BackendAsset) => {
|
|
setEditingAsset(asset.filename);
|
|
setEditingName(asset.user_assigned_name || '');
|
|
};
|
|
|
|
const saveAssetName = async (filename: string) => {
|
|
if (!focusGroupId || !editingName.trim()) {
|
|
cancelEditingAssetName();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await focusGroupsApi.updateAssetName(focusGroupId, filename, editingName.trim());
|
|
|
|
// Update local state
|
|
setBackendAssets(prev => prev.map(asset =>
|
|
asset.filename === filename
|
|
? { ...asset, user_assigned_name: editingName.trim() }
|
|
: asset
|
|
));
|
|
|
|
if (onAssetsChange) {
|
|
onAssetsChange(backendAssets);
|
|
}
|
|
|
|
setEditingAsset(null);
|
|
setEditingName('');
|
|
toast.success("Asset name updated");
|
|
} catch (error) {
|
|
console.error("Error updating asset name:", error);
|
|
toast.error("Failed to update asset name");
|
|
}
|
|
};
|
|
|
|
const cancelEditingAssetName = () => {
|
|
setEditingAsset(null);
|
|
setEditingName('');
|
|
};
|
|
|
|
// Determine the icon to use based on file type
|
|
const getAssetIcon = (mimeType: string) => {
|
|
if (mimeType.startsWith('image/')) {
|
|
return <ImageIcon className="h-8 w-8 text-slate-400" />;
|
|
} else if (mimeType.startsWith('video/')) {
|
|
return <FileVideo className="h-8 w-8 text-slate-400" />;
|
|
} else if (mimeType === 'application/pdf') {
|
|
return <FileText className="h-8 w-8 text-slate-400" />;
|
|
} else {
|
|
return <FileText className="h-8 w-8 text-slate-400" />;
|
|
}
|
|
};
|
|
|
|
const getDisplayName = (asset: BackendAsset, index: number) => {
|
|
return asset.user_assigned_name || `Asset ${index + 1}`;
|
|
};
|
|
|
|
const totalAssets = localAssets.length + backendAssets.length;
|
|
const remainingSlots = maxAssets - totalAssets;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Enhanced Upload Dropzone */}
|
|
<div className="space-y-4">
|
|
<div className="text-center">
|
|
<h3 className="text-lg font-semibold text-foreground">{label}</h3>
|
|
<p className="text-muted-foreground mt-1">{description}</p>
|
|
</div>
|
|
|
|
<div
|
|
ref={dropzoneRef}
|
|
className={`relative border-2 border-dashed rounded-xl p-8 transition-all duration-200 ${
|
|
disabled
|
|
? 'border-muted-foreground/20 bg-muted/30 cursor-not-allowed'
|
|
: isDragOver
|
|
? 'border-primary bg-primary/5 scale-[1.02]'
|
|
: 'border-muted-foreground/30 bg-background hover:border-primary/50 hover:bg-muted/50 cursor-pointer'
|
|
}`}
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
onClick={() => !disabled && fileInputRef.current?.click()}
|
|
>
|
|
<div className="flex flex-col items-center justify-center text-center">
|
|
<UploadCloud className={`h-12 w-12 mb-4 ${
|
|
disabled ? 'text-muted-foreground/40' :
|
|
isDragOver ? 'text-primary' : 'text-muted-foreground'
|
|
}`} />
|
|
|
|
{disabled ? (
|
|
<div>
|
|
<p className="text-lg font-medium text-muted-foreground mb-2">File Upload Disabled</p>
|
|
<p className="text-sm text-muted-foreground">Complete the required details above to enable file uploads</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="text-lg font-medium text-foreground mb-2">
|
|
{isDragOver ? 'Drop files here' : 'Drag and drop files here, or click to browse'}
|
|
</p>
|
|
|
|
{/* File Type Pills */}
|
|
<div className="flex flex-wrap justify-center gap-2 mb-4">
|
|
{getFileTypeNames().map((fileType, index) => (
|
|
<Badge key={index} variant="secondary" className="text-xs">
|
|
{fileType}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Maximum file size: {maxFileSize}MB
|
|
</p>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={allowedTypes.join(',')}
|
|
multiple
|
|
onChange={(e) => handleFileUpload(e.target.files)}
|
|
className="hidden"
|
|
disabled={disabled}
|
|
/>
|
|
|
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
size="sm"
|
|
disabled={disabled || remainingSlots <= 0}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
fileInputRef.current?.click();
|
|
}}
|
|
>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Select Files
|
|
</Button>
|
|
|
|
{(localAssets.length > 0 || backendAssets.length > 0) && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleClearAll();
|
|
}}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Clear All
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground mt-3">
|
|
{remainingSlots} of {maxAssets} uploads remaining
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enhanced Assets Preview */}
|
|
{(backendAssets.length > 0 || localAssets.length > 0) && (
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h4 className="text-lg font-semibold text-foreground">
|
|
Uploaded Files ({localAssets.length + backendAssets.length})
|
|
</h4>
|
|
{(localAssets.length > 0 || backendAssets.length > 0) && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClearAll}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Clear All
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{/* Backend assets (successfully uploaded) */}
|
|
{backendAssets.map((asset, index) => (
|
|
<div key={asset.filename} className="flex items-center gap-4 p-4 border rounded-lg bg-card shadow-sm">
|
|
{/* File preview/icon */}
|
|
<div className="w-12 h-12 bg-muted/50 rounded-md flex items-center justify-center flex-shrink-0">
|
|
{asset.mime_type?.startsWith('image/') ? (
|
|
<img
|
|
src={focusGroupsApi.getAssetUrl(focusGroupId!, asset.filename)}
|
|
alt={getDisplayName(asset, index)}
|
|
className="max-h-full max-w-full object-contain rounded-md"
|
|
/>
|
|
) : (
|
|
getAssetIcon(asset.mime_type)
|
|
)}
|
|
</div>
|
|
|
|
{/* File info */}
|
|
<div className="flex-grow min-w-0">
|
|
{enableRenaming && editingAsset === asset.filename ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={editingName}
|
|
onChange={(e) => setEditingName(e.target.value)}
|
|
placeholder={`Asset ${index + 1}`}
|
|
className="flex-1"
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
saveAssetName(asset.filename);
|
|
} else if (e.key === 'Escape') {
|
|
cancelEditingAssetName();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
type="button"
|
|
onClick={() => saveAssetName(asset.filename)}
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
type="button"
|
|
onClick={cancelEditingAssetName}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-medium text-sm truncate">
|
|
{getDisplayName(asset, index)}
|
|
</p>
|
|
{enableRenaming && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
type="button"
|
|
onClick={() => startEditingAssetName(asset)}
|
|
className="h-6 w-6 p-0"
|
|
>
|
|
<Edit3 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
Original: {asset.original_name}
|
|
</p>
|
|
<Badge variant="outline" className="text-xs text-green-600 bg-green-50 border-green-200">
|
|
Complete
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{enableRenaming && (
|
|
<div className="text-right mr-2">
|
|
<div className="text-xs text-muted-foreground mb-1">Will appear as:</div>
|
|
<div className="text-sm font-medium text-primary">
|
|
"{getDisplayName(asset, index)}"
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
type="button"
|
|
onClick={() => handleRemoveAsset(asset.filename)}
|
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Local assets (uploading/failed/completed) */}
|
|
{localAssets.map((asset) => (
|
|
<div key={asset.id} className="flex items-center gap-4 p-4 border rounded-lg bg-muted/30">
|
|
{/* File preview/icon */}
|
|
<div className="w-12 h-12 bg-muted/50 rounded-md flex items-center justify-center flex-shrink-0">
|
|
{asset.previewUrl ? (
|
|
<img
|
|
src={asset.previewUrl}
|
|
alt={asset.file.name}
|
|
className="max-h-full max-w-full object-contain rounded-md"
|
|
/>
|
|
) : (
|
|
getAssetIcon(asset.file.type)
|
|
)}
|
|
</div>
|
|
|
|
{/* File info */}
|
|
<div className="flex-grow min-w-0">
|
|
<p className="font-medium text-sm truncate">{asset.file.name}</p>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{(asset.file.size / 1024 / 1024).toFixed(2)} MB
|
|
</p>
|
|
|
|
{/* Progress bar for uploading files */}
|
|
{asset.status === 'uploading' && (
|
|
<div className="space-y-1">
|
|
<Progress value={asset.progress || 0} className="h-2" />
|
|
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
|
<span>Uploading...</span>
|
|
<span>{asset.progress || 0}%</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error message for failed files */}
|
|
{asset.error && (
|
|
<p className="text-xs text-destructive truncate mt-1">{asset.error}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status badge and actions */}
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{/* Status badge */}
|
|
{asset.status === 'uploading' && (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-xs text-blue-600 bg-blue-50 border-blue-200">
|
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
Uploading
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{asset.status === 'uploaded' && (
|
|
<Badge variant="outline" className="text-xs text-green-600 bg-green-50 border-green-200">
|
|
<Check className="h-3 w-3 mr-1" />
|
|
Complete
|
|
</Badge>
|
|
)}
|
|
{asset.status === 'failed' && (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-xs text-destructive bg-destructive/10 border-destructive/20">
|
|
Failed
|
|
</Badge>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
type="button"
|
|
onClick={() => handleRetryUpload(asset)}
|
|
className="h-7 text-xs"
|
|
>
|
|
<RefreshCw className="h-3 w-3 mr-1" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Remove button */}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
type="button"
|
|
onClick={() => handleRemoveLocalAsset(asset.id)}
|
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Help text */}
|
|
{enableRenaming && backendAssets.length > 0 && (
|
|
<div className="mt-6 p-4 bg-primary/5 border border-primary/20 rounded-lg">
|
|
<p className="text-sm text-foreground">
|
|
<strong>Asset Names:</strong> Click the edit icon to customize how assets will be referenced
|
|
in the discussion guide. Leave blank to use default numbering.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
} |