Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions frontend/module/components/confirmation-modal/ConfirmationModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const prevFocusRef = useRef<Element | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
>
<div
className="absolute inset-0 bg-black/50"
onClick={onCancel}
aria-hidden="true"
/>

<div className="relative z-10 mx-4 w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2
id="confirm-title"
className="text-lg font-semibold text-gray-900"
>
{title}
</h2>
<p id="confirm-message" className="mt-2 text-sm text-gray-600">
{message}
</p>

<div className="mt-6 flex justify-end gap-3">
<button
ref={cancelRef}
onClick={onCancel}
disabled={isLoading}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{cancelLabel}
</button>
<button
ref={confirmRef}
onClick={onConfirm}
disabled={isLoading}
className="flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{isLoading && (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
)}
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
107 changes: 107 additions & 0 deletions frontend/module/components/file-preview/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="rounded-xl border border-gray-200 bg-white">
<div className="max-h-[600px] overflow-auto">
{loading ? (
<div className="flex h-48 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
) : error ? (
<div className="flex h-48 flex-col items-center justify-center gap-3 text-center">
<p className="text-sm text-red-600">{error}</p>
</div>
) : !isSupported ? (
<div className="flex h-48 flex-col items-center justify-center gap-3 text-center p-6">
<p className="text-sm text-gray-600">
This file type cannot be previewed.
</p>
</div>
) : isImage && fileUrl ? (
<img
src={fileUrl}
alt="Document preview"
className="mx-auto max-w-full object-contain"
/>
) : isPdf && fileUrl ? (
<iframe
src={fileUrl}
title="Document preview"
className="h-[600px] w-full border-0"
/>
) : null}
</div>

<div className="flex justify-end border-t border-gray-200 p-3">
<button
onClick={handleDownload}
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</button>
</div>
</div>
);
}
81 changes: 81 additions & 0 deletions frontend/module/components/loading-skeleton/LoadingSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

interface SkeletonProps {
width?: string;
height?: string;
borderRadius?: string;
className?: string;
}

export function Skeleton({
width = "100%",
height = "1rem",
borderRadius = "0.375rem",
className = "",
}: SkeletonProps) {
return (
<div
aria-hidden="true"
style={{ width, height, borderRadius }}
className={`skeleton-shimmer bg-gray-200 ${className}`}
>
<style jsx>{`
@media (prefers-reduced-motion: no-preference) {
.skeleton-shimmer {
background: linear-gradient(
90deg,
#e5e7eb 25%,
#f3f4f6 50%,
#e5e7eb 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
);
}

export function DocumentCardSkeleton() {
return (
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
<div className="flex-1 space-y-2">
<Skeleton width="60%" height="1rem" />
<Skeleton width="40%" height="0.75rem" />
</div>
<Skeleton width="4rem" height="1.5rem" borderRadius="9999px" />
</div>
);
}

export function StatCardSkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-5">
<Skeleton width="6rem" height="0.875rem" />
<Skeleton width="4rem" height="2rem" className="mt-2" />
</div>
);
}

interface TableRowSkeletonProps {
columnCount?: number;
}

export function TableRowSkeleton({ columnCount = 4 }: TableRowSkeletonProps) {
return (
<tr aria-hidden="true">
{Array.from({ length: columnCount }).map((_, i) => (
<td key={i} className="px-4 py-3">
<Skeleton />
</td>
))}
</tr>
);
}

export default Skeleton;
42 changes: 42 additions & 0 deletions frontend/module/components/status-badge/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

type StatusSize = "sm" | "md";

interface StatusBadgeProps {
status: string;
size?: StatusSize;
}

const STATUS_STYLES: Record<string, string> = {
PENDING: "bg-gray-100 text-gray-700",
ANALYZING: "bg-blue-100 text-blue-700",
VERIFIED: "bg-green-100 text-green-700",
FLAGGED: "bg-orange-100 text-orange-700",
REJECTED: "bg-red-100 text-red-700",
OPEN: "bg-blue-100 text-blue-700",
UNDER_REVIEW: "bg-amber-100 text-amber-700",
RESOLVED: "bg-green-100 text-green-700",
};

function toTitleCase(str: string): string {
return str
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}

export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
const style = STATUS_STYLES[status] ?? "bg-gray-100 text-gray-600";
const sizeClass = size === "sm" ? "px-1.5 py-0.5 text-xs" : "px-2.5 py-1 text-sm";
const label = toTitleCase(status);

return (
<span
role="status"
aria-label={label}
className={`inline-flex items-center rounded-full font-medium ${style} ${sizeClass}`}
>
{label}
</span>
);
}
Loading