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 size Int // Size in bytes
extension String extension String
uploadDate DateTime @default(now()) uploadDate DateTime @default(now())
description String @default("")
uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull) uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
uploadedById String? 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"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { env } from "~/env.js"; import { env } from "~/env.js";
interface File { import { FilePreview } from "~/app/_components/FilePreview";
import { useFileActions } from "~/app/_components/FileActions";
interface FileDetails {
id: string; id: string;
name: string; name: string;
url: string; url: string;
description: string;
extension: string;
} }
interface FileGridProps { interface FileGridProps {
@ -18,18 +20,20 @@ interface FileGridProps {
} }
export default function FileGrid({ session }: 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 [error, setError] = useState<string | null>(null);
const router = useRouter(); 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 () => { const fetchFiles = async () => {
try { try {
const response = await fetch("/api/files"); const response = await fetch("/api/files");
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch files");
throw new Error("Failed to fetch files");
} const data = (await response.json()) as { files: FileDetails[] };
const data = await response.json() as { files: File[] }; // Explicitly type the response
setFiles(data.files); setFiles(data.files);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -37,49 +41,38 @@ export default function FileGrid({ session }: FileGridProps) {
} }
}; };
const handleDownload = async (fileId: string, fileName: string) => { // Determine file type based on extension
try { const getFileType = (extension: string): string => {
const response = await fetch(`/api/files/download?fileId=${encodeURIComponent(fileId)}&fileName=${encodeURIComponent(fileName)}`); const fileTypes: Record<string, string> = {
if (!response.ok) { ".mp4": "video/mp4",
throw new Error("Failed to download file"); ".webm": "video/webm",
} ".ogg": "video/ogg",
// Download the file with the correct filename ".jpg": "image/jpeg",
const blob = await response.blob(); ".jpeg": "image/jpeg",
const url = window.URL.createObjectURL(blob); ".png": "image/png",
const a = document.createElement("a"); ".gif": "image/gif",
a.href = url; ".svg": "image/svg+xml",
a.download = fileName; ".mp3": "audio/mpeg",
document.body.appendChild(a); ".wav": "audio/wav",
a.click(); };
a.remove(); return fileTypes[extension] || "unknown";
window.URL.revokeObjectURL(url);
toast.success(`File "${fileName}" downloaded successfully!`);
} catch (err) {
console.error(err);
toast.error("Failed to download file.");
}
}; };
// Handle real-time updates via SSE
useEffect(() => { useEffect(() => {
if (!session?.user) { if (!session?.user) {
setError("You must be logged in to view files."); setError("You must be logged in to view files.");
return; return;
} }
// Fetch files initially
void fetchFiles(); void fetchFiles();
// Listen for real-time updates via SSE
const eventSource = new EventSource("/api/files/stream"); const eventSource = new EventSource("/api/files/stream");
eventSource.onmessage = (event) => { 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.type === "file-added" && data.file) {
if (data.file) { setFiles((prevFiles) => (data.file ? [...prevFiles, data.file] : prevFiles));
setFiles((prevFiles) => [...prevFiles, data.file as File]);
}
toast.success(`File "${data.file.name}" added!`); toast.success(`File "${data.file.name}" added!`);
} else if (data.type === "file-removed" && data.fileId) { } else if (data.type === "file-removed" && data.fileId) {
setFiles((prevFiles) => prevFiles.filter((file) => file.id !== data.fileId)); setFiles((prevFiles) => prevFiles.filter((file) => file.id !== data.fileId));
@ -91,56 +84,32 @@ export default function FileGrid({ session }: FileGridProps) {
eventSource.close(); eventSource.close();
}; };
return () => { return () => eventSource.close();
eventSource.close(); // Cleanup on unmount
};
}, [session]); }, [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) { if (error) {
return <div className="text-red-500">{error}</div>; return <div className="text-red-500">{error}</div>;
} }
return ( return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-8"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-8">
{files.map((file) => ( {files.map((file) => {
const fileType = getFileType(file.extension);
return (
<div <div
key={file.id} 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> <h3 className="text-2xl font-bold">{file.name}</h3>
</button> </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 */} {/* Download Button */}
<button <button
onClick={() => handleDownload(file.id, file.name)} onClick={() => handleDownload(file.id, file.name)}
@ -191,7 +160,7 @@ export default function FileGrid({ session }: FileGridProps) {
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 24 24" viewBox="0 0 24 24"
strokeWidth={2} strokeWidth={2}
stroke="currentColor" stroke="currentColor"
className="h-5 w-5 text-white" className="h-5 w-5 text-white"
@ -205,7 +174,8 @@ export default function FileGrid({ session }: FileGridProps) {
</button> </button>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
); );
} }

View File

@ -2,12 +2,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface SharePageProps { interface FilePreviewProps {
fileId: string; fileId: string;
fileType: string; // Pass the file type as a prop 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 [mediaSrc, setMediaSrc] = useState<string | null>(null);
const [error, setError] = 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({ return NextResponse.json({
name: file.name, name: file.name,
size: file.size, size: file.size,
owner: file.uploadedBy?.name ?? null, // Use nullish coalescing owner: file.uploadedBy?.name ?? null,
owneravatar: file.uploadedBy?.image ?? null, ownerAvatar: file.uploadedBy?.image ?? null,
uploadDate: file.uploadDate, uploadDate: file.uploadDate,
id: file.id, id: file.id,
isOwner: session?.user?.id === file.uploadedById, isOwner: session?.user?.id === file.uploadedById,
type: file.extension, type: file.extension,
url: file.url, url: file.url,
description: file.description,
}); });
} catch (error) { } catch (error) {
console.error("Error fetching file details:", error); console.error("Error fetching file details:", error);
return NextResponse.json({ error: "Failed to fetch file details" }, { status: 500 }); 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"; "use client";
import { Suspense } from "react"; import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import toast, { Toaster } from "react-hot-toast"; 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 { interface FileDetails {
name: string; name: string;
size: number; size: number;
owner: string; owner: string;
owneravatar: string | null; ownerAvatar: string | null;
uploadDate: string; uploadDate: string;
id: string; id: string;
isOwner: boolean; isOwner: boolean;
type: string; type: string;
url: string; url: string;
description: string;
} }
export default function FilePreviewContainer() {
function Details() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const fileId = searchParams.get("id"); const fileId = searchParams.get("id");
const [fileDetails, setFileDetails] = useState<FileDetails | null>(null); const [fileDetails, setFileDetails] = useState<FileDetails | null>(null);
const [error, setError] = useState<string | 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 { handleDescriptionChange, handleCopyUrl, handleDownload, handleRemove } = useFileActions(
const fileType = fileDetails?.type === ".mp4" () => {},
? "video/mp4" setDescription,
: fileDetails?.type === ".webm" fileId || undefined
? "video/webm" );
: fileDetails?.type === ".ogg"
? "video/ogg" const getFileType = (extension: string): string => {
: fileDetails?.type === ".jpg" || fileDetails?.type === ".jpeg" const fileTypes: Record<string, string> = {
? "image/jpeg" ".mp4": "video/mp4",
: fileDetails?.type === ".png" ".webm": "video/webm",
? "image/png" ".ogg": "video/ogg",
: fileDetails?.type === ".gif" ".jpg": "image/jpeg",
? "image/gif" ".jpeg": "image/jpeg",
: fileDetails?.type === ".svg" ".png": "image/png",
? "image/svg+xml" ".gif": "image/gif",
: fileDetails?.type === ".mp3" ".svg": "image/svg+xml",
? "audio/mpeg" ".mp3": "audio/mpeg",
: fileDetails?.type === ".wav" ".wav": "audio/wav",
? "audio/wav" };
// if fileType is not one of the above, set it to unknown return fileTypes[extension] || "unknown";
: "unknown"; }
useEffect(() => { useEffect(() => {
if (!fileId) { if (!fileId) {
@ -57,12 +60,11 @@ function Details() {
const fetchFileDetails = async () => { const fetchFileDetails = async () => {
try { try {
const response = await fetch(`/api/share?id=${encodeURIComponent(fileId)}`); const response = await fetch(`/api/share?id=${encodeURIComponent(fileId)}`);
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch file details");
throw new Error("Failed to fetch file details");
}
const data: FileDetails = await response.json(); const data: FileDetails = await response.json();
setFileDetails(data); setFileDetails(data);
setDescription(data.description);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError("Failed to load file details."); setError("Failed to load file details.");
@ -78,19 +80,19 @@ function Details() {
if (!fileDetails) { if (!fileDetails) {
return ( 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} /> <Toaster position="top-right" reverseOrder={false} />
<div className="absolute top-4 left-4"> <div className="absolute top-4 left-4">
<button <button
onClick={() => router.push("/")} 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 Home
</button> </button>
</div> </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]"> <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> </h1>
</div> </div>
</main> </main>
@ -98,60 +100,155 @@ function Details() {
} }
return ( 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} /> <Head>
<div className="absolute top-4 left-4"> <title>{fileDetails.name} - File Details</title>
<button <meta
onClick={() => router.push("/")} property="og:title"
className="rounded-full bg-white/10 px-4 py-2 font-semibold no-underline transition hover:bg-white/20" content={`${fileDetails.name} - File Details`}
> />
Home <meta
</button> property="og:description"
</div> content={`Size: ${
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16"> fileDetails.size > 1024 * 1024 * 1024
<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}
</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
? (fileDetails.size / (1024 * 1024 * 1024)).toFixed(2) + " GB" ? (fileDetails.size / (1024 * 1024 * 1024)).toFixed(2) + " GB"
: fileDetails.size > 1024 * 1024 : fileDetails.size > 1024 * 1024
? (fileDetails.size / (1024 * 1024)).toFixed(2) + " MB" ? (fileDetails.size / (1024 * 1024)).toFixed(2) + " MB"
: fileDetails.size > 1024 : fileDetails.size > 1024
? (fileDetails.size / 1024).toFixed(2) + " KB" ? (fileDetails.size / 1024).toFixed(2) + " KB"
: fileDetails.size + " Bytes" : 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="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>
<p> <p>
<strong>Owner:</strong> <img className="rounded-md inline size-5" src={ fileDetails.owneravatar ? ( fileDetails.owneravatar) : ""} alt="owner image" /> {fileDetails.owner} <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>
<p> <p>
<strong>Upload Date:</strong> {new Date(fileDetails.uploadDate).toLocaleString()} <strong>Owner:</strong>{" "}
<img
className="rounded-md inline size-5"
src={fileDetails.ownerAvatar || ""}
alt="Owner avatar"
/>{" "}
{fileDetails.owner}
</p> </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> </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) { export function notifyClients(data: unknown) {
const message = JSON.stringify(data); const message = JSON.stringify(data);
const closedClients: Client[] = [];
clients.forEach((client) => { clients.forEach((client) => {
try { try {
client.send(message); client.send(message);
} catch (error) { } catch (error) {
console.error("Failed to send message to a client:", 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 }; export { clients };