From 9486afbd94b5bf534e1eae6e553bfe2002d2e952 Mon Sep 17 00:00:00 2001 From: NteinPrecious Date: Tue, 2 Jun 2026 01:47:31 +0100 Subject: [PATCH] feat: add StatusBadge, ConfirmationModal, LoadingSkeleton, and FilePreviewPanel - Closes #518 - Closes #519 - Closes #520 - Closes #521 --- .../confirmation-modal/ConfirmationModal.tsx | 115 ++++++++++++++++++ .../file-preview/FilePreviewPanel.tsx | 107 ++++++++++++++++ .../loading-skeleton/LoadingSkeleton.tsx | 81 ++++++++++++ .../components/status-badge/StatusBadge.tsx | 42 +++++++ 4 files changed, 345 insertions(+) create mode 100644 frontend/module/components/confirmation-modal/ConfirmationModal.tsx create mode 100644 frontend/module/components/file-preview/FilePreviewPanel.tsx create mode 100644 frontend/module/components/loading-skeleton/LoadingSkeleton.tsx create mode 100644 frontend/module/components/status-badge/StatusBadge.tsx diff --git a/frontend/module/components/confirmation-modal/ConfirmationModal.tsx b/frontend/module/components/confirmation-modal/ConfirmationModal.tsx new file mode 100644 index 0000000..3e7c81d --- /dev/null +++ b/frontend/module/components/confirmation-modal/ConfirmationModal.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface ConfirmationModalProps { + isOpen: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + isLoading?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmationModal({ + isOpen, + title, + message, + confirmLabel = "Delete", + cancelLabel = "Cancel", + isLoading = false, + onConfirm, + onCancel, +}: ConfirmationModalProps) { + const cancelRef = useRef(null); + const confirmRef = useRef(null); + const prevFocusRef = useRef(null); + + useEffect(() => { + if (isOpen) { + prevFocusRef.current = document.activeElement; + cancelRef.current?.focus(); + } else { + (prevFocusRef.current as HTMLElement | null)?.focus(); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + if (e.key === "Tab") { + const focusable = [cancelRef.current, confirmRef.current].filter(Boolean); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onCancel]); + + if (!isOpen) return null; + + return ( +
+ + ); +} diff --git a/frontend/module/components/file-preview/FilePreviewPanel.tsx b/frontend/module/components/file-preview/FilePreviewPanel.tsx new file mode 100644 index 0000000..2aeeea7 --- /dev/null +++ b/frontend/module/components/file-preview/FilePreviewPanel.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface FilePreviewPanelProps { + documentId: string; + mimeType: string; +} + +const SUPPORTED_IMAGE_TYPES = ["image/png", "image/jpeg"]; +const SUPPORTED_TYPES = [...SUPPORTED_IMAGE_TYPES, "application/pdf"]; + +export default function FilePreviewPanel({ + documentId, + mimeType, +}: FilePreviewPanelProps) { + const [fileUrl, setFileUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const isSupported = SUPPORTED_TYPES.includes(mimeType); + const isImage = SUPPORTED_IMAGE_TYPES.includes(mimeType); + const isPdf = mimeType === "application/pdf"; + + const downloadUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/module/documents/${documentId}/download`; + + useEffect(() => { + if (!isSupported) { + setLoading(false); + return; + } + + (async () => { + try { + const res = await fetch(downloadUrl, { + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token") ?? ""}`, + }, + }); + if (!res.ok) throw new Error("Failed to fetch file."); + const blob = await res.blob(); + setFileUrl(URL.createObjectURL(blob)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load file."); + } finally { + setLoading(false); + } + })(); + + return () => { + if (fileUrl) URL.revokeObjectURL(fileUrl); + }; + }, [documentId, downloadUrl, isSupported]); + + function handleDownload() { + const a = document.createElement("a"); + a.href = fileUrl ?? downloadUrl; + a.download = `document-${documentId}`; + a.click(); + } + + return ( +
+
+ {loading ? ( +
+
+
+ ) : error ? ( +
+

{error}

+
+ ) : !isSupported ? ( +
+

+ This file type cannot be previewed. +

+
+ ) : isImage && fileUrl ? ( + Document preview + ) : isPdf && fileUrl ? ( +