From e1097ba1be7a10b7b1c60a48086761a7ce5e8204 Mon Sep 17 00:00:00 2001 From: ZareMate <0.zaremate@gmail.com> Date: Wed, 16 Apr 2025 18:50:31 +0200 Subject: [PATCH] feat: add description field to File model and update related components for file details and actions --- prisma/schema.prisma | 2 +- src/app/_components/FileActions.tsx | 109 ++++++++ src/app/_components/FileGrid.tsx | 132 ++++----- .../{SharePage.tsx => FilePreview.tsx} | 4 +- src/app/api/share/route.ts | 28 +- src/app/share/page.tsx | 259 ++++++++++++------ src/utils/notifyClients.ts | 6 + 7 files changed, 373 insertions(+), 167 deletions(-) create mode 100644 src/app/_components/FileActions.tsx rename src/app/_components/{SharePage.tsx => FilePreview.tsx} (94%) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c115c0..97f9ba3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? } diff --git a/src/app/_components/FileActions.tsx b/src/app/_components/FileActions.tsx new file mode 100644 index 0000000..804c053 --- /dev/null +++ b/src/app/_components/FileActions.tsx @@ -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, + debounceTimer: React.RefObject + ) => { + 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, + }; +}; \ No newline at end of file diff --git a/src/app/_components/FileGrid.tsx b/src/app/_components/FileGrid.tsx index 3525817..2b04552 100644 --- a/src/app/_components/FileGrid.tsx +++ b/src/app/_components/FileGrid.tsx @@ -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([]); + const [files, setFiles] = useState([]); const [error, setError] = useState(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 = { + ".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
{error}
; } return ( -
- {files.map((file) => ( +
+ {files.map((file) => { + const fileType = getFileType(file.extension); + + return (
- - -
+ {file.description && (

Description: {file.description}

)} + + +
{/* Download Button */}
- ))} -
+ ); + })} +
); } \ No newline at end of file diff --git a/src/app/_components/SharePage.tsx b/src/app/_components/FilePreview.tsx similarity index 94% rename from src/app/_components/SharePage.tsx rename to src/app/_components/FilePreview.tsx index 88f557f..87c3f22 100644 --- a/src/app/_components/SharePage.tsx +++ b/src/app/_components/FilePreview.tsx @@ -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(null); const [error, setError] = useState(null); diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index d26be8c..3e60153 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -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 }); + } } \ No newline at end of file diff --git a/src/app/share/page.tsx b/src/app/share/page.tsx index 2fbbd9e..e30be2f 100644 --- a/src/app/share/page.tsx +++ b/src/app/share/page.tsx @@ -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(null); const [error, setError] = useState(null); + const [description, setDescription] = useState(""); + const debounceTimer = useRef(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 = { + ".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 ( -
+
-
+

- File Details + No File Found

@@ -98,60 +100,155 @@ function Details() { } return ( -
- -
- -
-
-

- File Details -

-
- {// if fileType is not ubknown, show the media player - fileType && !fileType.startsWith("unknown") ? ( - - ) : null} - + <> + + {fileDetails.name} - File Details + + 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()}`} + /> + + +
+ +
+
-
-

- Name: {fileDetails.name} -

-

- Size: { // format size - fileDetails.size > 1024 * 1024 * 1024 * 1024 - ? (fileDetails.size / (1024 * 1024 * 1024 * 1024)).toFixed(2) + " TB" - : fileDetails.size > 1024 * 1024 * 1024 +

+

+ File Details +

+
+ {fileDetails.type !== "unknown" && ( + + + )} +
+
+

+ Name: {fileDetails.name} +

+

+ 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: owner image {fileDetails.owner} -

-

- Upload Date: {new Date(fileDetails.uploadDate).toLocaleString()} -

+ : fileDetails.size + " Bytes"} +

+

+ Owner:{" "} + Owner avatar{" "} + {fileDetails.owner} +

+

+ Upload Date:{" "} + {new Date(fileDetails.uploadDate).toLocaleString()} +

+

+ Description:{" "} + {fileDetails.isOwner ? ( +