312 lines
9.8 KiB
TypeScript
312 lines
9.8 KiB
TypeScript
// ─────────────────────────────────────────────────────────────
|
|
// Reusable UI Components
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
import React from 'react';
|
|
import { CheckStatus, SimilarityLevel } from './lib/types';
|
|
|
|
// ── InfoRow ───────────────────────────────────────────────────
|
|
|
|
interface InfoRowProps {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
export const InfoRow: React.FC<InfoRowProps> = ({ label, value, icon }) => (
|
|
<div className="flex items-start justify-between gap-4 py-3 border-b border-slate-100 last:border-0">
|
|
<span className="flex items-center gap-2 text-sm font-medium text-slate-500 min-w-35">
|
|
{icon && <span className="text-slate-400">{icon}</span>}
|
|
{label}
|
|
</span>
|
|
<span className="text-sm text-slate-800 text-right font-medium">
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
|
|
// ── SectionCard ───────────────────────────────────────────────
|
|
|
|
interface SectionCardProps {
|
|
title: string;
|
|
icon?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
accent?: 'blue' | 'green' | 'red' | 'amber' | 'violet';
|
|
}
|
|
|
|
const accentMap: Record<string, string> = {
|
|
blue: 'border-t-blue-500',
|
|
green: 'border-t-emerald-500',
|
|
red: 'border-t-red-500',
|
|
amber: 'border-t-amber-500',
|
|
violet: 'border-t-violet-500',
|
|
};
|
|
|
|
export const SectionCard: React.FC<SectionCardProps> = ({
|
|
title,
|
|
icon,
|
|
children,
|
|
className = '',
|
|
accent = 'blue',
|
|
}) => (
|
|
<div
|
|
className={`w-full bg-white rounded-2xl shadow-sm border border-slate-100 border-t-4 ${accentMap[accent]} overflow-hidden ${className}`}
|
|
>
|
|
<div className="px-6 py-4 border-b border-slate-50">
|
|
<h2 className="text-sm font-semibold text-slate-700 uppercase tracking-widest flex items-center gap-2">
|
|
{icon && <span>{icon}</span>}
|
|
{title}
|
|
</h2>
|
|
</div>
|
|
<div className="px-6 py-4">{children}</div>
|
|
</div>
|
|
);
|
|
|
|
// ── StatusBadge ───────────────────────────────────────────────
|
|
|
|
interface StatusBadgeProps {
|
|
status: CheckStatus;
|
|
}
|
|
|
|
const statusStyles: Record<CheckStatus, string> = {
|
|
pending: 'bg-slate-100 text-slate-600',
|
|
processing: 'bg-blue-100 text-blue-700',
|
|
completed: 'bg-emerald-100 text-emerald-700',
|
|
failed: 'bg-red-100 text-red-700',
|
|
};
|
|
|
|
const statusDots: Record<CheckStatus, string> = {
|
|
pending: 'bg-slate-400',
|
|
processing: 'bg-blue-500 animate-pulse',
|
|
completed: 'bg-emerald-500',
|
|
failed: 'bg-red-500',
|
|
};
|
|
|
|
const statusLabels: Record<CheckStatus, string> = {
|
|
pending: 'Pending',
|
|
processing: 'Processing',
|
|
completed: 'Completed',
|
|
failed: 'Failed',
|
|
};
|
|
|
|
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => (
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold ${statusStyles[status]}`}
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${statusDots[status]}`} />
|
|
{statusLabels[status]}
|
|
</span>
|
|
);
|
|
|
|
// ── SimilarityMeter ───────────────────────────────────────────
|
|
|
|
interface SimilarityMeterProps {
|
|
percentage: number;
|
|
level: SimilarityLevel;
|
|
}
|
|
|
|
const levelColors: Record<SimilarityLevel, string> = {
|
|
low: 'from-emerald-400 to-emerald-500',
|
|
medium: 'from-amber-400 to-amber-500',
|
|
high: 'from-red-400 to-red-500',
|
|
};
|
|
|
|
const levelTextColors: Record<SimilarityLevel, string> = {
|
|
low: 'text-emerald-600',
|
|
medium: 'text-amber-600',
|
|
high: 'text-red-600',
|
|
};
|
|
|
|
const levelBgColors: Record<SimilarityLevel, string> = {
|
|
low: 'bg-emerald-50 border-emerald-200',
|
|
medium: 'bg-amber-50 border-amber-200',
|
|
high: 'bg-red-50 border-red-200',
|
|
};
|
|
|
|
const levelLabels: Record<SimilarityLevel, string> = {
|
|
low: 'Low Similarity — Likely Original',
|
|
medium: 'Medium Similarity — Review Recommended',
|
|
high: 'High Similarity — Action Required',
|
|
};
|
|
|
|
export const SimilarityMeter: React.FC<SimilarityMeterProps> = ({
|
|
percentage,
|
|
level,
|
|
}) => (
|
|
<div className="space-y-3">
|
|
<div className="flex items-end justify-between">
|
|
<span className="text-4xl font-bold tabular-nums text-slate-800">
|
|
{percentage}
|
|
<span className="text-xl text-slate-400 font-medium">%</span>
|
|
</span>
|
|
<span
|
|
className={`text-xs font-semibold uppercase tracking-wide ${levelTextColors[level]}`}
|
|
>
|
|
{level} risk
|
|
</span>
|
|
</div>
|
|
|
|
{/* Track */}
|
|
<div className="relative h-3 bg-slate-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`absolute inset-y-0 left-0 rounded-full bg-linear-to-r ${levelColors[level]} transition-all duration-700`}
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
{/* Threshold markers */}
|
|
<div className="absolute inset-y-0 left-[30%] w-px bg-slate-300 opacity-60" />
|
|
<div className="absolute inset-y-0 left-[60%] w-px bg-slate-300 opacity-60" />
|
|
</div>
|
|
|
|
<div className="flex text-[10px] text-slate-400 justify-between font-medium">
|
|
<span>0%</span>
|
|
<span>30%</span>
|
|
<span>60%</span>
|
|
<span>100%</span>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex items-center gap-2 p-3 rounded-xl border text-sm ${levelBgColors[level]} ${levelTextColors[level]} font-medium`}
|
|
>
|
|
<span>{levelLabels[level]}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ── Avatar ────────────────────────────────────────────────────
|
|
|
|
interface AvatarProps {
|
|
name: string;
|
|
avatarUrl?: string;
|
|
size?: 'sm' | 'md' | 'lg';
|
|
}
|
|
|
|
const sizeMap = {
|
|
sm: 'w-8 h-8 text-xs',
|
|
md: 'w-10 h-10 text-sm',
|
|
lg: 'w-14 h-14 text-lg',
|
|
};
|
|
|
|
export const Avatar: React.FC<AvatarProps> = ({
|
|
name,
|
|
avatarUrl,
|
|
size = 'md',
|
|
}) => {
|
|
const initials = name
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('')
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
|
|
if (avatarUrl) {
|
|
return (
|
|
<img
|
|
src={avatarUrl}
|
|
alt={name}
|
|
className={`${sizeMap[size]} rounded-full object-cover ring-2 ring-white`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`${sizeMap[size]} rounded-full bg-linear-to-br from-violet-500 to-indigo-600 flex items-center justify-center text-white font-bold ring-2 ring-white`}
|
|
>
|
|
{initials}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── SkeletonLoader ────────────────────────────────────────────
|
|
|
|
export const SkeletonLoader: React.FC = () => (
|
|
<div className="animate-pulse space-y-4">
|
|
{/* Header skeleton */}
|
|
<div className="bg-white rounded-2xl p-6 border border-slate-100">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="w-14 h-14 rounded-full bg-slate-200" />
|
|
<div className="space-y-2 flex-1">
|
|
<div className="h-5 bg-slate-200 rounded w-48" />
|
|
<div className="h-4 bg-slate-100 rounded w-36" />
|
|
</div>
|
|
<div className="h-6 w-24 bg-slate-200 rounded-full" />
|
|
</div>
|
|
</div>
|
|
{/* Cards skeleton */}
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="bg-white rounded-2xl border border-slate-100">
|
|
<div className="px-6 py-4 border-b border-slate-50">
|
|
<div className="h-4 bg-slate-200 rounded w-32" />
|
|
</div>
|
|
<div className="px-6 py-4 space-y-3">
|
|
{[1, 2, 3].map((j) => (
|
|
<div key={j} className="h-4 bg-slate-100 rounded w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// ── ErrorState ────────────────────────────────────────────────
|
|
|
|
interface ErrorStateProps {
|
|
message: string;
|
|
onRetry?: () => void;
|
|
}
|
|
|
|
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center space-y-4">
|
|
<div className="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center">
|
|
<svg
|
|
className="w-8 h-8 text-red-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-slate-700">Failed to load check</p>
|
|
<p className="text-sm text-slate-500 mt-1">{message}</p>
|
|
</div>
|
|
{onRetry && (
|
|
<button
|
|
onClick={onRetry}
|
|
className="px-5 py-2 bg-slate-800 text-white text-sm font-medium rounded-xl hover:bg-slate-700 transition-colors"
|
|
>
|
|
Try again
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ── FileTypeBadge ─────────────────────────────────────────────
|
|
|
|
interface FileTypeBadgeProps {
|
|
extension: string;
|
|
}
|
|
|
|
const extColors: Record<string, string> = {
|
|
PDF: 'bg-red-100 text-red-700',
|
|
DOCX: 'bg-blue-100 text-blue-700',
|
|
DOC: 'bg-blue-100 text-blue-700',
|
|
TXT: 'bg-slate-100 text-slate-700',
|
|
ODT: 'bg-green-100 text-green-700',
|
|
};
|
|
|
|
export const FileTypeBadge: React.FC<FileTypeBadgeProps> = ({ extension }) => (
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-bold tracking-wide ${extColors[extension] ?? 'bg-slate-100 text-slate-600'}`}
|
|
>
|
|
{extension}
|
|
</span>
|
|
);
|