Files
plagiat/src/widgets/detail/ui/detailPage.tsx
nabijonovdavronbek619@gmail.com 291375ce02 added multi language features
2026-03-31 19:45:21 +05:00

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>
);
};