feat(T05): Dateiablage pro Fahrzeug + File UI with thumbnails and gallery
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listFiles,
|
||||
isImageMime,
|
||||
getThumbnailUrl,
|
||||
type FileResponse,
|
||||
} from '@/lib/files';
|
||||
import { FilePreview } from './FilePreview';
|
||||
|
||||
interface FileGalleryProps {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
export function FileGallery({ vehicleId }: FileGalleryProps) {
|
||||
const [images, setImages] = useState<FileResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState<FileResponse | null>(null);
|
||||
|
||||
const fetchImages = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Fetch all files and filter for images
|
||||
const data = await listFiles(vehicleId, 1, 100);
|
||||
const imageFiles = data.items.filter((f) => isImageMime(f.mime_type));
|
||||
setImages(imageFiles);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load images');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [vehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages();
|
||||
}, [fetchImages]);
|
||||
|
||||
if (loading && images.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-text-muted" data-testid="gallery-loading">
|
||||
Loading gallery...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8 text-error" data-testid="gallery-error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-text-muted" data-testid="gallery-empty">
|
||||
No images available for this vehicle.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="file-gallery">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{images.map((image) => {
|
||||
const thumbUrl = getThumbnailUrl(image);
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => setSelectedImage(image)}
|
||||
data-testid={`gallery-item-${image.id}`}
|
||||
>
|
||||
{thumbUrl && (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={image.original_filename}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-end p-2">
|
||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity truncate">
|
||||
{image.original_filename}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Full preview modal */}
|
||||
<FilePreview
|
||||
file={selectedImage}
|
||||
open={!!selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import {
|
||||
listFiles,
|
||||
deleteFile,
|
||||
isImageMime,
|
||||
formatFileSize,
|
||||
getThumbnailUrl,
|
||||
type FileResponse,
|
||||
} from '@/lib/files';
|
||||
|
||||
interface FileListProps {
|
||||
vehicleId: string;
|
||||
onFileDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function FileList({ vehicleId, onFileDeleted }: FileListProps) {
|
||||
const [files, setFiles] = useState<FileResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<FileResponse | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listFiles(vehicleId, page, pageSize);
|
||||
setFiles(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load files');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [vehicleId, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, [fetchFiles]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteFile(vehicleId, deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
await fetchFiles();
|
||||
onFileDeleted?.();
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to delete file');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (file: FileResponse) => {
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('access_token')
|
||||
: null;
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
||||
const url = `${API_BASE_URL}/vehicles/${vehicleId}/files/${file.id}`;
|
||||
|
||||
// Use fetch with auth header to download as blob
|
||||
fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = file.original_filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
})
|
||||
.catch(() => setError('Failed to download file'));
|
||||
};
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-text-muted" data-testid="file-list-loading">
|
||||
Loading files...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8 text-error" data-testid="file-list-error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-text-muted" data-testid="file-list-empty">
|
||||
No files uploaded yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="file-list">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{files.map((file) => {
|
||||
const thumbUrl = getThumbnailUrl(file);
|
||||
const isImg = isImageMime(file.mime_type);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="border border-border rounded-lg overflow-hidden bg-surface"
|
||||
data-testid={`file-item-${file.id}`}
|
||||
>
|
||||
{/* Thumbnail or file icon */}
|
||||
<div className="aspect-square bg-background flex items-center justify-center">
|
||||
{isImg && thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={file.original_filename}
|
||||
className="w-full h-full object-cover"
|
||||
data-testid={`file-thumb-${file.id}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-text-muted">
|
||||
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-xs uppercase">{file.mime_type.split('/')[1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="p-3">
|
||||
<p className="text-sm font-medium text-text truncate" title={file.original_filename}>
|
||||
{file.original_filename}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{formatFileSize(file.file_size)}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xs px-2 py-1"
|
||||
onClick={() => handleDownload(file)}
|
||||
data-testid={`download-btn-${file.id}`}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="text-xs px-2 py-1"
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
data-testid={`delete-btn-${file.id}`}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > pageSize && (
|
||||
<div className="flex items-center justify-center gap-4 mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-text-muted">
|
||||
Page {page} of {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page * pageSize >= total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<Modal
|
||||
open={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete File"
|
||||
>
|
||||
<div data-testid="delete-confirm">
|
||||
<p className="text-text mb-4">
|
||||
Are you sure you want to delete <strong>{deleteTarget?.original_filename}</strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={deleting}
|
||||
onClick={handleDelete}
|
||||
data-testid="confirm-delete-btn"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { isImageMime, formatFileSize, type FileResponse } from '@/lib/files';
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: FileResponse | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilePreview({ file, open, onClose }: FilePreviewProps) {
|
||||
if (!file) return null;
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
||||
const downloadUrl = `${API_BASE_URL}/vehicles/${file.vehicle_id}/files/${file.id}`;
|
||||
const isImg = isImageMime(file.mime_type);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={file.original_filename}>
|
||||
<div data-testid="file-preview" className="space-y-4">
|
||||
{/* Preview content */}
|
||||
{isImg ? (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={file.original_filename}
|
||||
className="max-w-full max-h-[60vh] rounded-lg"
|
||||
data-testid="preview-image"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<svg className="h-16 w-16 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-text-muted">Preview not available for this file type</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File metadata */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">File type:</span>
|
||||
<span className="text-text">{file.mime_type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Size:</span>
|
||||
<span className="text-text">{formatFileSize(file.file_size)}</span>
|
||||
</div>
|
||||
{file.created_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Uploaded:</span>
|
||||
<span className="text-text">
|
||||
{new Date(file.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download button */}
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={file.original_filename}
|
||||
className="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors"
|
||||
data-testid="preview-download-btn"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, type DragEvent } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
uploadFile,
|
||||
ALLOWED_MIME_TYPES,
|
||||
MAX_FILE_SIZE_MB,
|
||||
MAX_FILE_SIZE_BYTES,
|
||||
type FileResponse,
|
||||
} from '@/lib/files';
|
||||
|
||||
interface FileUploadProps {
|
||||
vehicleId: string;
|
||||
onUploadComplete?: (file: FileResponse) => void;
|
||||
onUploadError?: (error: string) => void;
|
||||
}
|
||||
|
||||
interface UploadStatus {
|
||||
filename: string;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FileUpload({ vehicleId, onUploadComplete, onUploadError }: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadStatuses, setUploadStatuses] = useState<UploadStatus[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
return `Unsupported file type: ${file.type || 'unknown'}`;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
return `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFiles = useCallback(async (files: FileList) => {
|
||||
setIsUploading(true);
|
||||
const fileArray = Array.from(files);
|
||||
const statuses: UploadStatus[] = fileArray.map((f) => ({
|
||||
filename: f.name,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
setUploadStatuses(statuses);
|
||||
|
||||
for (let i = 0; i < fileArray.length; i++) {
|
||||
const file = fileArray[i];
|
||||
const validationError = validateFile(file);
|
||||
|
||||
if (validationError) {
|
||||
setUploadStatuses((prev) =>
|
||||
prev.map((s, idx) =>
|
||||
idx === i ? { ...s, status: 'error', error: validationError } : s
|
||||
)
|
||||
);
|
||||
onUploadError?.(validationError);
|
||||
continue;
|
||||
}
|
||||
|
||||
setUploadStatuses((prev) =>
|
||||
prev.map((s, idx) =>
|
||||
idx === i ? { ...s, status: 'uploading' } : s
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await uploadFile(vehicleId, file);
|
||||
setUploadStatuses((prev) =>
|
||||
prev.map((s, idx) =>
|
||||
idx === i ? { ...s, status: 'success' } : s
|
||||
)
|
||||
);
|
||||
onUploadComplete?.(result);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
const errorMsg = apiErr?.error?.message || 'Upload failed';
|
||||
setUploadStatuses((prev) =>
|
||||
prev.map((s, idx) =>
|
||||
idx === i ? { ...s, status: 'error', error: errorMsg } : s
|
||||
)
|
||||
);
|
||||
onUploadError?.(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
// Clear statuses after 3 seconds
|
||||
setTimeout(() => setUploadStatuses([]), 3000);
|
||||
}, [vehicleId, onUploadComplete, onUploadError]);
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
}, [handleFiles]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFiles(e.target.files);
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full" data-testid="file-upload">
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
|
||||
${isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}
|
||||
`}
|
||||
data-testid="dropzone"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.webp,.pdf,.doc,.docx"
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
data-testid="file-input"
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
className="h-12 w-12 text-text-muted"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-text font-medium">
|
||||
{isDragging ? 'Drop files here' : 'Drag & drop files or click to browse'}
|
||||
</p>
|
||||
<p className="text-text-muted text-sm">
|
||||
Supports: JPG, PNG, WebP, PDF, DOC, DOCX (max {MAX_FILE_SIZE_MB}MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadStatuses.length > 0 && (
|
||||
<div className="mt-4 space-y-2" data-testid="upload-statuses">
|
||||
{uploadStatuses.map((status, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between p-2 rounded ${
|
||||
status.status === 'success'
|
||||
? 'bg-success/10 text-success'
|
||||
: status.status === 'error'
|
||||
? 'bg-error/10 text-error'
|
||||
: 'bg-surface text-text'
|
||||
}`}
|
||||
data-testid={`upload-status-${idx}`}
|
||||
>
|
||||
<span className="text-sm">{status.filename}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{status.status === 'pending' && 'Pending...'}
|
||||
{status.status === 'uploading' && 'Uploading...'}
|
||||
{status.status === 'success' && '✓ Uploaded'}
|
||||
{status.status === 'error' && `✗ ${status.error}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="mt-2">
|
||||
<Button variant="secondary" disabled loading>
|
||||
Uploading...
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user