511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
// ─────────────────────────────────────────────────────────────
|
|
// PlagiarismDetailPage — Main Feature Component
|
|
// ─────────────────────────────────────────────────────────────
|
|
'use client';
|
|
import React from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { PlagiarismCheck } from '../lib/types';
|
|
import {
|
|
getFileExtension,
|
|
formatDate,
|
|
formatFileSize,
|
|
formatCurrency,
|
|
} from '../lib/api';
|
|
import { usePlagiarismDetail } from '../lib/useDetail';
|
|
import {
|
|
FileTypeBadge,
|
|
InfoRow,
|
|
SectionCard,
|
|
StatusBadge,
|
|
SimilarityMeter,
|
|
Avatar,
|
|
SkeletonLoader,
|
|
ErrorState,
|
|
} from '..';
|
|
|
|
// ── Icons (inline SVG for zero-dep) ──────────────────────────
|
|
|
|
const IconUser = () => (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
/>
|
|
</svg>
|
|
);
|
|
const IconFile = () => (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
const IconCalendar = () => (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
const IconPayment = () => (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
|
/>
|
|
</svg>
|
|
);
|
|
const IconShield = () => (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
|
/>
|
|
</svg>
|
|
);
|
|
const IconCert = () => (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
|
|
/>
|
|
</svg>
|
|
);
|
|
const IconDownload = () => (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
|
/>
|
|
</svg>
|
|
);
|
|
// const IconBack = () => (
|
|
// <svg
|
|
// className="w-4 h-4"
|
|
// fill="none"
|
|
// stroke="currentColor"
|
|
// viewBox="0 0 24 24"
|
|
// >
|
|
// <path
|
|
// strokeLinecap="round"
|
|
// strokeLinejoin="round"
|
|
// strokeWidth={2}
|
|
// d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
|
// />
|
|
// </svg>
|
|
// );
|
|
const IconSource = () => (
|
|
<svg
|
|
className="w-3.5 h-3.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
// ── Sub-components ────────────────────────────────────────────
|
|
|
|
interface CheckDetailViewProps {
|
|
check: PlagiarismCheck;
|
|
}
|
|
|
|
const CheckHeader: React.FC<CheckDetailViewProps> = ({ check }) => {
|
|
const t = useTranslations('DetailPage');
|
|
|
|
return (
|
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<Avatar
|
|
name={check.sender.name}
|
|
avatarUrl={check.sender.avatarUrl}
|
|
size="lg"
|
|
/>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-slate-900">
|
|
{check.sender.name}
|
|
</h1>
|
|
<p className="text-sm text-slate-500">{check.sender.email}</p>
|
|
<p className="text-xs text-slate-400 mt-1 font-mono">
|
|
{t('id')}: {check.id}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={check.status} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SubmissionInfoCard: React.FC<CheckDetailViewProps> = ({ check }) => {
|
|
const t = useTranslations('DetailPage');
|
|
|
|
return (
|
|
<SectionCard
|
|
title={t('submissionDetails')}
|
|
icon={<IconFile />}
|
|
accent="blue"
|
|
>
|
|
<InfoRow
|
|
label={t('sender')}
|
|
icon={<IconUser />}
|
|
value={
|
|
<span className="flex items-center gap-2 justify-end">
|
|
<Avatar name={check.sender.name} size="sm" />
|
|
{check.sender.name}
|
|
</span>
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label={t('fileName')}
|
|
icon={<IconFile />}
|
|
value={
|
|
<span className="flex items-center gap-2 justify-end flex-wrap">
|
|
<FileTypeBadge extension={getFileExtension(check.fileName)} />
|
|
<span className="font-mono text-xs text-slate-700 break-all max-w-60 text-right">
|
|
{check.fileName}
|
|
</span>
|
|
</span>
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label={t('fileSize')}
|
|
value={
|
|
<span className="text-slate-600">
|
|
{formatFileSize(check.fileSize)}
|
|
</span>
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label={t('submitted')}
|
|
icon={<IconCalendar />}
|
|
value={formatDate(check.submittedAt)}
|
|
/>
|
|
<InfoRow
|
|
label={t('payment')}
|
|
icon={<IconPayment />}
|
|
value={
|
|
<span className="text-emerald-600 font-bold">
|
|
{formatCurrency(check.paymentAmount, check.currency)}
|
|
</span>
|
|
}
|
|
/>
|
|
</SectionCard>
|
|
);
|
|
};
|
|
|
|
const ResultCard: React.FC<CheckDetailViewProps> = ({ check }) => {
|
|
const t = useTranslations('DetailPage');
|
|
|
|
if (check.status === 'processing' || check.status === 'pending') {
|
|
return (
|
|
<SectionCard
|
|
title={t('resultTitle')}
|
|
icon={<IconShield />}
|
|
accent="violet"
|
|
>
|
|
<div className="flex flex-col items-center justify-center py-10 text-center space-y-3">
|
|
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center">
|
|
<svg
|
|
className="w-6 h-6 text-blue-400 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<p className="text-sm font-semibold text-slate-600">
|
|
{t('analysisInProgress')}
|
|
</p>
|
|
<p className="text-xs text-slate-400">
|
|
{t('resultsReadyAfterProcessing')}
|
|
</p>
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
if (!check.result) {
|
|
return (
|
|
<SectionCard
|
|
title={t('resultTitle')}
|
|
icon={<IconShield />}
|
|
accent="violet"
|
|
>
|
|
<p className="text-sm text-slate-500 py-4">{t('noResultAvailable')}</p>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
const { result } = check;
|
|
|
|
return (
|
|
<SectionCard
|
|
title={t('plagiarismResult')}
|
|
icon={<IconShield />}
|
|
accent={
|
|
result.similarityLevel === 'low'
|
|
? 'green'
|
|
: result.similarityLevel === 'high'
|
|
? 'red'
|
|
: 'amber'
|
|
}
|
|
>
|
|
<div className="space-y-6">
|
|
{/* Meter */}
|
|
<SimilarityMeter
|
|
percentage={result.overallSimilarity}
|
|
level={result.similarityLevel}
|
|
/>
|
|
|
|
{/* Stats grid */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="bg-slate-50 rounded-xl p-4 text-center">
|
|
<p className="text-2xl font-bold text-slate-800 tabular-nums">
|
|
{result.checkedWords.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-slate-500 mt-1">{t('wordsChecked')}</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-xl p-4 text-center">
|
|
<p className="text-2xl font-bold text-slate-800 tabular-nums">
|
|
{result.matchedWords.toLocaleString()}
|
|
</p>
|
|
<p className="text-xs text-slate-500 mt-1">{t('wordsMatched')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sources */}
|
|
{result.sources.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-3">
|
|
{t('matchedSources')}
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{result.sources.map((src, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-3 p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-700 truncate">
|
|
{src.title}
|
|
</p>
|
|
<a
|
|
href={src.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-slate-400 hover:text-blue-500 flex items-center gap-1 mt-0.5 transition-colors"
|
|
>
|
|
<IconSource />
|
|
<span className="truncate">{src.url}</span>
|
|
</a>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<span
|
|
className={`text-sm font-bold ${src.matchPercentage >= 30 ? 'text-red-600' : src.matchPercentage >= 15 ? 'text-amber-600' : 'text-emerald-600'}`}
|
|
>
|
|
{src.matchPercentage}%
|
|
</span>
|
|
<p className="text-xs text-slate-400">
|
|
{src.matchedWords.toLocaleString()} {t('words')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<InfoRow
|
|
label={t('processedAt')}
|
|
icon={<IconCalendar />}
|
|
value={formatDate(result.processedAt)}
|
|
/>
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
};
|
|
|
|
const CertificateCard: React.FC<CheckDetailViewProps> = ({ check }) => {
|
|
const t = useTranslations('DetailPage');
|
|
|
|
if (!check.certificate) {
|
|
return (
|
|
<SectionCard title={t('certificate')} icon={<IconCert />} accent="violet">
|
|
<div className=" flex flex-col items-center justify-center py-8 text-center space-y-2">
|
|
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center">
|
|
<IconCert />
|
|
</div>
|
|
<p className="text-sm text-slate-500">{t('noCertificate')}</p>
|
|
{check.result?.similarityLevel === 'high' && (
|
|
<p className="text-xs text-red-500">
|
|
{t('noCertificateHighSimilarity')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
const { certificate } = check;
|
|
|
|
return (
|
|
<SectionCard title={t('certificate')} icon={<IconCert />} accent="green">
|
|
{/* Certificate visual */}
|
|
<div className="relative rounded-xl border-2 border-dashed border-emerald-200 bg-linear-to-br from-emerald-50 to-white p-5 mb-4 overflow-hidden">
|
|
<div className="absolute top-2 right-2 opacity-10">
|
|
<svg
|
|
className="w-20 h-20 text-emerald-600"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-xs font-semibold text-emerald-700 uppercase tracking-widest mb-1">
|
|
{certificate.issuerName}
|
|
</p>
|
|
<p className="text-lg font-bold text-slate-800 font-mono tracking-wide">
|
|
{certificate.verificationCode}
|
|
</p>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
{t('certificateId')}: {certificate.id}
|
|
</p>
|
|
</div>
|
|
|
|
<InfoRow
|
|
label={t('issued')}
|
|
icon={<IconCalendar />}
|
|
value={formatDate(certificate.issuedAt)}
|
|
/>
|
|
<InfoRow
|
|
label={t('expires')}
|
|
icon={<IconCalendar />}
|
|
value={formatDate(certificate.expiresAt)}
|
|
/>
|
|
<InfoRow label={t('issuer')} value={certificate.issuerName} />
|
|
|
|
<div className="mt-4">
|
|
<a
|
|
href={certificate.downloadUrl}
|
|
className="flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-xl transition-colors"
|
|
>
|
|
<IconDownload />
|
|
{t('downloadCertificate')}
|
|
</a>
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
};
|
|
|
|
// ── Detail View (assembled) ───────────────────────────────────
|
|
|
|
const CheckDetailView: React.FC<CheckDetailViewProps> = ({ check }) => (
|
|
<div className="space-y-4">
|
|
<CheckHeader check={check} />
|
|
<div className="flex items-start gap-4 w-full">
|
|
<CertificateCard check={check} />
|
|
<SubmissionInfoCard check={check} />
|
|
</div>
|
|
<ResultCard check={check} />
|
|
</div>
|
|
);
|
|
|
|
// ── Page ──────────────────────────────────────────────────────
|
|
|
|
interface PlagiarismDetailPageProps {
|
|
checkId: string;
|
|
onBack?: () => void;
|
|
}
|
|
|
|
export const PlagiarismDetailPage: React.FC<PlagiarismDetailPageProps> = ({
|
|
checkId,
|
|
}) => {
|
|
const t = useTranslations('DetailPage');
|
|
const { check, loadingState, error, reload } = usePlagiarismDetail(checkId);
|
|
|
|
return (
|
|
<div className="bg-slate-50 font-sans">
|
|
<main className="max-w-300 mx-auto px-4 py-6">
|
|
{loadingState === 'loading' && <SkeletonLoader />}
|
|
{loadingState === 'error' && (
|
|
<ErrorState message={error ?? t('unknownError')} onRetry={reload} />
|
|
)}
|
|
{loadingState === 'success' && check && (
|
|
<CheckDetailView check={check} />
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|