detail page done
This commit is contained in:
503
src/widgets/detail/ui/detailPage.tsx
Normal file
503
src/widgets/detail/ui/detailPage.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PlagiarismDetailPage — Main Feature Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
'use client';
|
||||
import React from 'react';
|
||||
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 }) => (
|
||||
<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">
|
||||
ID: {check.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={check.status} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SubmissionInfoCard: React.FC<CheckDetailViewProps> = ({ check }) => (
|
||||
<SectionCard title="Submission Details" icon={<IconFile />} accent="blue">
|
||||
<InfoRow
|
||||
label="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="File Name"
|
||||
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-[240px] text-right">
|
||||
{check.fileName}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="File Size"
|
||||
value={
|
||||
<span className="text-slate-600">{formatFileSize(check.fileSize)}</span>
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Submitted"
|
||||
icon={<IconCalendar />}
|
||||
value={formatDate(check.submittedAt)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Payment"
|
||||
icon={<IconPayment />}
|
||||
value={
|
||||
<span className="text-emerald-600 font-bold">
|
||||
{formatCurrency(check.paymentAmount, check.currency)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
const ResultCard: React.FC<CheckDetailViewProps> = ({ check }) => {
|
||||
if (check.status === 'processing' || check.status === 'pending') {
|
||||
return (
|
||||
<SectionCard title="Result" 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">
|
||||
Analysis in progress
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Results will appear once processing is complete.
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!check.result) {
|
||||
return (
|
||||
<SectionCard title="Result" icon={<IconShield />} accent="violet">
|
||||
<p className="text-sm text-slate-500 py-4">No result available.</p>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = check;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Plagiarism Result"
|
||||
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">Words Checked</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">Words Matched</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sources */}
|
||||
{result.sources.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-3">
|
||||
Matched Sources
|
||||
</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()} words
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfoRow
|
||||
label="Processed At"
|
||||
icon={<IconCalendar />}
|
||||
value={formatDate(result.processedAt)}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateCard: React.FC<CheckDetailViewProps> = ({ check }) => {
|
||||
if (!check.certificate) {
|
||||
return (
|
||||
<SectionCard title="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">
|
||||
No certificate issued for this check.
|
||||
</p>
|
||||
{check.result?.similarityLevel === 'high' && (
|
||||
<p className="text-xs text-red-500">
|
||||
Certificates are not issued for high-similarity results.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const { certificate } = check;
|
||||
|
||||
return (
|
||||
<SectionCard title="Certificate" icon={<IconCert />} accent="green">
|
||||
{/* Certificate visual */}
|
||||
<div className="relative rounded-xl border-2 border-dashed border-emerald-200 bg-gradient-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">
|
||||
Certificate ID: {certificate.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoRow
|
||||
label="Issued"
|
||||
icon={<IconCalendar />}
|
||||
value={formatDate(certificate.issuedAt)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Expires"
|
||||
icon={<IconCalendar />}
|
||||
value={formatDate(certificate.expiresAt)}
|
||||
/>
|
||||
<InfoRow label="Issuer" value={certificate.issuerName} />
|
||||
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={certificate.downloadUrl}
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
<IconDownload />
|
||||
Download Certificate
|
||||
</a>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Detail View (assembled) ───────────────────────────────────
|
||||
|
||||
const CheckDetailView: React.FC<CheckDetailViewProps> = ({ check }) => (
|
||||
<div className="space-y-4">
|
||||
<CheckHeader check={check} />
|
||||
<SubmissionInfoCard check={check} />
|
||||
<ResultCard check={check} />
|
||||
<CertificateCard check={check} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────
|
||||
|
||||
interface PlagiarismDetailPageProps {
|
||||
checkId: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export const PlagiarismDetailPage: React.FC<PlagiarismDetailPageProps> = ({
|
||||
checkId,
|
||||
onBack,
|
||||
}) => {
|
||||
const { check, loadingState, error, reload } = usePlagiarismDetail(checkId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans">
|
||||
{/* Top nav */}
|
||||
<header className="sticky top-0 z-10 bg-white border-b border-slate-100 px-4 py-3 flex items-center gap-3 shadow-sm">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 text-slate-500 hover:text-slate-800 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<IconBack />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="font-bold text-slate-900 text-sm leading-tight">
|
||||
Plagiarism Check Detail
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 font-mono">{checkId}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<main className="max-w-2xl mx-auto px-4 py-6">
|
||||
{loadingState === 'loading' && <SkeletonLoader />}
|
||||
{loadingState === 'error' && (
|
||||
<ErrorState message={error ?? 'Unknown error'} onRetry={reload} />
|
||||
)}
|
||||
{loadingState === 'success' && check && (
|
||||
<CheckDetailView check={check} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user