semblance/src/components/AssetUploader.tsx
michael e10a569bc2 Improve Customer Data Upload guidance with clearer instructions
- 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>
2025-12-02 08:26:45 -06:00

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>
);
}