refactor: simplify FilePreviewModal by utilizing useFilePreview hook

- Replaced complex state management and effect hooks in FilePreviewModal with a custom useFilePreview hook for improved readability and maintainability.
- Integrated FilePreviewRenderer component to handle different file types and rendering logic, enhancing the modularity of the code.
- Updated file icon mappings in file-icons.tsx to include additional file types and improve visual representation in the UI.
This commit is contained in:
Daniel Luiz Alves
2025-06-20 15:37:00 -03:00
parent 0a65917cbf
commit 0d346b75cc
12 changed files with 1341 additions and 447 deletions

View File

@@ -1,17 +1,13 @@
"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 { getDownloadUrl } from "@/http/endpoints";
import { useFilePreview } from "@/hooks/use-file-preview";
import { getFileIcon } from "@/utils/file-icons";
import { FilePreviewRenderer } from "./previews";
interface FilePreviewModalProps {
isOpen: boolean;
@@ -25,435 +21,7 @@ interface FilePreviewModalProps {
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
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);
const [textContent, setTextContent] = useState<string | null>(null);
useEffect(() => {
if (isOpen && file.objectName && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
setTextContent(null);
loadPreview();
}
}, [file.objectName, 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.objectName || isLoadingPreview) return;
setIsLoadingPreview(true);
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
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 if (fileType === "text") {
await loadTextPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(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);
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 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 = file.name.split(".").pop()?.toLowerCase();
try {
// For JSON files, validate and format
if (extension === "json") {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, 2);
setTextContent(formatted);
} else {
// For other text files, show as-is
setTextContent(text);
}
} catch (jsonError) {
// If JSON parsing fails, show as plain text
setTextContent(text);
}
} catch (error) {
console.error("Failed to load text content:", error);
setTextContent(null);
}
};
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 encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const link = document.createElement("a");
link.href = downloadUrlToUse;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
const getFileType = () => {
const extension = 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";
// Text and code files
if (
[
"json",
"txt",
"md",
"markdown",
"log",
"csv",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"js",
"jsx",
"ts",
"tsx",
"vue",
"svelte",
"php",
"py",
"rb",
"java",
"c",
"cpp",
"h",
"hpp",
"cs",
"go",
"rs",
"kt",
"swift",
"dart",
"scala",
"clj",
"hs",
"elm",
"f#",
"vb",
"pl",
"r",
"sql",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"bat",
"cmd",
"dockerfile",
"yaml",
"yml",
"toml",
"ini",
"conf",
"config",
"env",
"gitignore",
"gitattributes",
"editorconfig",
"eslintrc",
"prettierrc",
"babelrc",
"tsconfig",
"package",
"composer",
"gemfile",
"requirements",
"makefile",
"rakefile",
"gradle",
"pom",
"build",
].includes(extension || "")
)
return "text";
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>
);
}
// For text files, we don't need previewUrl, we use textContent instead
if (fileType === "text" && !textContent && !isLoading) {
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" && fileType !== "text") {
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 "text":
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
{textContent ? (
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words overflow-x-auto">
<code className={`language-${file.name.split(".").pop()?.toLowerCase() || "text"}`}>
{textContent}
</code>
</pre>
) : (
<div className="flex items-center justify-center h-32">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<p className="text-sm text-muted-foreground">{t("filePreview.loading")}</p>
</div>
</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>
);
}
};
const previewState = useFilePreview({ file, isOpen });
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -467,12 +35,24 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
<span className="truncate">{file.name}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div>
<div className="flex-1 overflow-auto">
<FilePreviewRenderer
fileType={previewState.fileType}
fileName={file.name}
previewUrl={previewState.previewUrl}
videoBlob={previewState.videoBlob}
textContent={previewState.textContent}
isLoading={previewState.isLoading}
pdfAsBlob={previewState.pdfAsBlob}
pdfLoadFailed={previewState.pdfLoadFailed}
onPdfLoadError={previewState.handlePdfLoadError}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={handleDownload}>
<Button onClick={previewState.handleDownload}>
<IconDownload className="h-4 w-4" />
{t("common.download")}
</Button>

View File

@@ -0,0 +1,13 @@
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
interface AudioPreviewProps {
src: string;
}
export function AudioPreview({ src }: AudioPreviewProps) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={src} />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useTranslations } from "next-intl";
import { getFileIcon } from "@/utils/file-icons";
interface DefaultPreviewProps {
fileName: string;
isLoading?: boolean;
message?: string;
}
export function DefaultPreview({ fileName, isLoading, message }: DefaultPreviewProps) {
const t = useTranslations();
const { icon: FileIcon, color } = getFileIcon(fileName);
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>
);
}
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">{message || t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { type FileType } from "@/utils/file-types";
import { AudioPreview } from "./audio-preview";
import { DefaultPreview } from "./default-preview";
import { ImagePreview } from "./image-preview";
import { PdfPreview } from "./pdf-preview";
import { TextPreview } from "./text-preview";
import { VideoPreview } from "./video-preview";
interface FilePreviewRendererProps {
fileType: FileType;
fileName: string;
previewUrl: string | null;
videoBlob: string | null;
textContent: string | null;
isLoading: boolean;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onPdfLoadError: () => void;
}
export function FilePreviewRenderer({
fileType,
fileName,
previewUrl,
videoBlob,
textContent,
isLoading,
pdfAsBlob,
pdfLoadFailed,
onPdfLoadError,
}: FilePreviewRendererProps) {
if (isLoading) {
return <DefaultPreview fileName={fileName} isLoading />;
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return <DefaultPreview fileName={fileName} />;
}
if (fileType === "text" && !textContent) {
return <DefaultPreview fileName={fileName} />;
}
if (!previewUrl && fileType !== "video" && fileType !== "text") {
return <DefaultPreview fileName={fileName} />;
}
switch (fileType) {
case "pdf":
return (
<PdfPreview
src={previewUrl!}
fileName={fileName}
pdfAsBlob={pdfAsBlob}
pdfLoadFailed={pdfLoadFailed}
onLoadError={onPdfLoadError}
/>
);
case "text":
return <TextPreview content={textContent} fileName={fileName} />;
case "image":
return <ImagePreview src={previewUrl!} alt={fileName} />;
case "audio":
return <AudioPreview src={mediaUrl!} />;
case "video":
return <VideoPreview src={mediaUrl!} />;
default:
return <DefaultPreview fileName={fileName} />;
}
}

View File

@@ -0,0 +1,14 @@
import { AspectRatio } from "@/components/ui/aspect-ratio";
interface ImagePreviewProps {
src: string;
alt: string;
}
export function ImagePreview({ src, alt }: ImagePreviewProps) {
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={src} alt={alt} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
}

View File

@@ -0,0 +1,7 @@
export { ImagePreview } from "./image-preview";
export { VideoPreview } from "./video-preview";
export { AudioPreview } from "./audio-preview";
export { PdfPreview } from "./pdf-preview";
export { TextPreview } from "./text-preview";
export { DefaultPreview } from "./default-preview";
export { FilePreviewRenderer } from "./file-preview-render";

View File

@@ -0,0 +1,54 @@
import { useTranslations } from "next-intl";
import { ScrollArea } from "@/components/ui/scroll-area";
interface PdfPreviewProps {
src: string;
fileName: string;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onLoadError: () => void;
}
export function PdfPreview({ src, fileName, pdfAsBlob, pdfLoadFailed, onLoadError }: PdfPreviewProps) {
const t = useTranslations();
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
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={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={onLoadError}
>
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
style={{ border: "none" }}
onError={onLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,40 @@
import { useTranslations } from "next-intl";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getFileExtension } from "@/utils/file-types";
interface TextPreviewProps {
content: string | null;
fileName: string;
isLoading?: boolean;
}
export function TextPreview({ content, fileName, isLoading }: TextPreviewProps) {
const t = useTranslations();
const extension = getFileExtension(fileName);
if (isLoading || !content) {
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
<div className="flex items-center justify-center h-32">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<p className="text-sm text-muted-foreground">{t("filePreview.loading")}</p>
</div>
</div>
</div>
</ScrollArea>
);
}
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words overflow-x-auto">
<code className={`language-${extension || "text"}`}>{content}</code>
</pre>
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,20 @@
import { useTranslations } from "next-intl";
interface VideoPreviewProps {
src: string;
}
export function VideoPreview({ src }: VideoPreviewProps) {
const t = useTranslations();
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={src} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getDownloadUrl } from "@/http/endpoints";
import { getFileExtension, getFileType, type FileType } from "@/utils/file-types";
interface FilePreviewState {
previewUrl: string | null;
videoBlob: string | null;
textContent: string | null;
downloadUrl: string | null;
isLoading: boolean;
isLoadingPreview: boolean;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
}
interface UseFilePreviewProps {
file: {
name: string;
objectName: string;
type?: string;
};
isOpen: boolean;
}
export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
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 fileType: FileType = getFileType(file.name);
// Reset state when file changes or modal opens
useEffect(() => {
if (isOpen && file.objectName && !state.isLoadingPreview) {
resetState();
loadPreview();
}
}, [file.objectName, isOpen]);
// Cleanup blob URLs
useEffect(() => {
return () => {
cleanupBlobUrls();
};
}, [state.previewUrl, state.videoBlob]);
// Cleanup when modal closes
useEffect(() => {
if (!isOpen) {
cleanupBlobUrls();
}
}, [isOpen]);
const resetState = () => {
setState((prev) => ({
...prev,
previewUrl: null,
videoBlob: null,
textContent: null,
downloadUrl: null,
pdfAsBlob: false,
pdfLoadFailed: false,
isLoading: true,
}));
};
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;
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,
}));
}
};
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 {
// 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) {
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 }));
if (state.downloadUrl) {
setTimeout(() => {
loadPdfPreview(state.downloadUrl!);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = state.downloadUrl;
if (!downloadUrlToUse) {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const link = document.createElement("a");
link.href = downloadUrlToUse;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
return {
...state,
fileType,
handleDownload,
handlePdfLoadError,
};
}

View File

@@ -1,5 +1,30 @@
import {
Icon,
IconApi,
IconAtom,
IconBook,
IconBrandCss3,
IconBrandDocker,
IconBrandGit,
IconBrandGolang,
IconBrandHtml5,
IconBrandJavascript,
IconBrandKotlin,
IconBrandNpm,
IconBrandPhp,
IconBrandPython,
IconBrandReact,
IconBrandRust,
IconBrandSass,
IconBrandSwift,
IconBrandTypescript,
IconBrandVue,
IconBrandYarn,
IconBug,
IconCloud,
IconCode,
IconDatabase,
IconDeviceDesktop,
IconFile,
IconFileCode,
IconFileDescription,
@@ -8,8 +33,16 @@ import {
IconFileText,
IconFileTypePdf,
IconFileZip,
IconKey,
IconLock,
IconMarkdown,
IconMath,
IconPalette,
IconPhoto,
IconPresentation,
IconSettings,
IconTerminal,
IconTool,
IconVideo,
} from "@tabler/icons-react";
@@ -20,56 +53,452 @@ interface FileIconMapping {
}
const fileIcons: FileIconMapping[] = [
// Images
{
extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"],
extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "ico", "heic", "avif"],
icon: IconPhoto,
color: "text-blue-500",
},
// Documents
{
extensions: ["pdf"],
icon: IconFileTypePdf,
color: "text-red-500",
},
{
extensions: ["doc", "docx"],
extensions: ["doc", "docx", "odt", "rtf"],
icon: IconFileText,
color: "text-blue-600",
},
{
extensions: ["xls", "xlsx", "csv"],
extensions: ["xls", "xlsx", "ods", "csv"],
icon: IconFileSpreadsheet,
color: "text-green-600",
},
{
extensions: ["ppt", "pptx"],
extensions: ["ppt", "pptx", "odp"],
icon: IconPresentation,
color: "text-orange-500",
},
// Media
{
extensions: ["mp3", "wav", "ogg", "m4a"],
extensions: ["mp3", "wav", "ogg", "m4a", "aac", "flac", "wma", "opus"],
icon: IconFileMusic,
color: "text-purple-500",
},
{
extensions: ["mp4", "avi", "mov", "wmv", "mkv"],
extensions: ["mp4", "avi", "mov", "wmv", "mkv", "webm", "flv", "m4v", "3gp"],
icon: IconVideo,
color: "text-pink-500",
},
// Archives
{
extensions: ["zip", "rar", "7z", "tar", "gz"],
extensions: ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "lz", "cab", "deb", "rpm"],
icon: IconFileZip,
color: "text-yellow-600",
},
// JavaScript/TypeScript
{
extensions: ["html", "css", "js", "ts", "jsx", "tsx", "json", "xml"],
icon: IconFileCode,
extensions: ["js", "mjs", "cjs"],
icon: IconBrandJavascript,
color: "text-yellow-500",
},
{
extensions: ["ts", "tsx"],
icon: IconBrandTypescript,
color: "text-blue-600",
},
{
extensions: ["jsx"],
icon: IconBrandReact,
color: "text-cyan-500",
},
{
extensions: ["vue"],
icon: IconBrandVue,
color: "text-green-500",
},
// Web Technologies
{
extensions: ["html", "htm", "xhtml"],
icon: IconBrandHtml5,
color: "text-orange-600",
},
{
extensions: ["css"],
icon: IconBrandCss3,
color: "text-blue-600",
},
{
extensions: ["scss", "sass"],
icon: IconBrandSass,
color: "text-pink-600",
},
{
extensions: ["less", "stylus"],
icon: IconPalette,
color: "text-purple-600",
},
// Programming Languages
{
extensions: ["py", "pyw", "pyc", "pyo", "pyd"],
icon: IconBrandPython,
color: "text-yellow-600",
},
{
extensions: ["php", "phtml"],
icon: IconBrandPhp,
color: "text-purple-700",
},
{
extensions: ["go"],
icon: IconBrandGolang,
color: "text-cyan-600",
},
{
extensions: ["rs"],
icon: IconBrandRust,
color: "text-orange-700",
},
{
extensions: ["swift"],
icon: IconBrandSwift,
color: "text-orange-500",
},
{
extensions: ["kt", "kts"],
icon: IconBrandKotlin,
color: "text-purple-600",
},
{
extensions: ["java", "class", "jar"],
icon: IconCode,
color: "text-red-600",
},
{
extensions: ["c", "h"],
icon: IconCode,
color: "text-blue-700",
},
{
extensions: ["cpp", "cxx", "cc", "hpp", "hxx"],
icon: IconCode,
color: "text-blue-800",
},
{
extensions: ["cs"],
icon: IconCode,
color: "text-purple-700",
},
{
extensions: ["rb", "rbw", "rake"],
icon: IconCode,
color: "text-red-500",
},
{
extensions: ["scala", "sc"],
icon: IconCode,
color: "text-red-700",
},
{
extensions: ["clj", "cljs", "cljc", "edn"],
icon: IconCode,
color: "text-green-700",
},
{
extensions: ["hs", "lhs"],
icon: IconCode,
color: "text-purple-800",
},
{
extensions: ["elm"],
icon: IconCode,
color: "text-blue-700",
},
{
extensions: ["dart"],
icon: IconCode,
color: "text-blue-600",
},
{
extensions: ["lua"],
icon: IconCode,
color: "text-blue-800",
},
{
extensions: ["r", "rmd"],
icon: IconMath,
color: "text-blue-700",
},
{
extensions: ["matlab", "m"],
icon: IconMath,
color: "text-orange-600",
},
{
extensions: ["julia", "jl"],
icon: IconMath,
color: "text-purple-600",
},
// Shell Scripts
{
extensions: ["sh", "bash", "zsh", "fish"],
icon: IconTerminal,
color: "text-green-600",
},
{
extensions: ["ps1", "psm1", "psd1"],
icon: IconTerminal,
color: "text-blue-700",
},
{
extensions: ["bat", "cmd"],
icon: IconTerminal,
color: "text-gray-600",
},
// Database
{
extensions: ["sql", "mysql", "pgsql", "sqlite", "db"],
icon: IconDatabase,
color: "text-blue-700",
},
// Configuration Files
{
extensions: ["json", "json5"],
icon: IconCode,
color: "text-yellow-700",
},
{
extensions: ["yaml", "yml"],
icon: IconSettings,
color: "text-purple-600",
},
{
extensions: ["toml"],
icon: IconSettings,
color: "text-orange-600",
},
{
extensions: ["xml", "xsd", "xsl", "xslt"],
icon: IconCode,
color: "text-orange-700",
},
{
extensions: ["ini", "cfg", "conf", "config"],
icon: IconSettings,
color: "text-gray-600",
},
{
extensions: ["txt", "md", "rtf"],
extensions: ["env", "dotenv"],
icon: IconKey,
color: "text-green-700",
},
{
extensions: ["properties"],
icon: IconSettings,
color: "text-blue-600",
},
// Docker & DevOps
{
extensions: ["dockerfile", "containerfile"],
icon: IconBrandDocker,
color: "text-blue-600",
},
{
extensions: ["tf", "tfvars", "hcl"],
icon: IconCloud,
color: "text-purple-600",
},
{
extensions: ["k8s", "kubernetes"],
icon: IconCloud,
color: "text-blue-700",
},
{
extensions: ["ansible", "playbook"],
icon: IconTool,
color: "text-red-600",
},
// Package Managers
{
extensions: ["package"],
icon: IconBrandNpm,
color: "text-red-600",
},
{
extensions: ["yarn"],
icon: IconBrandYarn,
color: "text-blue-600",
},
{
extensions: ["cargo"],
icon: IconBrandRust,
color: "text-orange-700",
},
{
extensions: ["gemfile"],
icon: IconCode,
color: "text-red-500",
},
{
extensions: ["composer"],
icon: IconBrandPhp,
color: "text-purple-700",
},
{
extensions: ["requirements", "pipfile", "poetry"],
icon: IconBrandPython,
color: "text-yellow-600",
},
{
extensions: ["gradle", "build.gradle"],
icon: IconTool,
color: "text-green-700",
},
{
extensions: ["pom"],
icon: IconCode,
color: "text-orange-600",
},
{
extensions: ["makefile", "cmake"],
icon: IconTool,
color: "text-blue-700",
},
// Git
{
extensions: ["gitignore", "gitattributes", "gitmodules", "gitconfig"],
icon: IconBrandGit,
color: "text-orange-600",
},
// Documentation
{
extensions: ["md", "markdown"],
icon: IconMarkdown,
color: "text-gray-700",
},
{
extensions: ["rst", "txt"],
icon: IconFileDescription,
color: "text-gray-500",
},
{
extensions: ["adoc", "asciidoc"],
icon: IconBook,
color: "text-blue-600",
},
{
extensions: ["tex", "latex"],
icon: IconMath,
color: "text-green-700",
},
{
extensions: ["log"],
icon: IconBug,
color: "text-yellow-600",
},
// Templates
{
extensions: ["hbs", "handlebars", "mustache"],
icon: IconCode,
color: "text-orange-600",
},
{
extensions: ["twig"],
icon: IconCode,
color: "text-green-600",
},
{
extensions: ["liquid"],
icon: IconCode,
color: "text-blue-600",
},
{
extensions: ["ejs", "pug", "jade"],
icon: IconCode,
color: "text-brown-600",
},
// Data Formats
{
extensions: ["graphql", "gql"],
icon: IconApi,
color: "text-pink-600",
},
{
extensions: ["proto", "protobuf"],
icon: IconApi,
color: "text-blue-700",
},
// Security & Certificates
{
extensions: ["pem", "crt", "cer", "key", "p12", "pfx"],
icon: IconLock,
color: "text-green-800",
},
// Web Assembly
{
extensions: ["wasm", "wat"],
icon: IconAtom,
color: "text-purple-700",
},
// Shaders
{
extensions: ["glsl", "hlsl", "vert", "frag", "geom"],
icon: IconDeviceDesktop,
color: "text-cyan-700",
},
// Specialized
{
extensions: ["vim", "vimrc"],
icon: IconCode,
color: "text-green-800",
},
{
extensions: ["eslintrc", "prettierrc", "babelrc"],
icon: IconSettings,
color: "text-yellow-700",
},
{
extensions: ["tsconfig", "jsconfig"],
icon: IconSettings,
color: "text-blue-700",
},
{
extensions: ["webpack", "rollup", "vite"],
icon: IconTool,
color: "text-cyan-600",
},
{
extensions: ["lock", "sum"],
icon: IconLock,
color: "text-gray-600",
},
// Fallback for general text/code files
{
extensions: ["svelte", "astro", "erb", "haml", "slim"],
icon: IconFileCode,
color: "text-gray-600",
},
];
export function getFileIcon(filename: string): { icon: Icon; color: string } {

View File

@@ -0,0 +1,374 @@
export type FileType = "pdf" | "image" | "audio" | "video" | "text" | "other";
export function getFileType(fileName: string): FileType {
const extension = fileName.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";
}
const textExtensions = [
// Data formats
"json",
"json5",
"jsonp",
"txt",
"csv",
"xml",
"svg",
"toml",
"yaml",
"yml",
"ini",
"conf",
"config",
"env",
"properties",
// Documentation
"md",
"markdown",
"adoc",
"asciidoc",
"rst",
"textile",
"wiki",
"log",
// Web technologies
"html",
"htm",
"xhtml",
"css",
"scss",
"sass",
"less",
"stylus",
// JavaScript ecosystem
"js",
"jsx",
"ts",
"tsx",
"mjs",
"cjs",
"vue",
"svelte",
"coffee",
"coffeescript",
// Programming languages
"php",
"py",
"pyw",
"rb",
"java",
"kt",
"kts",
"scala",
"clj",
"cljs",
"cljc",
"hs",
"elm",
"f#",
"fs",
"fsx",
"vb",
"vba",
"c",
"cpp",
"cxx",
"cc",
"h",
"hpp",
"hxx",
"cs",
"go",
"rs",
"swift",
"dart",
"r",
"rmd",
"pl",
"pm",
// Shell scripts
"sh",
"bash",
"zsh",
"fish",
"ps1",
"bat",
"cmd",
// Database
"sql",
"plsql",
"psql",
"mysql",
"sqlite",
// Configuration files
"dockerfile",
"containerfile",
"gitignore",
"gitattributes",
"gitmodules",
"gitconfig",
"editorconfig",
"eslintrc",
"prettierrc",
"stylelintrc",
"babelrc",
"browserslistrc",
"tsconfig",
"jsconfig",
"webpack",
"rollup",
"vite",
"astro",
// Package managers
"package",
"composer",
"gemfile",
"podfile",
"pipfile",
"poetry",
"pyproject",
"requirements",
"cargo",
"go.mod",
"go.sum",
"sbt",
"build.gradle",
"build.sbt",
"pom",
"build",
// Build tools
"makefile",
"cmake",
"rakefile",
"gradle",
"gulpfile",
"gruntfile",
"justfile",
// Templates
"hbs",
"handlebars",
"mustache",
"twig",
"jinja",
"jinja2",
"liquid",
"ejs",
"pug",
"jade",
// Data serialization
"proto",
"protobuf",
"avro",
"thrift",
"graphql",
"gql",
// Markup & styling
"tex",
"latex",
"bibtex",
"rtf",
"org",
"pod",
// Specialized formats
"vim",
"vimrc",
"tmux",
"nginx",
"apache",
"htaccess",
"robots",
"sitemap",
"webmanifest",
"lock",
"sum",
"mod",
"workspace",
"solution",
"sln",
"csproj",
"vcxproj",
"xcodeproj",
// Additional programming languages
"lua",
"rb",
"php",
"asp",
"aspx",
"jsp",
"erb",
"haml",
"slim",
"perl",
"awk",
"sed",
"tcl",
"groovy",
"scala",
"rust",
"zig",
"nim",
"crystal",
"julia",
"matlab",
"octave",
"wolfram",
"mathematica",
"sage",
"maxima",
"fortran",
"cobol",
"ada",
"pascal",
"delphi",
"basic",
"vb6",
"assembly",
"asm",
"s",
"nasm",
"gas",
"lisp",
"scheme",
"racket",
"clojure",
"erlang",
"elixir",
"haskell",
"ocaml",
"fsharp",
"prolog",
"mercury",
"curry",
"clean",
"idris",
"agda",
"coq",
"lean",
"smalltalk",
"forth",
"factor",
"postscript",
"tcl",
"tk",
"expect",
"applescript",
"powershell",
"autohotkey",
"ahk",
"autoit",
"nsis",
// Web assembly and low level
"wasm",
"wat",
"wast",
"wit",
"wai",
// Shaders
"glsl",
"hlsl",
"cg",
"fx",
"fxh",
"vsh",
"fsh",
"vert",
"frag",
"geom",
"tesc",
"tese",
"comp",
// Game development
"gdscript",
"gd",
"cs",
"boo",
"unityscript",
"mel",
"maxscript",
"haxe",
"as",
"actionscript",
// DevOps & Infrastructure
"tf",
"tfvars",
"hcl",
"nomad",
"consul",
"vault",
"packer",
"ansible",
"puppet",
"chef",
"salt",
"k8s",
"kubernetes",
"helm",
"kustomize",
"skaffold",
"tilt",
"buildkite",
"circleci",
"travis",
"jenkins",
"github",
"gitlab",
"bitbucket",
"azure",
"aws",
"gcp",
"terraform",
"cloudformation",
// Documentation generators
"jsdoc",
"javadoc",
"godoc",
"rustdoc",
"sphinx",
"mkdocs",
"gitbook",
"jekyll",
"hugo",
"gatsby",
];
if (textExtensions.includes(extension || "")) {
return "text";
}
return "other";
}
export function getFileExtension(fileName: string): string {
return fileName.split(".").pop()?.toLowerCase() || "";
}