feat: implement file copy functionality from reverse shares to user files

- Added a new endpoint to copy files from reverse shares to a user's personal files, ensuring only the creator can perform this action.
- Implemented error handling for various scenarios, including file not found, unauthorized access, and storage limitations.
- Updated the UI to include a "Copy to my files" action, enhancing user experience and accessibility.
- Localized new messages for success and error states in both English and Portuguese.
- Refactored related components to support the new copy functionality, ensuring a seamless integration into the existing workflow.
This commit is contained in:
Daniel Luiz Alves
2025-06-21 11:37:47 -03:00
parent c265b8e08d
commit 978a1e5755
14 changed files with 615 additions and 380 deletions

View File

@@ -190,18 +190,42 @@ export class FilesystemController {
const filePath = provider.getFilePath(tokenData.objectName);
const stats = await fs.promises.stat(filePath);
const isLargeFile = stats.size > 50 * 1024 * 1024;
const fileSize = stats.size;
const isLargeFile = fileSize > 50 * 1024 * 1024;
const fileName = tokenData.fileName || "download";
const range = request.headers.range;
reply.header("Content-Disposition", this.encodeFilenameForHeader(fileName));
reply.header("Content-Type", "application/octet-stream");
reply.header("Content-Length", stats.size);
reply.header("Accept-Ranges", "bytes");
if (isLargeFile) {
await this.downloadLargeFile(reply, provider, filePath);
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
reply.status(206);
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
reply.header("Content-Length", chunkSize);
if (isLargeFile) {
await this.downloadLargeFileRange(reply, provider, tokenData.objectName, start, end);
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
const chunk = buffer.slice(start, end + 1);
reply.send(chunk);
}
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
reply.send(buffer);
reply.header("Content-Length", fileSize);
if (isLargeFile) {
await this.downloadLargeFile(reply, provider, filePath);
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
reply.send(buffer);
}
}
provider.consumeDownloadToken(token);
@@ -222,4 +246,16 @@ export class FilesystemController {
throw error;
}
}
private async downloadLargeFileRange(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
start: number,
end: number
) {
const buffer = await provider.downloadFile(objectName);
const chunk = buffer.slice(start, end + 1);
reply.send(chunk);
}
}

View File

@@ -449,4 +449,31 @@ export class ReverseShareController {
return reply.status(500).send({ error: "Internal server error" });
}
}
async copyFileToUserFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const { fileId } = request.params as { fileId: string };
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const file = await this.reverseShareService.copyReverseShareFileToUserFiles(fileId, userId);
return reply.send({ file, message: "File copied to your files successfully" });
} catch (error: any) {
if (error.message === "File not found") {
return reply.status(404).send({ error: "File not found" });
}
if (error.message === "Unauthorized to copy this file") {
return reply.status(403).send({ error: "Unauthorized to copy this file" });
}
if (error.message.includes("File size exceeds") || error.message.includes("Insufficient storage")) {
return reply.status(400).send({ error: error.message });
}
console.error("Error in copyFileToUserFiles:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
}

View File

@@ -385,6 +385,7 @@ export async function reverseShareRoutes(app: FastifyInstance) {
"/reverse-shares/files/:fileId/download",
{
preValidation,
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB limit for large video files
schema: {
tags: ["Reverse Share"],
operationId: "downloadReverseShareFile",
@@ -546,4 +547,42 @@ export async function reverseShareRoutes(app: FastifyInstance) {
},
reverseShareController.updateFile.bind(reverseShareController)
);
app.post(
"/reverse-shares/files/:fileId/copy",
{
preValidation,
schema: {
tags: ["Reverse Share"],
operationId: "copyReverseShareFileToUserFiles",
summary: "Copy File from Reverse Share to User Files",
description:
"Copy a file from a reverse share to the user's personal files. Only the creator of the reverse share can copy files. The file will be duplicated in storage and added to the user's file collection.",
params: z.object({
fileId: z.string().describe("Unique identifier of the file to copy"),
}),
response: {
200: z.object({
file: z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
extension: z.string(),
size: z.string(),
objectName: z.string(),
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
}),
message: z.string(),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
403: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
},
},
},
reverseShareController.copyFileToUserFiles.bind(reverseShareController)
);
}

View File

@@ -350,8 +350,9 @@ export class ReverseShareService {
throw new Error("Unauthorized to download this file");
}
const fileName = file.name;
const expires = 3600; // 1 hour
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires);
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires, fileName);
return { url, expiresIn: expires };
}
@@ -485,6 +486,96 @@ export class ReverseShareService {
return this.formatFileResponse(updatedFile);
}
async copyReverseShareFileToUserFiles(fileId: string, creatorId: string) {
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
throw new Error("File not found");
}
if (file.reverseShare.creatorId !== creatorId) {
throw new Error("Unauthorized to copy this file");
}
const { prisma } = await import("../../shared/prisma.js");
const { ConfigService } = await import("../config/service.js");
const configService = new ConfigService();
const maxFileSize = BigInt(await configService.getValue("maxFileSize"));
if (file.size > maxFileSize) {
const maxSizeMB = Number(maxFileSize) / (1024 * 1024);
throw new Error(`File size exceeds the maximum allowed size of ${maxSizeMB}MB`);
}
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
const userFiles = await prisma.file.findMany({
where: { userId: creatorId },
select: { size: true },
});
const currentStorage = userFiles.reduce((acc: bigint, userFile: any) => acc + userFile.size, BigInt(0));
if (currentStorage + file.size > maxTotalStorage) {
const availableSpace = Number(maxTotalStorage - currentStorage) / (1024 * 1024);
throw new Error(`Insufficient storage space. You have ${availableSpace.toFixed(2)}MB available`);
}
const newObjectName = `${creatorId}/${Date.now()}-${file.name}`;
if (this.fileService.isFilesystemMode()) {
const { FilesystemStorageProvider } = await import("../../providers/filesystem-storage.provider.js");
const provider = FilesystemStorageProvider.getInstance();
const sourceBuffer = await provider.downloadFile(file.objectName);
await provider.uploadFile(newObjectName, sourceBuffer);
} else {
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);
const uploadUrl = await this.fileService.getPresignedPutUrl(newObjectName, 300);
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
const fileBuffer = Buffer.from(await response.arrayBuffer());
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
body: fileBuffer,
headers: {
"Content-Type": "application/octet-stream",
},
});
if (!uploadResponse.ok) {
throw new Error(`Failed to upload file: ${uploadResponse.statusText}`);
}
}
const newFileRecord = await prisma.file.create({
data: {
name: file.name,
description: file.description || `Copied from: ${file.reverseShare.name || "Unnamed"}`,
extension: file.extension,
size: file.size,
objectName: newObjectName,
userId: creatorId,
},
});
return {
id: newFileRecord.id,
name: newFileRecord.name,
description: newFileRecord.description,
extension: newFileRecord.extension,
size: newFileRecord.size.toString(),
objectName: newFileRecord.objectName,
userId: newFileRecord.userId,
createdAt: newFileRecord.createdAt.toISOString(),
updatedAt: newFileRecord.updatedAt.toISOString(),
};
}
private formatReverseShareResponse(reverseShare: ReverseShareData) {
const result = {
id: reverseShare.id,

View File

@@ -1157,7 +1157,9 @@
},
"actions": {
"preview": "Preview",
"download": "Download"
"download": "Download",
"copyToMyFiles": "Copy to my files",
"copying": "Copying..."
},
"uploadedBy": "Uploaded by {name}",
"anonymous": "Anonymous",
@@ -1165,7 +1167,9 @@
"downloadError": "Error downloading file",
"editSuccess": "File updated successfully",
"editError": "Error updating file",
"previewNotAvailable": "Preview not available"
"previewNotAvailable": "Preview not available",
"copySuccess": "File copied to your files successfully",
"copyError": "Error copying file to your files"
}
},
"form": {
@@ -1347,7 +1351,9 @@
"cancel": "Cancel",
"preview": "Preview",
"download": "Download",
"delete": "Delete"
"delete": "Delete",
"copyToMyFiles": "Copy to my files",
"copying": "Copying..."
},
"editField": {
"saveChanges": "Save changes",

View File

@@ -1153,7 +1153,9 @@
},
"actions": {
"preview": "Visualizar",
"download": "Baixar"
"download": "Baixar",
"copyToMyFiles": "Copiar para meus arquivos",
"copying": "Copiando..."
},
"uploadedBy": "Enviado por {name}",
"anonymous": "Anônimo",
@@ -1161,7 +1163,9 @@
"downloadError": "Erro ao baixar arquivo",
"editSuccess": "Arquivo atualizado com sucesso",
"editError": "Erro ao atualizar arquivo",
"previewNotAvailable": "Visualização não disponível"
"previewNotAvailable": "Visualização não disponível",
"copySuccess": "Arquivo copiado para seus arquivos com sucesso",
"copyError": "Erro ao copiar arquivo para seus arquivos"
}
},
"form": {
@@ -1343,7 +1347,9 @@
"cancel": "Cancelar",
"preview": "Visualizar",
"download": "Baixar",
"delete": "Excluir"
"delete": "Excluir",
"copyToMyFiles": "Copiar para meus arquivos",
"copying": "Copiando..."
},
"editField": {
"saveChanges": "Salvar alterações",

View File

@@ -1,7 +1,16 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IconCheck, IconDownload, IconEdit, IconEye, IconFile, IconTrash, IconX } from "@tabler/icons-react";
import {
IconCheck,
IconClipboardCopy,
IconDownload,
IconEdit,
IconEye,
IconFile,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useTranslations } from "next-intl";
@@ -16,6 +25,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
copyReverseShareFileToUserFiles,
deleteReverseShareFile,
downloadReverseShareFile,
updateReverseShareFile,
@@ -248,6 +258,7 @@ interface FileRowProps {
editValue: string;
inputRef: React.RefObject<HTMLInputElement | null>;
hoveredFile: HoverState | null;
copyingFile: string | null;
onStartEdit: (fileId: string, field: string, currentValue: string) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
@@ -257,6 +268,7 @@ interface FileRowProps {
onPreview: (file: ReverseShareFile) => void;
onDownload: (file: ReverseShareFile) => void;
onDelete: (file: ReverseShareFile) => void;
onCopy: (file: ReverseShareFile) => void;
}
function FileRow({
@@ -265,6 +277,7 @@ function FileRow({
editValue,
inputRef,
hoveredFile,
copyingFile,
onStartEdit,
onSaveEdit,
onCancelEdit,
@@ -274,6 +287,7 @@ function FileRow({
onPreview,
onDownload,
onDelete,
onCopy,
}: FileRowProps) {
const t = useTranslations();
const { icon: FileIcon, color } = getFileIcon(file.name);
@@ -348,6 +362,24 @@ function FileRow({
>
<IconEye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onCopy(file)}
disabled={copyingFile === file.id}
title={
copyingFile === file.id
? t("reverseShares.components.fileActions.copying")
: t("reverseShares.components.fileActions.copyToMyFiles")
}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 disabled:opacity-50"
>
{copyingFile === file.id ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent"></div>
) : (
<IconClipboardCopy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
@@ -389,6 +421,7 @@ export function ReceivedFilesModal({
const t = useTranslations();
const [previewFile, setPreviewFile] = useState<ReverseShareFile | null>(null);
const [hoveredFile, setHoveredFile] = useState<HoverState | null>(null);
const [copyingFile, setCopyingFile] = useState<string | null>(null);
const { editingFile, editValue, setEditValue, inputRef, startEdit, cancelEdit } = useFileEdit();
@@ -477,6 +510,29 @@ export function ReceivedFilesModal({
}
};
const handleCopyFile = async (file: ReverseShareFile) => {
try {
setCopyingFile(file.id);
await copyReverseShareFileToUserFiles(file.id);
toast.success(t("reverseShares.modals.receivedFiles.copySuccess"));
} catch (error: any) {
console.error("Error copying file:", error);
if (error.response?.data?.error) {
const errorMessage = error.response.data.error;
if (errorMessage.includes("File size exceeds") || errorMessage.includes("Insufficient storage")) {
toast.error(errorMessage);
} else {
toast.error(t("reverseShares.modals.receivedFiles.copyError"));
}
} else {
toast.error(t("reverseShares.modals.receivedFiles.copyError"));
}
} finally {
setCopyingFile(null);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
saveEdit();
@@ -550,6 +606,7 @@ export function ReceivedFilesModal({
editValue={editValue}
inputRef={inputRef}
hoveredFile={hoveredFile}
copyingFile={copyingFile}
onStartEdit={startEdit}
onSaveEdit={saveEdit}
onCancelEdit={cancelEdit}
@@ -559,6 +616,7 @@ export function ReceivedFilesModal({
onPreview={handlePreview}
onDownload={handleDownload}
onDelete={handleDeleteFile}
onCopy={handleCopyFile}
/>
))}
</TableBody>

View File

@@ -1,17 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { downloadReverseShareFile } from "@/http/endpoints/reverse-shares";
import { getFileIcon } from "@/utils/file-icons";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
interface ReverseShareFilePreviewModalProps {
isOpen: boolean;
@@ -21,326 +10,18 @@ interface ReverseShareFilePreviewModalProps {
name: string;
objectName: string;
extension?: string;
};
} | null;
}
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
const t = useTranslations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [videoBlob, setVideoBlob] = useState<string | null>(null);
const [pdfAsBlob, setPdfAsBlob] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
if (!file) return null;
useEffect(() => {
if (isOpen && file.id && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
loadPreview();
}
}, [file.id, isOpen]);
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
}
};
}, [previewUrl, videoBlob]);
useEffect(() => {
if (!isOpen) {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
setVideoBlob(null);
}
}
}, [isOpen]);
const loadPreview = async () => {
if (!file.id || isLoadingPreview) return;
setIsLoadingPreview(true);
try {
const response = await downloadReverseShareFile(file.id);
const url = response.data.url;
setDownloadUrl(url);
const fileType = getFileType();
if (fileType === "video") {
await loadVideoPreview(url);
} else if (fileType === "audio") {
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(false);
}
const adaptedFile = {
name: file.name,
objectName: file.objectName,
type: file.extension,
id: file.id,
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blobUrl);
} catch (error) {
console.error("Failed to load video as blob:", error);
setPreviewUrl(url);
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
} catch (error) {
console.error("Failed to load audio as blob:", error);
setPreviewUrl(url);
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setPreviewUrl(blobUrl);
setPdfAsBlob(true);
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setPreviewUrl(url);
setTimeout(() => {
if (!pdfLoadFailed && !pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
setPdfLoadFailed(true);
if (downloadUrl) {
setTimeout(() => {
loadPdfPreview(downloadUrl);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = downloadUrl;
if (!downloadUrlToUse) {
const response = await downloadReverseShareFile(file.id);
downloadUrlToUse = response.data.url;
}
const fileResponse = await fetch(downloadUrlToUse);
if (!fileResponse.ok) {
throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
}
const blob = await fileResponse.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
const getFileType = () => {
const extension = file.extension?.toLowerCase() || file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
return "other";
};
const renderPreview = () => {
const fileType = getFileType();
const { icon: FileIcon, color } = getFileIcon(file.name);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={handlePdfLoadError}
>
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
onError={handlePdfLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={mediaUrl!} />
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={mediaUrl!} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{(() => {
const FileIcon = getFileIcon(file.name).icon;
return <FileIcon size={24} />;
})()}
<span className="truncate">{file.name}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={handleDownload}>
<IconDownload className="h-4 w-4" />
{t("common.download")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;
}

View File

@@ -16,12 +16,23 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
redirect: "manual",
});
const resBody = await apiRes.text();
if (!apiRes.ok) {
const resBody = await apiRes.text();
return new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const res = new NextResponse(resBody, {
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": apiRes.headers.get("Content-Length") || "",
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
"Content-Range": apiRes.headers.get("Content-Range") || "",
},
});

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params;
const cookieHeader = req.headers.get("cookie");
const apiRes = await fetch(`${process.env.API_BASE_URL}/reverse-shares/files/${fileId}/copy`, {
method: "POST",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -7,18 +7,28 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ file
const apiRes = await fetch(`${process.env.API_BASE_URL}/reverse-shares/files/${fileId}/download`, {
method: "GET",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
if (!apiRes.ok) {
const resBody = await apiRes.text();
return new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const res = new NextResponse(resBody, {
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": apiRes.headers.get("Content-Length") || "",
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
"Content-Range": apiRes.headers.get("Content-Range") || "",
},
});

View File

@@ -16,12 +16,14 @@ interface FilePreviewModalProps {
name: string;
objectName: string;
type?: string;
id?: string;
};
isReverseShare?: boolean;
}
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
export function FilePreviewModal({ isOpen, onClose, file, isReverseShare = false }: FilePreviewModalProps) {
const t = useTranslations();
const previewState = useFilePreview({ file, isOpen });
const previewState = useFilePreview({ file, isOpen, isReverseShare });
return (
<Dialog open={isOpen} onOpenChange={onClose}>

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getDownloadUrl } from "@/http/endpoints";
import { downloadReverseShareFile } from "@/http/endpoints/reverse-shares";
import { getFileExtension, getFileType, type FileType } from "@/utils/file-types";
interface FilePreviewState {
@@ -21,11 +22,21 @@ interface UseFilePreviewProps {
name: string;
objectName: string;
type?: string;
id?: string;
};
isOpen: boolean;
isReverseShare?: boolean;
}
export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
export function useFilePreview({ file, isOpen, isReverseShare = false }: UseFilePreviewProps) {
if (isReverseShare) {
return useReverseShareFilePreview({ file, isOpen });
}
return useNormalFilePreview({ file, isOpen });
}
// Separate hook for reverse shares - exact copy of working logic
function useReverseShareFilePreview({ file, isOpen }: { file: UseFilePreviewProps["file"]; isOpen: boolean }) {
const t = useTranslations();
const [state, setState] = useState<FilePreviewState>({
previewUrl: null,
@@ -38,24 +49,26 @@ export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
pdfLoadFailed: false,
});
const loadedRef = useRef<string | null>(null);
const fileType: FileType = getFileType(file.name);
// Reset state when file changes or modal opens
useEffect(() => {
if (isOpen && file.objectName && !state.isLoadingPreview) {
if (isOpen && file.id && loadedRef.current !== file.id) {
loadedRef.current = file.id;
resetState();
loadPreview();
} else if (!isOpen) {
loadedRef.current = null;
}
}, [file.objectName, isOpen]);
}, [isOpen, file.id]);
// Cleanup blob URLs
useEffect(() => {
return () => {
cleanupBlobUrls();
};
}, [state.previewUrl, state.videoBlob]);
// Cleanup when modal closes
useEffect(() => {
if (!isOpen) {
cleanupBlobUrls();
@@ -85,13 +98,12 @@ export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
};
const loadPreview = async () => {
if (!file.objectName || state.isLoadingPreview) return;
if (!file.id || state.isLoadingPreview) return;
setState((prev) => ({ ...prev, isLoadingPreview: true }));
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const response = await downloadReverseShareFile(file.id);
const url = response.data.url;
setState((prev) => ({ ...prev, downloadUrl: url }));
@@ -193,17 +205,14 @@ export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
const extension = getFileExtension(file.name);
try {
// For JSON files, validate and format
if (extension === "json") {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, 2);
setState((prev) => ({ ...prev, textContent: formatted }));
} else {
// For other text files, show as-is
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (jsonError) {
// If JSON parsing fails, show as plain text
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (error) {
@@ -214,35 +223,253 @@ export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
const handlePdfLoadError = async () => {
if (state.pdfLoadFailed || state.pdfAsBlob) return;
setState((prev) => ({ ...prev, pdfLoadFailed: true }));
if (state.downloadUrl) {
setTimeout(() => {
loadPdfPreview(state.downloadUrl!);
}, 500);
}
};
const handleDownload = async () => {
if (!file.id) return;
try {
let downloadUrlToUse = state.downloadUrl;
if (!downloadUrlToUse) {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const response = await downloadReverseShareFile(file.id);
const link = document.createElement("a");
link.href = downloadUrlToUse;
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error(t("filePreview.downloadError"));
}
};
return {
...state,
fileType,
handleDownload,
handlePdfLoadError,
};
}
function useNormalFilePreview({ file, isOpen }: { file: UseFilePreviewProps["file"]; isOpen: boolean }) {
const t = useTranslations();
const [state, setState] = useState<FilePreviewState>({
previewUrl: null,
videoBlob: null,
textContent: null,
downloadUrl: null,
isLoading: true,
isLoadingPreview: false,
pdfAsBlob: false,
pdfLoadFailed: false,
});
const loadedRef = useRef<string | null>(null);
const loadingRef = useRef<boolean>(false);
const fileType: FileType = getFileType(file.name);
useEffect(() => {
const fileKey = file.objectName;
if (isOpen && fileKey && loadedRef.current !== fileKey) {
loadedRef.current = fileKey;
resetState();
loadPreview();
} else if (!isOpen) {
loadedRef.current = null;
loadingRef.current = false;
}
}, [isOpen, file.objectName]);
useEffect(() => {
return () => {
cleanupBlobUrls();
};
}, [state.previewUrl, state.videoBlob]);
useEffect(() => {
if (!isOpen) {
cleanupBlobUrls();
}
}, [isOpen]);
const resetState = () => {
setState((prev) => ({
...prev,
previewUrl: null,
videoBlob: null,
textContent: null,
downloadUrl: null,
pdfAsBlob: false,
pdfLoadFailed: false,
isLoading: true,
}));
loadedRef.current = null;
loadingRef.current = false;
};
const cleanupBlobUrls = () => {
if (state.previewUrl && state.previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(state.previewUrl);
}
if (state.videoBlob && state.videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(state.videoBlob);
}
};
const loadPreview = async () => {
if (!file.objectName || state.isLoadingPreview) return;
const currentFileKey = file.objectName;
if (loadingRef.current) {
return;
}
loadingRef.current = true;
setState((prev) => ({ ...prev, isLoadingPreview: true }));
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const url = response.data.url;
setState((prev) => ({ ...prev, downloadUrl: url }));
switch (fileType) {
case "video":
await loadVideoPreview(url);
break;
case "audio":
await loadAudioPreview(url);
break;
case "pdf":
await loadPdfPreview(url);
break;
case "text":
await loadTextPreview(url);
break;
default:
setState((prev) => ({ ...prev, previewUrl: url }));
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setState((prev) => ({
...prev,
isLoading: false,
isLoadingPreview: false,
}));
loadingRef.current = false;
}
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, videoBlob: blobUrl }));
} catch (error) {
console.error("Failed to load video as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, previewUrl: blobUrl }));
} catch (error) {
console.error("Failed to load audio as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setState((prev) => ({
...prev,
previewUrl: blobUrl,
pdfAsBlob: true,
}));
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
setTimeout(() => {
if (!state.pdfLoadFailed && !state.pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const loadTextPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const text = await response.text();
const extension = getFileExtension(file.name);
try {
if (extension === "json") {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, 2);
setState((prev) => ({ ...prev, textContent: formatted }));
} else {
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (jsonError) {
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (error) {
console.error("Failed to load text content:", error);
setState((prev) => ({ ...prev, textContent: null }));
}
};
const handlePdfLoadError = async () => {
if (state.pdfLoadFailed || state.pdfAsBlob) return;
setState((prev) => ({ ...prev, pdfLoadFailed: true }));
};
const handleDownload = async () => {
if (!file.objectName) return;
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const link = document.createElement("a");
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};

View File

@@ -262,3 +262,14 @@ export const updateReverseShareFile = <TData = UpdateReverseShareFileResult>(
): Promise<TData> => {
return apiInstance.put(`/api/reverse-shares/files/${fileId}`, updateReverseShareFileBody, options);
};
/**
* Copy file from reverse share to user files
* @summary Copy File from Reverse Share to User Files
*/
export const copyReverseShareFileToUserFiles = <TData = any>(
fileId: string,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/reverse-shares/files/${fileId}/copy`, undefined, options);
};