232 lines
7.3 KiB
TypeScript
232 lines
7.3 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|