diff --git a/src/components/AttachmentPreview.test.tsx b/src/components/AttachmentPreview.test.tsx new file mode 100644 index 0000000..53ac791 --- /dev/null +++ b/src/components/AttachmentPreview.test.tsx @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { AttachmentPreview } from "./AttachmentPreview"; + +describe("AttachmentPreview", () => { + const mockOnClose = vi.fn(); + const mockOnDownload = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render image preview", () => { + const imageFile = new File(["image data"], "photo.jpg", { + type: "image/jpeg", + }); + const imageUrl = URL.createObjectURL(imageFile); + + render( + + + + ); + + expect(screen.getByRole("img")).toBeInTheDocument(); + expect(screen.getByRole("img")).toHaveAttribute("src", imageUrl); + expect(screen.getByText("photo.jpg")).toBeInTheDocument(); + }); + + it("should render PDF preview", () => { + const pdfFile = new File(["pdf data"], "document.pdf", { + type: "application/pdf", + }); + const pdfUrl = URL.createObjectURL(pdfFile); + + render( + + + + ); + + expect(screen.getByTitle("PDF Preview")).toBeInTheDocument(); + expect(screen.getByText("document.pdf")).toBeInTheDocument(); + }); + + it("should show unsupported preview message for other types", () => { + const textFile = new File(["text data"], "document.txt", { + type: "text/plain", + }); + const textUrl = URL.createObjectURL(textFile); + + render( + + + + ); + + expect(screen.getByText(/preview not available/i)).toBeInTheDocument(); + }); + + it("should close modal when close button clicked", () => { + const imageFile = new File(["image data"], "photo.jpg", { + type: "image/jpeg", + }); + const imageUrl = URL.createObjectURL(imageFile); + + render( + + + + ); + + const closeButton = screen.getByRole("button", { name: /close preview/i }); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it("should close modal when ESC key pressed", () => { + const imageFile = new File(["image data"], "photo.jpg", { + type: "image/jpeg", + }); + const imageUrl = URL.createObjectURL(imageFile); + + render( + + + + ); + + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it("should trigger download when download button clicked", () => { + const imageFile = new File(["image data"], "photo.jpg", { + type: "image/jpeg", + }); + const imageUrl = URL.createObjectURL(imageFile); + + render( + + + + ); + + const downloadButton = screen.getByRole("button", { + name: /download file/i, + }); + fireEvent.click(downloadButton); + + expect(mockOnDownload).toHaveBeenCalledTimes(1); + }); + + it("should support zoom controls for images", () => { + const imageFile = new File(["image data"], "photo.jpg", { + type: "image/jpeg", + }); + const imageUrl = URL.createObjectURL(imageFile); + + render( + + + + ); + + const zoomInButton = screen.getByRole("button", { name: /zoom in/i }); + const zoomOutButton = screen.getByRole("button", { name: /zoom out/i }); + + expect(zoomInButton).toBeInTheDocument(); + expect(zoomOutButton).toBeInTheDocument(); + }); + + it("should display file size", () => { + const imageFile = new File(["x".repeat(2048)], "photo.jpg", { + type: "image/jpeg", + }); + const imageUrl = URL.createObjectURL(imageFile); + + render( + + + + ); + + expect(screen.getByText(/2\.0 KB/i)).toBeInTheDocument(); + }); + + it("should be keyboard accessible", () => { + const imageFile = new File(["image data"], "photo.jpg", { + type: "image/jpeg", + }); + const imageUrl = URL.createObjectURL(imageFile); + + render( + + + + ); + + const closeButton = screen.getByRole("button", { name: /close preview/i }); + const downloadButton = screen.getByRole("button", { + name: /download file/i, + }); + + expect(closeButton).toHaveAttribute("type", "button"); + expect(downloadButton).toHaveAttribute("type", "button"); + }); +}); diff --git a/src/components/AttachmentPreview.tsx b/src/components/AttachmentPreview.tsx new file mode 100644 index 0000000..6d45eec --- /dev/null +++ b/src/components/AttachmentPreview.tsx @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { useState } from "react"; +import { Trans } from "@lingui/macro"; +import { + XMarkIcon, + ArrowDownTrayIcon, + MagnifyingGlassPlusIcon, + MagnifyingGlassMinusIcon, +} from "@heroicons/react/24/outline"; +import { Button } from "./button"; +import { Dialog, DialogActions, DialogBody, DialogTitle } from "./dialog"; + +/** + * AttachmentPreview Component Props + */ +export interface AttachmentPreviewProps { + /** File to preview */ + file: File; + /** Object URL for the file */ + fileUrl: string; + /** Callback when modal is closed */ + onClose: () => void; + /** Callback when download button is clicked */ + onDownload: () => void; +} + +/** + * Check if file is an image + */ +function isImage(mimeType: string): boolean { + return mimeType.startsWith("image/"); +} + +/** + * Check if file is a PDF + */ +function isPDF(mimeType: string): boolean { + return mimeType === "application/pdf"; +} + +/** + * Format file size in human-readable format + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + + const units = ["B", "KB", "MB", "GB"]; + const k = 1024; + const i = Math.min( + Math.floor(Math.log(bytes) / Math.log(k)), + units.length - 1 + ); + + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${units[i]}`; +} + +/** + * AttachmentPreview Component + * + * Modal dialog for previewing image and PDF attachments. + * Supports zoom controls for images and inline PDF display. + * + * Features: + * - Image preview with zoom controls + * - PDF preview (iframe) + * - Download button + * - Close button and ESC key support + * - Click backdrop to close + * - Keyboard accessible + * + * @example + * ```tsx + * setShowPreview(false)} + * onDownload={() => downloadFile(file)} + * /> + * ``` + */ +export function AttachmentPreview({ + file, + fileUrl, + onClose, + onDownload, +}: AttachmentPreviewProps) { + const [zoomLevel, setZoomLevel] = useState(100); + const canPreview = isImage(file.type) || isPDF(file.type); + + /** + * Zoom in (max 200%) + */ + const handleZoomIn = () => { + setZoomLevel((prev) => Math.min(prev + 25, 200)); + }; + + /** + * Zoom out (min 50%) + */ + const handleZoomOut = () => { + setZoomLevel((prev) => Math.max(prev - 25, 50)); + }; + + return ( + + +
+
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
+
+ + +
+
+
+ + + {/* Image Preview */} + {isImage(file.type) && ( +
+ {/* Zoom Controls */} +
+ + + {zoomLevel}% + + +
+ + {/* Image */} +
+ {file.name} +
+
+ )} + + {/* PDF Preview */} + {isPDF(file.type) && ( +
+