feat: add description field to File model and update related components for file details and actions

This commit is contained in:
ZareMate 2025-04-16 18:50:31 +02:00
parent 3048c00648
commit e1097ba1be
Signed by: zaremate
GPG Key ID: 369A0E45E03A81C3
7 changed files with 373 additions and 167 deletions

View File

@ -82,7 +82,7 @@ model File {
size Int // Size in bytes
extension String
uploadDate DateTime @default(now())
description String @default("")
uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
uploadedById String?
}

View File

@ -0,0 +1,109 @@
import toast from "react-hot-toast";
import { env } from "~/env.js";
export const useFileActions = (
setFiles: (callback: (prevFiles: any[]) => any[]) => void,
setDescription?: (description: string) => void,
fileId?: string
) => {
const pageUrl = `${env.NEXT_PUBLIC_PAGE_URL}/share?id=`;
// Handle file download
const handleDownload = async (fileId: string, fileName: string) => {
try {
const response = await fetch(
`/api/files/download?fileId=${encodeURIComponent(fileId)}&fileName=${encodeURIComponent(fileName)}`
);
if (!response.ok) throw new Error("Failed to download file");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
toast.success(`File "${fileName}" downloaded successfully!`);
} catch (err) {
console.error(err);
toast.error("Failed to download file.");
}
};
// Copy file URL to clipboard
const handleCopyUrl = (url: string) => {
navigator.clipboard
.writeText(pageUrl + url)
.then(() => toast.success("File URL copied to clipboard!"))
.catch(() => toast.error("Failed to copy URL."));
};
// Remove a file
const handleRemove = async (fileId: string) => {
try {
const response = await fetch(`/api/remove`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: fileId }),
});
if (!response.ok) throw new Error("Failed to delete file");
setFiles((prevFiles) => prevFiles.filter((file) => file.id !== fileId));
toast.success("File removed successfully!");
} catch (err) {
console.error(err);
toast.error("Failed to remove file.");
}
};
// Handle description change
const handleDescriptionChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
debounceTimer: React.RefObject<NodeJS.Timeout | null>
) => {
if (!setDescription) return;
const newDescription = e.target.value;
setDescription(newDescription);
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
handleDescriptionSave(newDescription);
}, 1000);
};
// Save updated description
const handleDescriptionSave = async (description: string) => {
if (!fileId) {
toast.error("File ID is required.");
return;
}
try {
const response = await fetch(`/api/share?id=${encodeURIComponent(fileId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }),
});
if (!response.ok) throw new Error("Failed to update description");
toast.success("Description updated successfully!");
} catch (err) {
console.error(err);
toast.error("Failed to update description.");
}
};
return {
handleDownload,
handleCopyUrl,
handleRemove,
handleDescriptionChange,
handleDescriptionSave,
};
};

View File

@ -1,16 +1,18 @@
//!eslint-disable @typescript-eslint/no-unsafe-assignment
//!eslint-disable @typescript-eslint/no-unsafe-argument
"use client";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { env } from "~/env.js";
interface File {
import { FilePreview } from "~/app/_components/FilePreview";
import { useFileActions } from "~/app/_components/FileActions";
interface FileDetails {
id: string;
name: string;
url: string;
description: string;
extension: string;
}
interface FileGridProps {
@ -18,18 +20,20 @@ interface FileGridProps {
}
export default function FileGrid({ session }: FileGridProps) {
const [files, setFiles] = useState<File[]>([]);
const [files, setFiles] = useState<FileDetails[]>([]);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const pageUrl = env.NEXT_PUBLIC_PAGE_URL; // Assuming PAGE_URL is defined in your environment variables
const pageUrl = env.NEXT_PUBLIC_PAGE_URL;
const { handleDownload, handleCopyUrl, handleRemove } = useFileActions(setFiles);
// Fetch files from the server
const fetchFiles = async () => {
try {
const response = await fetch("/api/files");
if (!response.ok) {
throw new Error("Failed to fetch files");
}
const data = await response.json() as { files: File[] }; // Explicitly type the response
if (!response.ok) throw new Error("Failed to fetch files");
const data = (await response.json()) as { files: FileDetails[] };
setFiles(data.files);
} catch (err) {
console.error(err);
@ -37,49 +41,38 @@ export default function FileGrid({ session }: FileGridProps) {
}
};
const handleDownload = async (fileId: string, fileName: string) => {
try {
const response = await fetch(`/api/files/download?fileId=${encodeURIComponent(fileId)}&fileName=${encodeURIComponent(fileName)}`);
if (!response.ok) {
throw new Error("Failed to download file");
}
// Download the file with the correct filename
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
toast.success(`File "${fileName}" downloaded successfully!`);
} catch (err) {
console.error(err);
toast.error("Failed to download file.");
}
// Determine file type based on extension
const getFileType = (extension: string): string => {
const fileTypes: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".ogg": "video/ogg",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".wav": "audio/wav",
};
return fileTypes[extension] || "unknown";
};
// Handle real-time updates via SSE
useEffect(() => {
if (!session?.user) {
setError("You must be logged in to view files.");
return;
}
// Fetch files initially
void fetchFiles();
// Listen for real-time updates via SSE
const eventSource = new EventSource("/api/files/stream");
eventSource.onmessage = (event) => {
const data: { type: string; file?: File; fileId?: string } = JSON.parse(event.data); // Explicitly type the parsed data
const data: { type: string; file?: FileDetails; fileId?: string } = JSON.parse(event.data);
if (data.type === "file-added" && data.file) {
if (data.file) {
setFiles((prevFiles) => [...prevFiles, data.file as File]);
}
setFiles((prevFiles) => (data.file ? [...prevFiles, data.file] : prevFiles));
toast.success(`File "${data.file.name}" added!`);
} else if (data.type === "file-removed" && data.fileId) {
setFiles((prevFiles) => prevFiles.filter((file) => file.id !== data.fileId));
@ -91,59 +84,35 @@ export default function FileGrid({ session }: FileGridProps) {
eventSource.close();
};
return () => {
eventSource.close(); // Cleanup on unmount
};
return () => eventSource.close();
}, [session]);
const handleCopyUrl = (url: string) => {
navigator.clipboard
.writeText(pageUrl + url)
.then(() => toast.success("File URL copied to clipboard!"))
.catch(() => toast.error("Failed to copy URL."));
};
const handleRemove = async (fileId: string) => {
try {
const response = await fetch(`/api/remove`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: fileId }),
});
if (!response.ok) {
throw new Error("Failed to delete file");
}
setFiles((prevFiles) => prevFiles.filter((file) => file.id !== fileId));
toast.success("File removed successfully!");
} catch (err) {
console.error(err);
toast.error("Failed to remove file.");
}
};
if (error) {
return <div className="text-red-500">{error}</div>;
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-8">
{files.map((file) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-8">
{files.map((file) => {
const fileType = getFileType(file.extension);
return (
<div
key={file.id}
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
className="flex place-content-end max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
>
{fileType !== "unknown" && <div className=" self-center max-w-50"><FilePreview fileId={file.id} fileType={fileType} /></div>}
<button onClick={() => router.push(env.NEXT_PUBLIC_PAGE_URL + file.url)}>
<button onClick={() => router.push(pageUrl + file.url)}>
<h3 className="text-2xl font-bold">{file.name}</h3>
</button>
<div className="flex gap-2">
{file.description && (<p className="text-sm text-gray-400">Description: {file.description}</p>)}
<div className="flex self-center gap-2">
{/* Download Button */}
<button
onClick={() => handleDownload(file.id,file.name)}
onClick={() => handleDownload(file.id, file.name)}
className="flex items-center justify-center rounded-full bg-blue-500 p-2 hover:bg-blue-600"
>
<svg
@ -191,7 +160,7 @@ export default function FileGrid({ session }: FileGridProps) {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 24 24"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="h-5 w-5 text-white"
@ -205,7 +174,8 @@ export default function FileGrid({ session }: FileGridProps) {
</button>
</div>
</div>
))}
</div>
);
})}
</div>
);
}

View File

@ -2,12 +2,12 @@
import { useEffect, useState } from "react";
interface SharePageProps {
interface FilePreviewProps {
fileId: string;
fileType: string; // Pass the file type as a prop
}
export function SharePage({ fileId, fileType }: SharePageProps) {
export function FilePreview({ fileId, fileType }: FilePreviewProps) {
const [mediaSrc, setMediaSrc] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

View File

@ -24,16 +24,40 @@ export async function GET(req: Request) {
return NextResponse.json({
name: file.name,
size: file.size,
owner: file.uploadedBy?.name ?? null, // Use nullish coalescing
owneravatar: file.uploadedBy?.image ?? null,
owner: file.uploadedBy?.name ?? null,
ownerAvatar: file.uploadedBy?.image ?? null,
uploadDate: file.uploadDate,
id: file.id,
isOwner: session?.user?.id === file.uploadedById,
type: file.extension,
url: file.url,
description: file.description,
});
} catch (error) {
console.error("Error fetching file details:", error);
return NextResponse.json({ error: "Failed to fetch file details" }, { status: 500 });
}
}
export async function PUT(req: Request) {
const session = await auth();
const url = new URL(req.url);
const fileId = url.searchParams.get("id");
const { description = "" } = await req.json();
if (!fileId) {
return NextResponse.json({ error: "File name is required" }, { status: 400 });
}
try {
const file = await db.file.update({
where: { id: fileId },
data: { description },
});
return NextResponse.json(file);
} catch (error) {
console.error("Error updating file description:", error);
return NextResponse.json({ error: "Failed to update file description" }, { status: 500 });
}
}

View File

@ -1,52 +1,55 @@
"use client";
import { Suspense } from "react";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import toast, { Toaster } from "react-hot-toast";
import { SharePage } from "~/app/_components/SharePage";
import { FilePreview } from "~/app/_components/FilePreview";
import { useFileActions } from "~/app/_components/FileActions";
import Head from "next/head";
interface FileDetails {
name: string;
size: number;
owner: string;
owneravatar: string | null;
ownerAvatar: string | null;
uploadDate: string;
id: string;
isOwner: boolean;
type: string;
url: string;
description: string;
}
function Details() {
export default function FilePreviewContainer() {
const searchParams = useSearchParams();
const router = useRouter();
const fileId = searchParams.get("id");
const [fileDetails, setFileDetails] = useState<FileDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const [description, setDescription] = useState<string>("");
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
// Determine the file type based on the file extension
const fileType = fileDetails?.type === ".mp4"
? "video/mp4"
: fileDetails?.type === ".webm"
? "video/webm"
: fileDetails?.type === ".ogg"
? "video/ogg"
: fileDetails?.type === ".jpg" || fileDetails?.type === ".jpeg"
? "image/jpeg"
: fileDetails?.type === ".png"
? "image/png"
: fileDetails?.type === ".gif"
? "image/gif"
: fileDetails?.type === ".svg"
? "image/svg+xml"
: fileDetails?.type === ".mp3"
? "audio/mpeg"
: fileDetails?.type === ".wav"
? "audio/wav"
// if fileType is not one of the above, set it to unknown
: "unknown";
const { handleDescriptionChange, handleCopyUrl, handleDownload, handleRemove } = useFileActions(
() => {},
setDescription,
fileId || undefined
);
const getFileType = (extension: string): string => {
const fileTypes: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".ogg": "video/ogg",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".wav": "audio/wav",
};
return fileTypes[extension] || "unknown";
}
useEffect(() => {
if (!fileId) {
@ -57,12 +60,11 @@ function Details() {
const fetchFileDetails = async () => {
try {
const response = await fetch(`/api/share?id=${encodeURIComponent(fileId)}`);
if (!response.ok) {
throw new Error("Failed to fetch file details");
}
if (!response.ok) throw new Error("Failed to fetch file details");
const data: FileDetails = await response.json();
setFileDetails(data);
setDescription(data.description);
} catch (err) {
console.error(err);
setError("Failed to load file details.");
@ -78,19 +80,19 @@ function Details() {
if (!fileDetails) {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<Toaster position="top-right" reverseOrder={false} />
<div className="absolute top-4 left-4">
<button
onClick={() => router.push("/")}
className="rounded-full bg-white/10 px-4 py-2 font-semibold no-underline transition hover:bg-white/20"
className="rounded-full bg-white/10 px-4 py-2 font-semibold hover:bg-white/20"
>
Home
</button>
</div>
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
<div className="container flex flex-col items-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
<span className="text-[hsl(280,100%,70%)]">File</span> Details
<span className="text-[hsl(280,100%,70%)]">No</span> File Found
</h1>
</div>
</main>
@ -98,60 +100,155 @@ function Details() {
}
return (
<main className="relative flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<Toaster position="top-right" reverseOrder={false} />
<div className="absolute top-4 left-4">
<button
onClick={() => router.push("/")}
className="rounded-full bg-white/10 px-4 py-2 font-semibold no-underline transition hover:bg-white/20"
>
Home
</button>
</div>
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
<span className="text-[hsl(280,100%,70%)]">File</span> Details
</h1>
<div className="mt-6">
{// if fileType is not ubknown, show the media player
fileType && !fileType.startsWith("unknown") ? (
<SharePage fileId={fileDetails.id} fileType={fileType} />
) : null}
<>
<Head>
<title>{fileDetails.name} - File Details</title>
<meta
property="og:title"
content={`${fileDetails.name} - File Details`}
/>
<meta
property="og:description"
content={`Size: ${
fileDetails.size > 1024 * 1024 * 1024
? (fileDetails.size / (1024 * 1024 * 1024)).toFixed(2) + " GB"
: fileDetails.size > 1024 * 1024
? (fileDetails.size / (1024 * 1024)).toFixed(2) + " MB"
: fileDetails.size > 1024
? (fileDetails.size / 1024).toFixed(2) + " KB"
: fileDetails.size + " Bytes"
}, Owner: ${fileDetails.owner}, Uploaded on: ${new Date(
fileDetails.uploadDate
).toLocaleString()}`}
/>
<meta property="og:type" content="website" />
</Head>
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<Toaster position="top-right" reverseOrder={false} />
<div className="absolute top-4 left-4">
<button
onClick={() => router.push("/")}
className="rounded-full bg-white/10 px-4 py-2 font-semibold hover:bg-white/20"
>
Home
</button>
</div>
<div className="bg-white/10 shadow-md rounded-lg p-6 w-full max-w-md text-white">
<p>
<strong>Name:</strong> {fileDetails.name}
</p>
<p>
<strong>Size:</strong> { // format size
fileDetails.size > 1024 * 1024 * 1024 * 1024
? (fileDetails.size / (1024 * 1024 * 1024 * 1024)).toFixed(2) + " TB"
: fileDetails.size > 1024 * 1024 * 1024
<div className="container flex flex-col items-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
<span className="text-[hsl(280,100%,70%)]">File</span> Details
</h1>
<div className="mt-6">
{fileDetails.type !== "unknown" && (
<FilePreview fileId={fileDetails.id} fileType={getFileType(fileDetails.type)} />
)}
</div>
<div className="bg-white/10 shadow-md rounded-lg p-6 w-full max-w-md text-white">
<p>
<strong>Name:</strong> {fileDetails.name}
</p>
<p>
<strong>Size:</strong>{" "}
{fileDetails.size > 1024 * 1024 * 1024
? (fileDetails.size / (1024 * 1024 * 1024)).toFixed(2) + " GB"
: fileDetails.size > 1024 * 1024
? (fileDetails.size / (1024 * 1024)).toFixed(2) + " MB"
: fileDetails.size > 1024
? (fileDetails.size / 1024).toFixed(2) + " KB"
: fileDetails.size + " Bytes"
}
</p>
<p>
<strong>Owner:</strong> <img className="rounded-md inline size-5" src={ fileDetails.owneravatar ? ( fileDetails.owneravatar) : ""} alt="owner image" /> {fileDetails.owner}
</p>
<p>
<strong>Upload Date:</strong> {new Date(fileDetails.uploadDate).toLocaleString()}
</p>
: fileDetails.size + " Bytes"}
</p>
<p>
<strong>Owner:</strong>{" "}
<img
className="rounded-md inline size-5"
src={fileDetails.ownerAvatar || ""}
alt="Owner avatar"
/>{" "}
{fileDetails.owner}
</p>
<p>
<strong>Upload Date:</strong>{" "}
{new Date(fileDetails.uploadDate).toLocaleString()}
</p>
<p>
<strong>Description:</strong>{" "}
{fileDetails.isOwner ? (
<textarea
value={description}
onChange={(e) => handleDescriptionChange(e, debounceTimer)}
className="w-full h-24 p-2 bg-white/10 rounded-lg text-white"
/>
) : fileDetails.description === "" ? (
<span>No description available</span>
) : (
<span>{fileDetails.description}</span>
)}
</p>
{fileDetails.isOwner && (
<div className="flex place-content-center gap-4 mt-4">
<button
onClick={() => handleDownload(fileDetails.id, fileDetails.name)}
className="rounded-full bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 flex items-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="h-5 w-5 text-white"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V3"
/>
</svg>
</button>
<button
onClick={() => handleCopyUrl(fileDetails.url)}
className="rounded-full bg-green-500 px-4 py-2 text-white hover:bg-green-600 flex items-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="h-5 w-5 text-white"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 9h8m-6 4h4m-7 8h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<button
onClick={() => handleRemove(fileDetails.id)}
className="rounded-full bg-red-500 px-4 py-2 text-white hover:bg-red-600 flex items-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="h-5 w-5 text-white"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
</div>
</div>
</div>
</main>
</main>
</>
);
}
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Details />
</Suspense>
);
}

View File

@ -6,13 +6,19 @@ const clients: Set<Client> = new Set<Client>();
export function notifyClients(data: unknown) {
const message = JSON.stringify(data);
const closedClients: Client[] = [];
clients.forEach((client) => {
try {
client.send(message);
} catch (error) {
console.error("Failed to send message to a client:", error);
closedClients.push(client); // Mark the client for removal
}
});
// Remove closed clients from the set
closedClients.forEach((client) => clients.delete(client));
}
export { clients };