import type { FileMetadata } from "@nhost/nhost-js/storage";
import { type JSX, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../lib/nhost/AuthProvider";
interface DeleteStatus {
message: string;
isError: boolean;
}
interface GraphqlGetFilesResponse {
files: FileMetadata[];
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
}
export default function Files(): JSX.Element {
const { isAuthenticated, nhost } = useAuth();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState<boolean>(false);
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
const [isFetching, setIsFetching] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileMetadata[]>([]);
const [viewingFile, setViewingFile] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
const fetchFiles = useCallback(async (): Promise<void> => {
setIsFetching(true);
setError(null);
try {
// Use GraphQL to fetch files from the storage system
// Files are automatically filtered by user permissions
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
query: `query GetFiles {
files {
id
name
size
mimeType
bucketId
uploadedByUserId
}
}`,
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch files",
);
}
setFiles(response.body.data?.files || []);
} catch (err) {
console.error("Error fetching files:", err);
setError("Failed to load files. Please try refreshing the page.");
} finally {
setIsFetching(false);
}
}, [nhost.graphql]);
useEffect(() => {
if (isAuthenticated) {
fetchFiles();
}
}, [isAuthenticated, fetchFiles]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
if (file) {
setSelectedFile(file);
setError(null);
setUploadResult(null);
}
}
};
const handleUpload = async (): Promise<void> => {
if (!selectedFile) {
setError("Please select a file to upload");
return;
}
setUploading(true);
setError(null);
try {
// Upload file to the personal bucket
// The uploadedByUserId is automatically set by the storage permissions
const response = await nhost.storage.uploadFiles({
"bucket-id": "personal",
"file[]": [selectedFile],
});
const uploadedFile = response.body.processedFiles?.[0];
if (uploadedFile === undefined) {
throw new Error("Failed to upload file");
}
setUploadResult(uploadedFile);
// Clear the form
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
// Update the files list
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
setUploadResult(null);
}, 3000);
} catch (err: unknown) {
const message = (err as Error).message || "An unknown error occurred";
setError(`Failed to upload file: ${message}`);
} finally {
setUploading(false);
}
};
const handleViewFile = async (
fileId: string,
fileName: string,
mimeType: string,
): Promise<void> => {
setViewingFile(fileId);
try {
// Get the file from storage
const response = await nhost.storage.getFile(fileId);
const url = URL.createObjectURL(response.body);
// Handle different file types appropriately
if (
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType.startsWith("text/") ||
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/")
) {
// Open viewable files in new tab
window.open(url, "_blank");
} else {
// Download other file types
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Show download confirmation
const newWindow = window.open("", "_blank", "width=400,height=200");
if (newWindow) {
newWindow.document.documentElement.innerHTML = `
<head>
<title>File Download</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
</style>
</head>
<body>
<h3>Downloading: ${fileName}</h3>
<p>Your download has started. You can close this window.</p>
</body>
`;
}
}
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
setError(`Failed to view file: ${message}`);
console.error("Error viewing file:", err);
} finally {
setViewingFile(null);
}
};
const handleDeleteFile = async (fileId: string): Promise<void> => {
if (!fileId || deleting) return;
setDeleting(fileId);
setError(null);
setDeleteStatus(null);
const fileToDelete = files.find((file) => file.id === fileId);
const fileName = fileToDelete?.name || "File";
try {
// Delete file from storage
// Permissions ensure users can only delete their own files
await nhost.storage.deleteFile(fileId);
setDeleteStatus({
message: `${fileName} deleted successfully`,
isError: false,
});
// Remove from local state
setFiles(files.filter((file) => file.id !== fileId));
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
setDeleteStatus(null);
}, 3000);
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
setDeleteStatus({
message: `Failed to delete ${fileName}: ${message}`,
isError: true,
});
console.error("Error deleting file:", err);
} finally {
setDeleting(null);
}
};
return (
<div className="container">
<header className="page-header">
<h1 className="page-title">File Upload</h1>
</header>
<div className="form-card">
<h2 className="form-title">Upload a File</h2>
<div className="field-group">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
border: 0,
}}
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
className="btn btn-secondary file-upload-btn"
onClick={() => fileInputRef.current?.click()}
>
<svg
width="40"
height="40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="Upload file"
>
<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>Click to select a file</p>
{selectedFile && (
<p className="file-upload-info">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</button>
</div>
{error && <div className="error-message">{error}</div>}
{uploadResult && (
<div className="success-message">File uploaded successfully!</div>
)}
<button
type="button"
onClick={handleUpload}
disabled={!selectedFile || uploading}
className="btn btn-primary"
style={{ width: "100%" }}
>
{uploading ? "Uploading..." : "Upload File"}
</button>
</div>
<div className="form-card">
<h2 className="form-title">Your Files</h2>
{deleteStatus && (
<div
className={
deleteStatus.isError ? "error-message" : "success-message"
}
>
{deleteStatus.message}
</div>
)}
{isFetching ? (
<div className="loading-container">
<div className="loading-content">
<div className="spinner"></div>
<span className="loading-text">Loading files...</span>
</div>
</div>
) : files.length === 0 ? (
<div className="empty-state">
<svg
className="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<h3 className="empty-title">No files yet</h3>
<p className="empty-description">
Upload your first file to get started!
</p>
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table className="file-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id}>
<td className="file-name">{file.name}</td>
<td className="file-meta">{file.mimeType}</td>
<td className="file-meta">
{formatFileSize(file.size || 0)}
</td>
<td>
<div className="file-actions">
<button
type="button"
onClick={() =>
handleViewFile(
file.id || "unknown",
file.name || "unknown",
file.mimeType || "unknown",
)
}
disabled={viewingFile === file.id}
className="action-btn action-btn-edit"
title="View File"
>
{viewingFile === file.id ? "⏳" : "👁️"}
</button>
<button
type="button"
onClick={() => handleDeleteFile(file.id || "unknown")}
disabled={deleting === file.id}
className="action-btn action-btn-delete"
title="Delete File"
>
{deleting === file.id ? "⏳" : "🗑️"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}