complate detail page

This commit is contained in:
nabijonovdavronbek619@gmail.com
2026-04-02 19:19:06 +05:00
parent 10cf895262
commit 238c2c1653
28 changed files with 2949 additions and 973 deletions

View File

@@ -1,4 +1,4 @@
import PlagiatResult from '@/widgets/detail/ui/PlagiatResult';
import DocumentDetailPage from '@/widgets/detail/pageDetail';
interface Props {
params: Promise<{ detail: string }>;
@@ -7,5 +7,5 @@ interface Props {
export default async function DetailPage({ params }: Props) {
const { detail } = await params;
console.log(detail);
return <PlagiatResult />;
return <DocumentDetailPage id={Number(detail)} />;
}

View File

@@ -3,4 +3,5 @@ export const links = {
register: '/users/register/',
plagiarismCheck: '/shared/documents/',
history: '/shared/documents/list/',
detail: (id: number) => `/shared/documents/${id}/`,
};

View File

@@ -62,3 +62,13 @@ export const MOCK_CHECKS: Record<string, PlagiarismCheck> = {
},
},
};
export const blue = {
50: '#E6F1FB',
100: '#B5D4F4',
200: '#85B7EB',
400: '#378ADD',
600: '#185FA5',
800: '#0C447C',
900: '#042C53',
} as const;

View File

@@ -0,0 +1,260 @@
// utils/transformPlagiatResponse.ts
interface HighlightSegment {
text: string;
plagiarized: boolean;
}
interface SemanticMetrics {
totalWords: number;
uniqueWords: number;
lexicalUniqueness: number;
avgWordLength: number;
geoAvgWordLength: number;
minWordLength: number;
maxWordLength: number;
sentences: number;
avgWordsPerSentence: number;
polysyllabicWords: number;
polysyllabicPercent: number;
totalChars: number;
charsNoSpaces: number;
vowels: number;
consonants: number;
punctuation: number;
uppercase: number;
lowercase: number;
digits: number;
capsLockWords: number;
stopWords: number;
stopWordsPercent: number;
junkWords: number;
junkPercent: number;
maxConsecutiveRepeats: number;
top5Words: string;
spamRatio: number;
hasHtml: boolean;
hasEmail: boolean;
hasUrl: boolean;
hasDate: boolean;
hasPhone: boolean;
startsEqualsEnd: boolean;
isPalindrome: boolean;
hasQuestion: boolean;
hasExclamation: boolean;
paragraphs: number;
lines: number;
latinPercent: number;
cyrillicPercent: number;
longWords16plus: number;
}
interface Certificate {
verificationCode: string;
issuerName: string;
issuedAt: string;
expiresAt: string;
downloadUrl: string;
}
interface Source {
url: string;
matchPercentage: number;
module: string;
}
interface PlagiatData {
name: string;
initials: string;
email: string;
location: string;
fileName: string;
checkedAt: string;
humanPercent: number;
aiPercent: number;
plagiarismPercent: number;
originalityPercent: number;
citationPercent: number;
highlightedText: HighlightSegment[];
sources: Source[];
semantic: SemanticMetrics;
certificate: Certificate | null;
}
interface ApiRes {
ai: number;
hash: string;
text: string;
citation: number;
plagiarism: number;
originality: number;
}
interface ApiAnalyzeText {
Цифр: number;
Гласных: number;
'URL найден': string;
'Стоп-слов': number;
'ТОП-5 слов': string;
'Email найден': string;
Палиндром: string;
Согласных: number;
'Слов в CAPSLOCK': number;
'Без пробелов': number;
'Дата найдена': string;
'Доля мусора (%)': number;
'Начало = Конец': string;
'Строчных букв': number;
'Заглавных букв': number;
'Символов всего': number;
'Телефон найден': string;
'Доля латиницы (%)': number;
'Мин. длина слова': number;
'Уникальных слов': number;
'Доля стоп-слов (%)': number;
'Наличие HTML-тегов': string;
'Доля кириллицы (%)': number;
'Количество строк': number;
'Макс. длина слова': number;
'Знаков препинания': number;
'Мусорные слова (шт)': number;
'Средняя длина слов': number;
'Количество абзацев': number;
'Вопросительный знак': string;
'Восклицательный знак': string;
'Заспамленность (max/total)': number;
'Общее количество слов': number;
'Слов длиной 16+ символов': number;
'Количество предложений': number;
'Полисиллабических слов': number;
'Макс. подряд повторов слова': number;
'Лексическая уникальность (%)': number;
'Доля полисиллабических слов (%)': number;
'Геометрическая средняя длина слов': number;
'Среднее количество слов в предложении': number;
}
interface ApiResultJson {
ok: boolean;
res: ApiRes;
error: string;
success: string;
text_res: string;
analyze_text: ApiAnalyzeText;
}
interface ApiResult {
id: number;
document: number;
result_json: ApiResultJson;
created_at: string;
updated_at: string;
}
interface ApiDocument {
id: number;
title: string;
file: string;
certificate: boolean;
text: string;
created_at: string;
updated_at: string;
results: ApiResult[];
}
// ─── Parsers ──────────────────────────────────────────────────────────────────
function parseHighlightedText(textRes: string): HighlightSegment[] {
const segments: HighlightSegment[] = [];
const parts = textRes.split(/(<sel>[\s\S]*?<\/sel>)/g);
for (const part of parts) {
if (part.startsWith('<sel>')) {
segments.push({ text: part.replace(/<\/?sel>/g, ''), plagiarized: true });
} else if (part) {
segments.push({ text: part, plagiarized: false });
}
}
return segments;
}
function parseAnalyzeText(a: ApiAnalyzeText): SemanticMetrics {
const bool = (v: string): boolean => v === 'Да';
return {
totalWords: a['Общее количество слов'],
uniqueWords: a['Уникальных слов'],
lexicalUniqueness: a['Лексическая уникальность (%)'],
avgWordLength: a['Средняя длина слов'],
geoAvgWordLength: a['Геометрическая средняя длина слов'],
minWordLength: a['Мин. длина слова'],
maxWordLength: a['Макс. длина слова'],
sentences: a['Количество предложений'],
avgWordsPerSentence: a['Среднее количество слов в предложении'],
polysyllabicWords: a['Полисиллабических слов'],
polysyllabicPercent: a['Доля полисиллабических слов (%)'],
totalChars: a['Символов всего'],
charsNoSpaces: a['Без пробелов'],
vowels: a['Гласных'],
consonants: a['Согласных'],
punctuation: a['Знаков препинания'],
uppercase: a['Заглавных букв'],
lowercase: a['Строчных букв'],
digits: a['Цифр'],
capsLockWords: a['Слов в CAPSLOCK'],
stopWords: a['Стоп-слов'],
stopWordsPercent: a['Доля стоп-слов (%)'],
junkWords: a['Мусорные слова (шт)'],
junkPercent: a['Доля мусора (%)'],
maxConsecutiveRepeats: a['Макс. подряд повторов слова'],
top5Words: a['ТОП-5 слов'],
spamRatio: a['Заспамленность (max/total)'],
hasHtml: bool(a['Наличие HTML-тегов']),
hasEmail: bool(a['Email найден']),
hasUrl: bool(a['URL найден']),
hasDate: bool(a['Дата найдена']),
hasPhone: bool(a['Телефон найден']),
startsEqualsEnd: bool(a['Начало = Конец']),
isPalindrome: bool(a['Палиндром']),
hasQuestion: bool(a['Вопросительный знак']),
hasExclamation: bool(a['Восклицательный знак']),
paragraphs: a['Количество абзацев'],
lines: a['Количество строк'],
latinPercent: a['Доля латиницы (%)'],
cyrillicPercent: a['Доля кириллицы (%)'],
longWords16plus: a['Слов длиной 16+ символов'],
};
}
// ─── Main transformer ─────────────────────────────────────────────────────────
export function transformPlagiatResponse(apiDoc: ApiDocument): PlagiatData {
const result = apiDoc.results[0];
const res = result.result_json.res;
const analyze = result.result_json.analyze_text;
const nameParts = apiDoc.text.trim().split(/\s+/);
const initials = nameParts
.slice(0, 2)
.map((n) => n[0]?.toUpperCase() ?? '')
.join('');
return {
name: apiDoc.text,
initials,
email: '',
location: '',
fileName: apiDoc.file.split('/').pop() ?? apiDoc.file,
checkedAt: apiDoc.created_at.slice(0, 10),
humanPercent: 100 - res.ai,
aiPercent: res.ai,
plagiarismPercent: res.plagiarism,
originalityPercent: res.originality,
citationPercent: res.citation,
highlightedText: parseHighlightedText(result.result_json.text_res),
sources: [],
semantic: parseAnalyzeText(analyze),
certificate: null,
};
}

View File

@@ -0,0 +1,130 @@
import { PlagiatData } from '../lib/types';
export const mockData: PlagiatData = {
name: 'Sokhibjon Orzikulov',
initials: 'SO',
email: 'sakhib@orzklv.uz',
location: 'Tashkent, Uzbekistan',
fileName: 'resume_sokhibjon.pdf',
checkedAt: '2026-04-02',
humanPercent: 90,
aiPercent: 10,
plagiarismPercent: 92,
originalityPercent: 5,
citationPercent: 3,
highlightedText: [
{ text: 'Sokhibjon Orzikulov — ', plagiarized: false },
{
text: 'tashkentlik dasturchi va open source muhandis.',
plagiarized: true,
},
{ text: ' U ', plagiarized: false },
{ text: 'NixOS, Rust va embedded tizimlar', plagiarized: true },
{ text: ' ustida ishlaydi. ', plagiarized: false },
{ text: 'Uzinfocom kompaniyasida', plagiarized: true },
{
text: " Open Source bo'lim rahbari sifatida faoliyat yuritadi va ",
plagiarized: false,
},
{ text: 'Floss Uzbekistan tashkilotini boshqaradi.', plagiarized: true },
{ text: ' U shuningdek ', plagiarized: false },
{
text: "LLVM committer bo'lib, bir nechta dasturlash tillari va Linux distributivlarini",
plagiarized: true,
},
{ text: ' maintain qilgan.', plagiarized: false },
],
sources: [
{
url: 'https://www.b-soc.ru/partner/everland-soczialno-predprinimatelskij-proekt/',
matchPercentage: 9,
module: 'Internet Free',
},
{
url: 'https://legalacts.ru/doc/prikaz-rosstata-ot-30072014-n-493-ob/',
matchPercentage: 7,
module: 'Internet Free',
},
{
url: 'https://www.consultant.ru/document/cons_doc_LAW_402170/',
matchPercentage: 6,
module: 'Internet Free',
},
{
url: 'https://be5.biz/pravo/t029/12.html',
matchPercentage: 5,
module: 'Internet Free',
},
{
url: 'https://kn51.ru/wp-content/uploads/2022/03/pravila_priema_2022n-1-1.pdf',
matchPercentage: 4,
module: 'Internet Free',
},
{
url: 'http://knacits.ru/index.php/rumts',
matchPercentage: 3,
module: 'Internet Free',
},
{
url: 'https://spb.ucheba.ru/program/703329',
matchPercentage: 3,
module: 'Internet Free',
},
{
url: 'https://nnovcons.ru/files/priem2022/priem_pravila_2022_p.pdf',
matchPercentage: 2,
module: 'Internet Free',
},
],
semantic: {
totalWords: 1477,
uniqueWords: 593,
lexicalUniqueness: 40.15,
avgWordLength: 5.92,
geoAvgWordLength: 5.25,
minWordLength: 1,
maxWordLength: 15,
sentences: 105,
avgWordsPerSentence: 14.07,
polysyllabicWords: 561,
polysyllabicPercent: 37.98,
totalChars: 10742,
charsNoSpaces: 9386,
vowels: 3177,
consonants: 5357,
punctuation: 274,
uppercase: 575,
lowercase: 7959,
digits: 203,
capsLockWords: 49,
stopWords: 0,
stopWordsPercent: 0,
junkWords: 0,
junkPercent: 0,
maxConsecutiveRepeats: 2,
top5Words: 'of(42), for(39), the(35), and(33), on(27)',
spamRatio: 0.03,
hasHtml: false,
hasEmail: true,
hasUrl: true,
hasDate: false,
hasPhone: true,
startsEqualsEnd: false,
isPalindrome: false,
hasQuestion: true,
hasExclamation: false,
paragraphs: 3,
lines: 191,
latinPercent: 100,
cyrillicPercent: 0,
longWords16plus: 0,
},
certificate: {
id: '1',
verificationCode: 'PLAG-9001-VERIFY',
issuerName: 'Global Plagiarism Checker',
issuedAt: '2026-03-30',
expiresAt: '2027-03-30',
downloadUrl: '/certificates/cert-9001.pdf',
},
};

View File

@@ -51,3 +51,87 @@ export interface PlagiarismCheck {
result?: PlagiarismResult;
certificate?: Certificate;
}
// ─── Detail page Types ────────────────────────────────────────────────────────────────────
export interface Source {
url: string;
matchPercentage: number;
module: string;
}
export interface SemanticMetrics {
totalWords: number;
uniqueWords: number;
lexicalUniqueness: number;
avgWordLength: number;
geoAvgWordLength: number;
minWordLength: number;
maxWordLength: number;
sentences: number;
avgWordsPerSentence: number;
polysyllabicWords: number;
polysyllabicPercent: number;
totalChars: number;
charsNoSpaces: number;
vowels: number;
consonants: number;
punctuation: number;
uppercase: number;
lowercase: number;
digits: number;
capsLockWords: number;
stopWords: number;
stopWordsPercent: number;
junkWords: number;
junkPercent: number;
maxConsecutiveRepeats: number;
top5Words: string;
spamRatio: number;
hasHtml: boolean;
hasEmail: boolean;
hasUrl: boolean;
hasDate: boolean;
hasPhone: boolean;
startsEqualsEnd: boolean;
isPalindrome: boolean;
hasQuestion: boolean;
hasExclamation: boolean;
paragraphs: number;
lines: number;
latinPercent: number;
cyrillicPercent: number;
longWords16plus: number;
}
export interface CertificateDetail {
id: string;
verificationCode: string;
issuerName: string;
issuedAt: string;
expiresAt: string;
downloadUrl: string;
}
export interface PlagiatData {
name: string;
initials: string;
email: string;
location: string;
fileName: string;
checkedAt: string;
humanPercent: number;
aiPercent: number;
plagiarismPercent: number;
originalityPercent: number;
citationPercent: number;
highlightedText: HighlightSegment[];
sources: Source[];
semantic: SemanticMetrics | null;
certificate: CertificateDetail | null;
}
export interface HighlightSegment {
text: string;
plagiarized: boolean;
}

View File

@@ -0,0 +1,662 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { links } from '@/shared/request/links';
import { apiRequest } from '@/shared/request/apiRequest';
// ── Types ────────────────────────────────────────────────────────────────────
interface AnalyzeText {
[key: string]: number | string;
}
interface ResultJson {
ok: boolean;
res: {
ai: number;
hash: string;
text: string;
citation: number;
plagiarism: number;
originality: number;
};
error: string;
success: string;
text_res: string;
analyze_text: AnalyzeText;
}
interface Result {
id: number;
document: number;
result_json: ResultJson;
created_at: string;
updated_at: string;
}
interface Document {
id: number;
title: string;
file: string;
certificate: boolean;
text: string;
created_at: string;
updated_at: string;
results: Result[];
}
// ── Props ─────────────────────────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────────────────────
function parseHighlightedText(text_res: string): React.ReactNode[] {
const parts = text_res.split(/(<sel>.*?<\/sel>)/gs);
return parts.map((part, i) => {
if (part.startsWith('<sel>') && part.endsWith('</sel>')) {
const inner = part.slice(5, -6);
return (
<mark
key={i}
className="bg-amber-200/80 text-amber-900 rounded px-0.5 font-medium"
>
{inner}
</mark>
);
}
return <span key={i}>{part}</span>;
});
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('uz-UZ', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
/** A flag key is one whose value is purely a yes/no boolean string */
function isBooleanString(val: string | number): boolean {
if (typeof val !== 'string') return false;
const lower = val.toLowerCase();
return (
lower === 'нет' ||
lower === 'да' ||
lower === 'yes' ||
lower === 'no' ||
lower === 'true' ||
lower === 'false'
);
}
function isFlagPositive(val: string | number): boolean {
if (typeof val === 'number') return val !== 0;
const lower = val.toLowerCase();
return lower !== 'нет' && lower !== 'no' && lower !== 'false' && lower !== '';
}
// ── Score Ring ───────────────────────────────────────────────────────────────
function ScoreRing({
value,
label,
color,
}: {
value: number;
label: string;
color: string;
}) {
const r = 38;
const circ = 2 * Math.PI * r;
const dash = (Math.min(Math.max(value, 0), 100) / 100) * circ;
return (
<div className="flex flex-col items-center gap-2">
<div className="relative w-24 h-24">
<svg className="w-full h-full -rotate-90" viewBox="0 0 96 96">
<circle
cx="48"
cy="48"
r={r}
fill="none"
stroke="#e2e8f0"
strokeWidth="8"
/>
<circle
cx="48"
cy="48"
r={r}
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${dash} ${circ}`}
style={{ transition: 'stroke-dasharray 1s ease' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold text-slate-800">{value}%</span>
</div>
</div>
<span className="text-xs font-semibold uppercase tracking-widest text-slate-500">
{label}
</span>
</div>
);
}
// ── Stat Card ────────────────────────────────────────────────────────────────
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-white border border-slate-100 rounded-xl p-4 flex flex-col gap-1 shadow-sm hover:shadow-md transition-shadow">
<span className="text-[11px] uppercase tracking-widest text-slate-400 font-semibold leading-snug">
{label}
</span>
<span className="text-slate-800 font-semibold text-sm break-words">
{String(value)}
</span>
</div>
);
}
// ── Section Shell ─────────────────────────────────────────────────────────────
function Section({
title,
children,
accent,
}: {
title: string;
children: React.ReactNode;
accent?: string;
}) {
return (
<section className="mb-10">
<div className="flex items-center gap-3 mb-5">
{accent && <span className={`w-1 h-6 rounded-full ${accent}`} />}
<h2 className="text-sm font-bold uppercase tracking-widest text-slate-500">
{title}
</h2>
</div>
{children}
</section>
);
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
function Skeleton({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<div
className={`animate-pulse bg-slate-200 rounded-lg ${className ?? ''}`}
style={style}
/>
);
}
function LoadingSkeleton() {
return (
<div className="max-w-5xl mx-auto px-6 py-10 space-y-10">
<div className="flex gap-3 mb-6">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-56" />
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-8">
<div className="flex justify-around gap-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex flex-col items-center gap-3">
<Skeleton className="w-24 h-24 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
<Skeleton className="mt-8 h-14 w-full" />
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-6 space-y-3">
{[...Array(6)].map((_, i) => (
<Skeleton
key={i}
className="h-4"
style={{ width: `${70 + i * 5}%` }}
/>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{[...Array(12)].map((_, i) => (
<Skeleton key={i} className="h-16" />
))}
</div>
</div>
);
}
// ── Error State ───────────────────────────────────────────────────────────────
function ErrorState({ message }: { message?: string }) {
return (
<div className="max-w-5xl mx-auto px-6 py-20 flex flex-col items-center gap-4 text-center">
<div className="w-14 h-14 rounded-full bg-red-50 flex items-center justify-center">
<svg
className="w-7 h-7 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 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>
<p className="text-slate-700 font-semibold">Failed to load document</p>
<p className="text-slate-400 text-sm">
{message ?? 'An unexpected error occurred.'}
</p>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function DocumentDetailPage({ id }: { id: number }) {
const {
data: doc,
isLoading,
isError,
error,
} = useQuery({
queryKey: ['detail', id],
queryFn: () => apiRequest('GET', links.detail(id)),
enabled: !!id,
staleTime: 1000 * 60 * 5,
select: (data) => data?.data as Document,
});
const [activeTab, setActiveTab] = useState<'highlighted' | 'plain'>(
'highlighted',
);
// ── Loading ──
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 h-[65px]" />
<LoadingSkeleton />
</div>
);
}
// ── Error ──
if (isError || !doc) {
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 h-[65px]" />
<ErrorState message={(error as Error)?.message} />
</div>
);
}
// ── Derived data ──
const result = doc.results?.[0];
const res = result?.result_json?.res;
const analyze: AnalyzeText = result?.result_json?.analyze_text ?? {};
const textRes = result?.result_json?.text_res ?? '';
const aiRisk = !res
? 'low'
: res.ai >= 70
? 'high'
: res.ai >= 40
? 'medium'
: 'low';
const aiColors: Record<string, string> = {
high: 'text-red-600 bg-red-50 border-red-200',
medium: 'text-amber-600 bg-amber-50 border-amber-200',
low: 'text-emerald-600 bg-emerald-50 border-emerald-200',
};
const aiLabel: Record<string, string> = {
high: 'AI Content Detected',
medium: 'Possible AI Content',
low: 'Likely Original',
};
// Partition analyze_text dynamically:
// 1. Flag keys → Yes/No boolean strings → Detection Flags section
// 2. Top-words key → contains "топ" or "top" → Top Words section
// 3. Everything else → stat grid
const allKeys = Object.keys(analyze);
const topWordsKey = allKeys.find(
(k) => k.toLowerCase().includes('топ') || k.toLowerCase().includes('top'),
);
const flagKeys = allKeys.filter(
(k) => k !== topWordsKey && isBooleanString(analyze[k] as string | number),
);
const statKeys = allKeys.filter(
(k) => k !== topWordsKey && !flagKeys.includes(k),
);
const topWordsValue = topWordsKey ? String(analyze[topWordsKey]) : null;
return (
<div className="min-h-screen bg-slate-50 font-sans">
{/* ── Header ── */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-20">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => window.history.back()}
className="w-8 h-8 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<svg
className="w-4 h-4 text-slate-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div>
<p className="text-[11px] uppercase tracking-widest text-slate-400 font-semibold">
Document #{doc.id}
</p>
<h1 className="text-base font-bold text-slate-800 leading-tight">
{doc.title}
</h1>
</div>
</div>
<div className="flex items-center gap-3">
{doc.certificate && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-semibold">
<svg
className="w-3.5 h-3.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
Certificate
</span>
)}
{doc.file && (
<a
href={doc.file}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-slate-700 transition-colors"
>
<svg
className="w-3.5 h-3.5"
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 PDF
</a>
)}
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-10">
{/* ── Meta row ── */}
<div className="flex flex-wrap gap-4 mb-10 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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>
Created: {formatDate(doc.created_at)}
</span>
<span className="flex items-center gap-1.5">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Updated: {formatDate(doc.updated_at)}
</span>
{res?.hash && (
<span className="flex items-center gap-1.5 font-mono">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/>
</svg>
Hash: {res.hash}
</span>
)}
</div>
{/* ── Score Overview ── */}
{res && (
<Section title="Analysis Scores" accent="bg-violet-500">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<div className="flex flex-wrap justify-around gap-8">
<ScoreRing
value={res.originality}
label="Originality"
color="#10b981"
/>
<ScoreRing
value={res.plagiarism}
label="Plagiarism"
color="#f59e0b"
/>
<ScoreRing
value={res.citation}
label="Citation"
color="#6366f1"
/>
<ScoreRing value={res.ai} label="AI Content" color="#ef4444" />
</div>
<div
className={`mt-8 flex items-center gap-3 rounded-xl border px-5 py-4 ${aiColors[aiRisk]}`}
>
<svg
className="w-5 h-5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2h-2"
/>
</svg>
<div>
<p className="font-bold text-sm">{aiLabel[aiRisk]}</p>
<p className="text-xs opacity-75 mt-0.5">
AI probability score: <strong>{res.ai}%</strong> content
may have been generated or assisted by an AI model.
</p>
</div>
</div>
</div>
</Section>
)}
{/* ── Document Text ── */}
{(res?.text || textRes) && (
<Section title="Document Text" accent="bg-blue-500">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="flex border-b border-slate-100">
{(['highlighted', 'plain'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-5 py-3 text-xs font-semibold uppercase tracking-widest transition-colors ${
activeTab === tab
? 'border-b-2 border-slate-800 text-slate-800'
: 'text-slate-400 hover:text-slate-600'
}`}
>
{tab === 'highlighted'
? 'Plagiarism Highlights'
: 'Plain Text'}
</button>
))}
</div>
<div className="p-6">
<pre className="whitespace-pre-wrap font-mono text-sm text-slate-700 leading-relaxed">
{activeTab === 'highlighted'
? parseHighlightedText(textRes)
: (res?.text ?? textRes)}
</pre>
</div>
{activeTab === 'highlighted' && (
<div className="px-6 pb-5 flex items-center gap-2 text-xs text-slate-500">
<span className="inline-block w-3 h-3 rounded-sm bg-amber-200 border border-amber-300" />
Highlighted fragments indicate potential plagiarism matches
</div>
)}
</div>
</Section>
)}
{/* ── Text Statistics (all numeric / non-boolean keys) ── */}
{statKeys.length > 0 && (
<Section title="Text Statistics" accent="bg-teal-500">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{statKeys.map((key) => (
<StatCard key={key} label={key} value={analyze[key]} />
))}
</div>
</Section>
)}
{/* ── Detection Flags (all boolean Yes/No keys) ── */}
{flagKeys.length > 0 && (
<Section title="Detection Flags" accent="bg-rose-500">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{flagKeys.map((key) => {
const val = analyze[key];
const positive = isFlagPositive(val as string | number);
return (
<div
key={key}
className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${
positive
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-slate-50 border-slate-200 text-slate-500'
}`}
>
<div
className={`w-2 h-2 rounded-full flex-shrink-0 ${
positive ? 'bg-red-500' : 'bg-slate-300'
}`}
/>
<div>
<p className="text-[11px] uppercase tracking-widest font-semibold opacity-60">
{key}
</p>
<p className="text-xs font-bold">{String(val)}</p>
</div>
</div>
);
})}
</div>
</Section>
)}
{/* ── Top Words (key detected by "топ"/"top" substring) ── */}
{topWordsValue && (
<Section title="Top Words" accent="bg-orange-500">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<div className="flex flex-wrap gap-2">
{topWordsValue
.split(',')
.map((w) => w.trim())
.filter(Boolean)
.map((word, i) => {
const match = word.match(/^(.*)\((\d+)\)$/);
const term = match ? match[1].trim() : word;
const cnt = match ? parseInt(match[2]) : 1;
return (
<span
key={i}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-slate-100 text-slate-700 font-semibold"
style={{ fontSize: `${0.75 + cnt * 0.04}rem` }}
>
{term}
<span className="bg-slate-300 text-slate-600 text-[10px] font-bold px-1.5 py-0.5 rounded-full">
{cnt}
</span>
</span>
);
})}
</div>
</div>
</Section>
)}
{/* ── Footer ── */}
{result && (
<footer className="text-center text-xs text-slate-400 pb-10 mt-4">
Result ID #{result.id} · Analyzed {formatDate(result.created_at)}
</footer>
)}
</main>
</div>
);
}

View File

@@ -1,460 +0,0 @@
'use client';
import { useState } from 'react';
interface Source {
url: string;
title: string;
matchPercentage: number;
matchedWords: number;
}
interface CheckResult {
ai: number;
plagiarism: number;
originality: number;
citation: number;
checkedWords: number;
uniqueWords: number;
lexicalUniqueness: number;
sentences: number;
avgWordsPerSentence: number;
lines: number;
sources: Source[];
}
interface Certificate {
id: string;
issuedAt: string;
expiresAt: string;
verificationCode: string;
issuerName: string;
downloadUrl: string;
}
interface PlagiatResultProps {
name: string;
initials: string;
email: string;
location: string;
fileName: string;
checkedAt: string;
result: CheckResult;
certificate: Certificate;
}
// Blue palette
const blue = {
50: '#E6F1FB',
100: '#B5D4F4',
200: '#85B7EB',
400: '#378ADD',
600: '#185FA5',
800: '#0C447C',
900: '#042C53',
};
const mockData: PlagiatResultProps = {
name: 'Sokhibjon Orzikulov',
initials: 'SO',
email: 'sakhib@orzklv.uz',
location: 'Tashkent, Uzbekistan',
fileName: 'resume_sokhibjon.pdf',
checkedAt: '2026-04-02',
result: {
ai: 86,
plagiarism: 92,
originality: 5,
citation: 3,
checkedWords: 1477,
uniqueWords: 593,
lexicalUniqueness: 40,
sentences: 105,
avgWordsPerSentence: 14.1,
lines: 191,
sources: [
{
url: 'https://arxiv.org/abs/1706.03762',
title: 'arxiv.org — Attention Is All You Need',
matchPercentage: 9,
matchedWords: 937,
},
{
url: 'https://en.wikipedia.org/wiki/Machine_learning',
title: 'Wikipedia — Machine Learning',
matchPercentage: 7,
matchedWords: 730,
},
{
url: 'https://towardsdatascience.com/introduction-to-neural-networks',
title: 'Towards Data Science — Introduction to Neural Networks',
matchPercentage: 6,
matchedWords: 625,
},
],
},
certificate: {
id: 'cert-9001',
issuedAt: '2026-03-30',
expiresAt: '2027-03-30',
verificationCode: 'PLAG-9001-VERIFY',
issuerName: 'Global Plagiarism Checker',
downloadUrl: '/certificates/cert-9001.pdf',
},
};
// ─── Sub-components ───────────────────────────────────────────────────────────
function CircleGauge({ value }: { value: number }) {
const r = 50;
const circ = 2 * Math.PI * r;
const offset = circ - (value / 100) * circ;
return (
<svg
width="130"
height="130"
viewBox="0 0 130 130"
aria-label={`${value}% plagiat`}
>
<circle
cx="65"
cy="65"
r={r}
fill="none"
stroke={blue[100]}
strokeWidth="14"
/>
<circle
cx="65"
cy="65"
r={r}
fill="none"
stroke={blue[900]}
strokeWidth="14"
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 65 65)"
style={{ transition: 'stroke-dashoffset 0.8s ease' }}
/>
<text
x="65"
y="60"
textAnchor="middle"
fontSize="26"
fontWeight="500"
fill={blue[900]}
>
{value}%
</text>
<text x="65" y="78" textAnchor="middle" fontSize="11" fill={blue[400]}>
plagiat
</text>
</svg>
);
}
function BarRow({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<div className="mb-2.5">
<div className="flex justify-between items-center text-sm mb-1">
<span className="flex items-center gap-1.5 text-slate-500">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ background: color }}
/>
{label}
</span>
<span className="font-medium" style={{ color: blue[900] }}>
{value}%
</span>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ background: blue[100] }}
>
<div
className="h-full rounded-full"
style={{
width: `${value}%`,
background: color,
transition: 'width 0.8s ease',
}}
/>
</div>
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg p-3.5" style={{ background: blue[50] }}>
<p className="text-xs mb-1" style={{ color: blue[600] }}>
{label}
</p>
<p className="text-xl font-medium" style={{ color: blue[900] }}>
{value}
</p>
</div>
);
}
function SourceItem({ source, index }: { source: Source; index: number }) {
const colors = [blue[900], blue[600], blue[400]];
const color = colors[index] ?? blue[400];
return (
<div
className="rounded-lg p-3"
style={{ border: `0.5px solid ${blue[100]}` }}
>
<div className="flex items-center justify-between mb-1">
<span
className="text-sm font-medium truncate max-w-[72%]"
style={{ color: blue[900] }}
>
{source.title}
</span>
<span className="text-sm font-medium" style={{ color }}>
{source.matchPercentage}%
</span>
</div>
<p className="text-[11px] mb-1.5 truncate" style={{ color: blue[400] }}>
{source.url}
</p>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ background: blue[100] }}
>
<div
className="h-full rounded-full"
style={{
width: `${source.matchPercentage}%`,
background: color,
transition: 'width 0.8s ease',
}}
/>
</div>
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function PlagiatResult({
data = mockData,
}: {
data?: PlagiatResultProps;
}) {
const [downloading, setDownloading] = useState(false);
const { result, certificate } = data;
const handleDownload = () => {
setDownloading(true);
setTimeout(() => setDownloading(false), 1500);
};
const divider = (
<hr
style={{
borderColor: blue[100],
borderTopWidth: '0.5px',
borderStyle: 'solid',
margin: '1.25rem 0',
}}
/>
);
return (
<div className="min-h-screen flex items-start justify-center p-6 bg-slate-50">
<div
className="w-full max-w-2xl rounded-xl p-5"
style={{ background: '#ffffff', border: `0.5px solid ${blue[100]}` }}
>
{/* ── Header ── */}
<div className="flex items-center gap-3 mb-5">
<div
className="w-11 h-11 rounded-full flex items-center justify-center text-sm font-medium shrink-0"
style={{ background: blue[50], color: blue[600] }}
>
{data.initials}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[15px] font-medium"
style={{ color: blue[900] }}
>
{data.name}
</span>
<span
className="text-xs px-2.5 py-0.5 rounded-md font-medium"
style={{ background: blue[100], color: blue[800] }}
>
{result.plagiarism}% plagiat
</span>
</div>
<p
className="text-[13px] mt-0.5 truncate"
style={{ color: blue[400] }}
>
{data.fileName} · {data.email} · {data.location}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-[11px]" style={{ color: blue[400] }}>
Tekshirilgan
</p>
<p className="text-[12px] mt-0.5" style={{ color: blue[600] }}>
{data.checkedAt}
</p>
</div>
</div>
{divider}
{/* ── Top metrics ── */}
<div className="grid grid-cols-4 gap-2.5 mb-5">
<MetricCard
label="Plagiat darajasi"
value={`${result.plagiarism}%`}
/>
<MetricCard label="AI yozgan" value={`${result.ai}%`} />
<MetricCard label="Originallik" value={`${result.originality}%`} />
<MetricCard label="Iqtibos" value={`${result.citation}%`} />
</div>
{/* ── Gauge + bars ── */}
<div className="flex items-center gap-5 mb-5">
<CircleGauge value={result.plagiarism} />
<div className="flex-1">
<BarRow
label="Plagiat"
value={result.plagiarism}
color={blue[900]}
/>
<BarRow
label="AI generatsiya"
value={result.ai}
color={blue[600]}
/>
<BarRow
label="Original"
value={result.originality}
color={blue[400]}
/>
<BarRow label="Iqtibos" value={result.citation} color={blue[200]} />
</div>
</div>
{divider}
{/* ── Text analysis ── */}
<p
className="text-[13px] font-medium uppercase tracking-wider mb-3"
style={{ color: blue[400] }}
>
Matn tahlili
</p>
<div className="grid grid-cols-3 gap-2 mb-5">
<MetricCard
label="Jami so'z"
value={result.checkedWords.toLocaleString()}
/>
<MetricCard
label="Unikal so'z"
value={result.uniqueWords.toLocaleString()}
/>
<MetricCard
label="Leksik unikalligi"
value={`${result.lexicalUniqueness}%`}
/>
<MetricCard label="Jumlalar" value={String(result.sentences)} />
<MetricCard
label="O'rt. so'z/juml."
value={String(result.avgWordsPerSentence)}
/>
<MetricCard label="Qatorlar" value={String(result.lines)} />
</div>
{divider}
{/* ── Sources ── */}
<p
className="text-[13px] font-medium uppercase tracking-wider mb-3"
style={{ color: blue[400] }}
>
Manba yo&lsquo;nalishlari
</p>
<div className="flex flex-col gap-2.5 mb-5">
{result.sources.map((source, i) => (
<SourceItem key={source.url} source={source} index={i} />
))}
</div>
{divider}
{/* ── Certificate ── */}
<p
className="text-[13px] font-medium uppercase tracking-wider mb-3"
style={{ color: blue[400] }}
>
Sertifikat
</p>
<div
className="flex items-center gap-3 rounded-lg p-3.5"
style={{ background: blue[50], border: `0.5px solid ${blue[100]}` }}
>
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: blue[600] }}
>
<svg
width="16"
height="16"
viewBox="0 0 18 18"
fill="none"
aria-hidden="true"
>
<path
d="M9 1L11.2 6.5H17L12.4 10L14.1 16L9 12.8L3.9 16L5.6 10L1 6.5H6.8L9 1Z"
fill="#E6F1FB"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium" style={{ color: blue[900] }}>
{certificate.issuerName} sertifikati
</p>
<p
className="text-[11px] mt-0.5 font-mono truncate"
style={{ color: blue[600] }}
>
{certificate.verificationCode} · Amal qilish:{' '}
{certificate.expiresAt}
</p>
</div>
<button
onClick={handleDownload}
className="text-xs px-3 py-1.5 rounded-md shrink-0 transition-colors cursor-pointer"
style={{
border: `0.5px solid ${blue[400]}`,
color: blue[600],
background: downloading ? blue[100] : 'transparent',
}}
>
{downloading ? 'Yuklanmoqda...' : 'Yuklab olish ↗'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,782 @@
// 'use client';
// import { useState } from 'react';
// import EmptySourcesList from './emptyList';
// import { PlagiatData, SemanticMetrics } from '../lib/types';
// // ─── Mock data ────────────────────────────────────────────────────────────────
// const mockData: PlagiatData = {
// name: 'Sokhibjon Orzikulov',
// initials: 'SO',
// email: 'sakhib@orzklv.uz',
// location: 'Tashkent, Uzbekistan',
// fileName: 'resume_sokhibjon.pdf',
// checkedAt: '2026-04-02',
// humanPercent: 90,
// aiPercent: 10,
// plagiarismPercent: 92,
// originalityPercent: 5,
// citationPercent: 3,
// highlightedText: [
// { text: 'Sokhibjon Orzikulov — ', plagiarized: false },
// {
// text: 'tashkentlik dasturchi va open source muhandis.',
// plagiarized: true,
// },
// { text: ' U ', plagiarized: false },
// { text: 'NixOS, Rust va embedded tizimlar', plagiarized: true },
// { text: ' ustida ishlaydi. ', plagiarized: false },
// { text: 'Uzinfocom kompaniyasida', plagiarized: true },
// {
// text: " Open Source bo'lim rahbari sifatida faoliyat yuritadi va ",
// plagiarized: false,
// },
// { text: 'Floss Uzbekistan tashkilotini boshqaradi.', plagiarized: true },
// { text: ' U shuningdek ', plagiarized: false },
// {
// text: "LLVM committer bo'lib, bir nechta dasturlash tillari va Linux distributivlarini",
// plagiarized: true,
// },
// { text: ' maintain qilgan.', plagiarized: false },
// ],
// sources: [
// {
// url: 'https://www.b-soc.ru/partner/everland-soczialno-predprinimatelskij-proekt/',
// matchPercentage: 9,
// module: 'Internet Free',
// },
// {
// url: 'https://legalacts.ru/doc/prikaz-rosstata-ot-30072014-n-493-ob/',
// matchPercentage: 7,
// module: 'Internet Free',
// },
// {
// url: 'https://www.consultant.ru/document/cons_doc_LAW_402170/',
// matchPercentage: 6,
// module: 'Internet Free',
// },
// {
// url: 'https://be5.biz/pravo/t029/12.html',
// matchPercentage: 5,
// module: 'Internet Free',
// },
// {
// url: 'https://kn51.ru/wp-content/uploads/2022/03/pravila_priema_2022n-1-1.pdf',
// matchPercentage: 4,
// module: 'Internet Free',
// },
// {
// url: 'http://knacits.ru/index.php/rumts',
// matchPercentage: 3,
// module: 'Internet Free',
// },
// {
// url: 'https://spb.ucheba.ru/program/703329',
// matchPercentage: 3,
// module: 'Internet Free',
// },
// {
// url: 'https://nnovcons.ru/files/priem2022/priem_pravila_2022_p.pdf',
// matchPercentage: 2,
// module: 'Internet Free',
// },
// ],
// semantic: {
// totalWords: 1477,
// uniqueWords: 593,
// lexicalUniqueness: 40.15,
// avgWordLength: 5.92,
// geoAvgWordLength: 5.25,
// minWordLength: 1,
// maxWordLength: 15,
// sentences: 105,
// avgWordsPerSentence: 14.07,
// polysyllabicWords: 561,
// polysyllabicPercent: 37.98,
// totalChars: 10742,
// charsNoSpaces: 9386,
// vowels: 3177,
// consonants: 5357,
// punctuation: 274,
// uppercase: 575,
// lowercase: 7959,
// digits: 203,
// capsLockWords: 49,
// stopWords: 0,
// stopWordsPercent: 0,
// junkWords: 0,
// junkPercent: 0,
// maxConsecutiveRepeats: 2,
// top5Words: 'of(42), for(39), the(35), and(33), on(27)',
// spamRatio: 0.03,
// hasHtml: false,
// hasEmail: true,
// hasUrl: true,
// hasDate: false,
// hasPhone: true,
// startsEqualsEnd: false,
// isPalindrome: false,
// hasQuestion: true,
// hasExclamation: false,
// paragraphs: 3,
// lines: 191,
// latinPercent: 100,
// cyrillicPercent: 0,
// longWords16plus: 0,
// },
// certificate: {
// id: '1',
// verificationCode: 'PLAG-9001-VERIFY',
// issuerName: 'Global Plagiarism Checker',
// issuedAt: '2026-03-30',
// expiresAt: '2027-03-30',
// downloadUrl: '/certificates/cert-9001.pdf',
// },
// };
// // ─── Blue palette ─────────────────────────────────────────────────────────────
// const blue = {
// 50: '#E6F1FB',
// 100: '#B5D4F4',
// 200: '#85B7EB',
// 400: '#378ADD',
// 600: '#185FA5',
// 800: '#0C447C',
// 900: '#042C53',
// };
// // ─── Small helpers ────────────────────────────────────────────────────────────
// function Divider() {
// return (
// <hr
// style={{
// border: 'none',
// borderTop: `0.5px solid ${blue[100]}`,
// margin: '1.25rem 0',
// }}
// />
// );
// }
// function SectionTitle({ children }: { children: React.ReactNode }) {
// return (
// <p
// className="text-[12px] font-medium uppercase tracking-widest mb-3"
// style={{ color: blue[400] }}
// >
// {children}
// </p>
// );
// }
// function MetricCard({ label, value }: { label: string; value: string }) {
// return (
// <div className="rounded-lg p-3" style={{ background: blue[50] }}>
// <p className="text-[11px] mb-1" style={{ color: blue[600] }}>
// {label}
// </p>
// <p className="text-lg font-medium" style={{ color: blue[900] }}>
// {value}
// </p>
// </div>
// );
// }
// function BarRow({
// label,
// value,
// color,
// }: {
// label: string;
// value: number;
// color: string;
// }) {
// return (
// <div className="mb-2.5">
// <div className="flex justify-between items-center text-sm mb-1">
// <span
// className="flex items-center gap-1.5"
// style={{ color: blue[600] }}
// >
// <span
// className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
// style={{ background: color }}
// />
// {label}
// </span>
// <span className="font-medium" style={{ color: blue[900] }}>
// {value}%
// </span>
// </div>
// <div
// className="h-1.5 rounded-full overflow-hidden"
// style={{ background: blue[100] }}
// >
// <div
// className="h-full rounded-full"
// style={{
// width: `${value}%`,
// background: color,
// transition: 'width 0.8s ease',
// }}
// />
// </div>
// </div>
// );
// }
// function CircleGauge({ value }: { value: number }) {
// const r = 48;
// const circ = 2 * Math.PI * r;
// const offset = circ - (value / 100) * circ;
// return (
// <svg
// width="120"
// height="120"
// viewBox="0 0 120 120"
// aria-label={`${value}% plagiat`}
// >
// <circle
// cx="60"
// cy="60"
// r={r}
// fill="none"
// stroke={blue[100]}
// strokeWidth="12"
// />
// <circle
// cx="60"
// cy="60"
// r={r}
// fill="none"
// stroke={blue[900]}
// strokeWidth="12"
// strokeDasharray={circ}
// strokeDashoffset={offset}
// strokeLinecap="round"
// transform="rotate(-90 60 60)"
// style={{ transition: 'stroke-dashoffset 0.9s ease' }}
// />
// <text
// x="60"
// y="56"
// textAnchor="middle"
// fontSize="22"
// fontWeight="500"
// fill={blue[900]}
// >
// {value}%
// </text>
// <text x="60" y="72" textAnchor="middle" fontSize="10" fill={blue[400]}>
// plagiat
// </text>
// </svg>
// );
// }
// // ─── Semantic modal ───────────────────────────────────────────────────────────
// function SemanticModal({
// data,
// onClose,
// }: {
// data: SemanticMetrics | null;
// onClose: () => void;
// }) {
// const rows: [string, string][] | null = data
// ? [
// ["Umumiy so'zlar", String(data.totalWords)],
// ["Unikal so'zlar", String(data.uniqueWords)],
// ['Leksik unikalligi (%)', data.lexicalUniqueness.toFixed(2)],
// ["O'rtacha so'z uzunligi", data.avgWordLength.toFixed(2)],
// ["Geometrik o'rtacha uzunlik", data.geoAvgWordLength.toFixed(2)],
// ["Min. so'z uzunligi", String(data.minWordLength)],
// ["Maks. so'z uzunligi", String(data.maxWordLength)],
// ['Jumlalar soni', String(data.sentences)],
// ["O'rtacha so'z/jumla", data.avgWordsPerSentence.toFixed(2)],
// ["Ko'p bo'g'inli so'zlar", String(data.polysyllabicWords)],
// ["Ko'p bo'g'inli ulushi (%)", data.polysyllabicPercent.toFixed(2)],
// ['Jami belgilar', String(data.totalChars)],
// ["Bo'shliqlarsiz", String(data.charsNoSpaces)],
// ['Unlilar', String(data.vowels)],
// ['Undoshlar', String(data.consonants)],
// ['Tinish belgilari', String(data.punctuation)],
// ['Bosh harflar', String(data.uppercase)],
// ['Kichik harflar', String(data.lowercase)],
// ['Raqamlar', String(data.digits)],
// ["CAPSLOCK so'zlar", String(data.capsLockWords)],
// ["Stop-so'zlar", String(data.stopWords)],
// ["Stop-so'zlar ulushi (%)", data.stopWordsPercent.toFixed(2)],
// ["Keraksiz so'zlar", String(data.junkWords)],
// ['Keraksiz ulushi (%)', data.junkPercent.toFixed(2)],
// ['Maks. ketma-ket takrorlar', String(data.maxConsecutiveRepeats)],
// ["TOP-5 so'zlar", data.top5Words],
// ['Spam darajasi', data.spamRatio.toFixed(2)],
// ['HTML teglar', data.hasHtml ? 'Ha' : "Yo'q"],
// ['Email topildi', data.hasEmail ? 'Ha' : "Yo'q"],
// ['URL topildi', data.hasUrl ? 'Ha' : "Yo'q"],
// ['Sana topildi', data.hasDate ? 'Ha' : "Yo'q"],
// ['Telefon topildi', data.hasPhone ? 'Ha' : "Yo'q"],
// ['Boshi = Oxiri', data.startsEqualsEnd ? 'Ha' : "Yo'q"],
// ['Palindrom', data.isPalindrome ? 'Ha' : "Yo'q"],
// ['Savol belgisi', data.hasQuestion ? 'Ha' : "Yo'q"],
// ['Undov belgisi', data.hasExclamation ? 'Ha' : "Yo'q"],
// ['Paragraflar', String(data.paragraphs)],
// ['Qatorlar', String(data.lines)],
// ['Lotin ulushi (%)', data.latinPercent.toFixed(2)],
// ['Kirill ulushi (%)', data.cyrillicPercent.toFixed(2)],
// ["16+ belgili so'zlar", String(data.longWords16plus)],
// ]
// : [];
// return (
// <div
// className="fixed inset-0 z-50 flex items-center justify-center p-4"
// style={{ background: 'rgba(4,44,83,0.45)' }}
// onClick={onClose}
// >
// <div
// className="w-full max-w-xl rounded-xl overflow-hidden"
// style={{
// background: '#fff',
// border: `0.5px solid ${blue[100]}`,
// maxHeight: '80vh',
// display: 'flex',
// flexDirection: 'column',
// }}
// onClick={(e) => e.stopPropagation()}
// >
// {/* Modal header */}
// <div
// className="flex items-center justify-between px-5 py-4"
// style={{ borderBottom: `0.5px solid ${blue[100]}` }}
// >
// <span
// className="text-[14px] font-medium"
// style={{ color: blue[900] }}
// >
// Semantik tahlil
// </span>
// <button
// onClick={onClose}
// className="text-xl leading-none cursor-pointer"
// style={{ color: blue[400], background: 'none', border: 'none' }}
// >
// ×
// </button>
// </div>
// {/* Modal body */}
// <div className="overflow-y-auto flex-1">
// <table className="w-full text-sm">
// <thead>
// <tr style={{ background: blue[50] }}>
// <th
// className="text-left px-5 py-2.5 font-medium"
// style={{ color: blue[600] }}
// >
// Metrika
// </th>
// <th
// className="text-right px-5 py-2.5 font-medium"
// style={{ color: blue[600] }}
// >
// Qiymat
// </th>
// </tr>
// </thead>
// <tbody>
// {rows.map(([label, value], i) => (
// <tr
// key={label}
// style={{
// background: i % 2 === 0 ? '#fff' : blue[50],
// borderBottom: `0.5px solid ${blue[100]}`,
// }}
// >
// <td className="px-5 py-2" style={{ color: blue[800] }}>
// {label}
// </td>
// <td
// className="px-5 py-2 text-right font-medium"
// style={{ color: blue[900] }}
// >
// {value}
// </td>
// </tr>
// ))}
// </tbody>
// </table>
// </div>
// </div>
// </div>
// );
// }
// // ─── Main component ───────────────────────────────────────────────────────────
// export default function PlagiatResult({
// data = mockData,
// }: {
// data?: PlagiatData;
// }) {
// const [showSemantic, setShowSemantic] = useState(false);
// const [saving, setSaving] = useState(false);
// const handleSave = () => {
// setSaving(true);
// setTimeout(() => setSaving(false), 1500);
// };
// const handleRecheck = () => {
// alert("Yangi fayl yuklash sahifasiga yo'naltirish...");
// };
// return (
// <>
// {showSemantic && (
// <SemanticModal
// data={data.semantic}
// onClose={() => setShowSemantic(false)}
// />
// )}
// <div className="min-h-screen flex items-start justify-center p-6 bg-slate-50">
// <div
// className="w-full max-w-2xl rounded-xl p-5"
// style={{ background: '#fff', border: `0.5px solid ${blue[100]}` }}
// >
// {/* ── Certificate (top) ── */}
// {data.certificate && (
// <div
// className="flex items-center gap-3 rounded-lg p-3.5 mb-5"
// style={{
// background: blue[50],
// border: `0.5px solid ${blue[100]}`,
// }}
// >
// <div
// className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
// style={{ background: blue[600] }}
// >
// <svg
// width="16"
// height="16"
// viewBox="0 0 18 18"
// fill="none"
// aria-hidden="true"
// >
// <path
// d="M9 1L11.2 6.5H17L12.4 10L14.1 16L9 12.8L3.9 16L5.6 10L1 6.5H6.8L9 1Z"
// fill="#E6F1FB"
// />
// </svg>
// </div>
// <div className="flex-1 min-w-0">
// <p
// className="text-[13px] font-medium"
// style={{ color: blue[900] }}
// >
// {data.certificate.issuerName} sertifikati
// </p>
// <p
// className="text-[11px] mt-0.5 truncate"
// style={{ color: blue[600], fontFamily: 'monospace' }}
// >
// {data.certificate.verificationCode}
// {data.certificate.expiresAt}
// </p>
// </div>
// <a
// href={data.certificate.downloadUrl}
// className="text-xs px-3 py-1.5 rounded-md shrink-0 transition-colors no-underline"
// style={{
// border: `0.5px solid ${blue[400]}`,
// color: blue[600],
// }}
// >
// Yuklab olish ↗
// </a>
// </div>
// )}
// {/* ── Header ── */}
// <div className="flex items-center gap-3 mb-5">
// <div
// className="w-11 h-11 rounded-full flex items-center justify-center text-sm font-medium shrink-0"
// style={{ background: blue[50], color: blue[600] }}
// >
// {data.initials}
// </div>
// <div className="flex-1 min-w-0">
// <div className="flex items-center gap-2 flex-wrap">
// <span
// className="text-[15px] font-medium"
// style={{ color: blue[900] }}
// >
// {data.name}
// </span>
// <span
// className="text-xs px-2.5 py-0.5 rounded-md font-medium"
// style={{ background: blue[100], color: blue[800] }}
// >
// {data.plagiarismPercent}% plagiat
// </span>
// </div>
// <p
// className="text-[13px] mt-0.5 truncate"
// style={{ color: blue[400] }}
// >
// {data.fileName} · {data.email} · {data.location}
// </p>
// </div>
// <div className="text-right shrink-0">
// <p className="text-[11px]" style={{ color: blue[400] }}>
// Tekshirilgan
// </p>
// <p className="text-[12px] mt-0.5" style={{ color: blue[600] }}>
// {data.checkedAt}
// </p>
// </div>
// </div>
// {/* ── Human vs AI bar ── */}
// <div className="mb-5">
// <div
// className="flex justify-between text-xs mb-1.5"
// style={{ color: blue[600] }}
// >
// <span>Inson: {data.humanPercent}%</span>
// <span>II: {data.aiPercent}%</span>
// </div>
// <div
// className="h-3 rounded-full overflow-hidden flex"
// style={{ background: blue[100] }}
// >
// <div
// className="h-full"
// style={{
// width: `${data.humanPercent}%`,
// background: `linear-gradient(to right, ${blue[600]}, ${blue[400]})`,
// transition: 'width 0.8s ease',
// }}
// />
// <div
// className="h-full flex-1"
// style={{ background: '#e879a0' }}
// />
// </div>
// </div>
// <Divider />
// {/* ── Top metrics ── */}
// <div className="grid grid-cols-4 gap-2.5 mb-5">
// <MetricCard
// label="Plagiat darajasi"
// value={`${data.plagiarismPercent}%`}
// />
// <MetricCard label="AI yozgan" value={`${data.aiPercent}%`} />
// <MetricCard
// label="Originallik"
// value={`${data.originalityPercent}%`}
// />
// <MetricCard label="Iqtibos" value={`${data.citationPercent}%`} />
// </div>
// {/* ── Gauge + bars ── */}
// <div className="flex items-center gap-5 mb-5">
// <CircleGauge value={data.plagiarismPercent} />
// <div className="flex-1">
// <BarRow
// label="Plagiat"
// value={data.plagiarismPercent}
// color={blue[900]}
// />
// <BarRow
// label="AI generatsiya"
// value={data.aiPercent}
// color={blue[600]}
// />
// <BarRow
// label="Original"
// value={data.originalityPercent}
// color={blue[400]}
// />
// <BarRow
// label="Iqtibos"
// value={data.citationPercent}
// color={blue[200]}
// />
// </div>
// </div>
// <Divider />
// {/* ── Sources list ── */}
// <SectionTitle>Manbalar ro'yxati</SectionTitle>
// {data.sources.length > 0 ? (
// <div className="mb-5">
// <table className="w-full text-sm">
// <thead>
// <tr style={{ borderBottom: `0.5px solid ${blue[100]}` }}>
// <th
// className="text-left pb-2 font-medium"
// style={{ color: blue[600] }}
// >
// URL
// </th>
// <th
// className="text-right pb-2 font-medium"
// style={{ color: blue[600] }}
// >
// Modul
// </th>
// </tr>
// </thead>
// <tbody>
// {data.sources.map((s, i) => (
// <tr
// key={s.url}
// style={{ borderBottom: `0.5px solid ${blue[50]}` }}
// >
// <td className="py-1.5 pr-4">
// <div className="flex items-center gap-2">
// <div className="w-1.5 h-1.5 rounded-full shrink-0 bg-blue-600" />
// <span
// className="text-[12px] truncate max-w-95 block"
// style={{ color: blue[800] }}
// >
// {s.url}
// </span>
// </div>
// </td>
// <td className="py-1.5 text-right text-[12px] whitespace-nowrap text-blue-500">
// {s.module}
// </td>
// </tr>
// ))}
// </tbody>
// </table>
// </div>
// ) : (
// <EmptySourcesList />
// )}
// <Divider />
// {/* ── Highlighted text ── */}
// <SectionTitle>Matn ko'rish</SectionTitle>
// <div
// className="rounded-lg p-4 text-sm leading-relaxed mb-5"
// style={{
// background: blue[50],
// border: `0.5px solid ${blue[100]}`,
// color: blue[900],
// lineHeight: '1.8',
// }}
// >
// {data.highlightedText.map((seg, i) =>
// seg.plagiarized ? (
// <mark
// key={i}
// style={{
// background: '#FEE2E2',
// color: '#991B1B',
// borderRadius: '3px',
// padding: '1px 2px',
// }}
// >
// {seg.text}
// </mark>
// ) : (
// <span key={i}>{seg.text}</span>
// ),
// )}
// <p className="mt-3 text-[11px]" style={{ color: blue[400] }}>
// <span
// className="inline-block w-3 h-3 rounded-sm mr-1 align-middle"
// style={{ background: '#FEE2E2', border: '1px solid #FCA5A5' }}
// />
// Qizil bilan belgilangan qismlar plagiat deb topilgan.
// </p>
// </div>
// {/* ── 3 Buttons ── */}
// <div className="flex gap-2.5 flex-wrap mb-5">
// <button
// onClick={handleSave}
// className="text-sm px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer"
// style={{
// background: saving ? blue[800] : blue[900],
// color: '#fff',
// border: 'none',
// }}
// >
// {saving ? 'Saqlanmoqda...' : 'Hisobotni saqlash'}
// </button>
// <button
// onClick={() => setShowSemantic(true)}
// className="text-sm px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer"
// style={{
// background: blue[600],
// color: '#fff',
// border: 'none',
// }}
// >
// Semantik tahlil
// </button>
// <button
// onClick={handleRecheck}
// className="text-sm px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer"
// style={{
// background: 'transparent',
// color: blue[600],
// border: `0.5px solid ${blue[400]}`,
// }}
// >
// Yana bir fayl tekshirish
// </button>
// </div>
// <Divider />
// {/* ── Text analysis mini metrics ── */}
// <SectionTitle>Matn tahlili</SectionTitle>
// {data.semantic ? (
// <div className="grid grid-cols-3 gap-2">
// <MetricCard
// label="Jami so'z"
// value={data.semantic.totalWords.toLocaleString()}
// />
// <MetricCard
// label="Unikal so'z"
// value={data.semantic.uniqueWords.toLocaleString()}
// />
// <MetricCard
// label="Leksik unikalligi"
// value={`${data.semantic.lexicalUniqueness.toFixed(1)}%`}
// />
// <MetricCard
// label="Jumlalar"
// value={String(data.semantic.sentences)}
// />
// <MetricCard
// label="O'rt. so'z/juml."
// value={data.semantic.avgWordsPerSentence.toFixed(1)}
// />
// <MetricCard
// label="Qatorlar"
// value={String(data.semantic.lines)}
// />
// </div>
// ) : (
// <EmptySourcesList />
// )}
// </div>
// </div>
// </>
// );
// }

View File

@@ -0,0 +1,42 @@
import { blue } from '../../lib/constant';
interface Props {
label: string;
value: number;
color: string;
}
export default function BarRow({ label, value, color }: Props) {
return (
<div className="mb-2.5">
<div className="flex justify-between items-center text-sm mb-1">
<span
className="flex items-center gap-1.5"
style={{ color: blue[600] }}
>
<span
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: color }}
/>
{label}
</span>
<span className="font-medium" style={{ color: blue[900] }}>
{value}%
</span>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ background: blue[100] }}
>
<div
className="h-full rounded-full"
style={{
width: `${value}%`,
background: color,
transition: 'width 0.8s ease',
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { blue } from '../../lib/constant';
export default function CircleGauge({ value }: { value: number }) {
const r = 48;
const circ = 2 * Math.PI * r;
const offset = circ - (value / 100) * circ;
return (
<svg
width="120"
height="120"
viewBox="0 0 120 120"
aria-label={`${value}% plagiat`}
>
<circle
cx="60"
cy="60"
r={r}
fill="none"
stroke={blue[100]}
strokeWidth="12"
/>
<circle
cx="60"
cy="60"
r={r}
fill="none"
stroke={blue[900]}
strokeWidth="12"
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 60 60)"
style={{ transition: 'stroke-dashoffset 0.9s ease' }}
/>
<text
x="60"
y="56"
textAnchor="middle"
fontSize="22"
fontWeight="500"
fill={blue[900]}
>
{value}%
</text>
<text x="60" y="72" textAnchor="middle" fontSize="10" fill={blue[400]}>
plagiat
</text>
</svg>
);
}

View File

@@ -0,0 +1,13 @@
import { blue } from '../../lib/constant';
export default function Divider() {
return (
<hr
style={{
border: 'none',
borderTop: `0.5px solid ${blue[100]}`,
margin: '1.25rem 0',
}}
/>
);
}

View File

@@ -0,0 +1,44 @@
import { blue } from '../../lib/constant';
import { PlagiatData } from '../../lib/types';
import BarRow from './BarRow';
import CircleGauge from './CircleGauge';
type Props = Pick<
PlagiatData,
'plagiarismPercent' | 'aiPercent' | 'originalityPercent' | 'citationPercent'
>;
export default function GaugeWithBars({
plagiarismPercent,
aiPercent,
originalityPercent,
citationPercent,
}: Props) {
return (
<div className="flex items-center gap-5 mb-5">
<CircleGauge value={plagiarismPercent || 0} />
<div className="flex-1">
<BarRow
label="Plagiat"
value={plagiarismPercent || 0}
color={blue[900]}
/>
<BarRow
label="AI generatsiya"
value={aiPercent || 0}
color={blue[600]}
/>
<BarRow
label="Original"
value={originalityPercent || 0}
color={blue[400]}
/>
<BarRow
label="Iqtibos"
value={citationPercent || 0}
color={blue[200]}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { blue } from '../../lib/constant';
import { PlagiatData } from '../../lib/types';
type Props = Pick<
PlagiatData,
| 'initials'
| 'name'
| 'plagiarismPercent'
| 'fileName'
| 'email'
| 'location'
| 'checkedAt'
>;
export default function Header({
initials,
name,
plagiarismPercent,
fileName,
email,
location,
checkedAt,
}: Props) {
return (
<div className="flex items-center gap-3 mb-5">
<div
className="w-11 h-11 rounded-full flex items-center justify-center text-sm font-medium shrink-0"
style={{ background: blue[50], color: blue[600] }}
>
{initials}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[15px] font-medium"
style={{ color: blue[900] }}
>
{name}
</span>
<span
className="text-xs px-2.5 py-0.5 rounded-md font-medium"
style={{ background: blue[100], color: blue[800] }}
>
{plagiarismPercent}% plagiat
</span>
</div>
<p className="text-[13px] mt-0.5 truncate" style={{ color: blue[400] }}>
{fileName} · {email} · {location}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-[11px]" style={{ color: blue[400] }}>
Tekshirilgan
</p>
<p className="text-[12px] mt-0.5" style={{ color: blue[600] }}>
{checkedAt}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { blue } from '../../lib/constant';
import { HighlightSegment } from '../../lib/types';
import SectionTitle from './SectionTitle';
export default function HighlightedText({
segments,
}: {
segments: HighlightSegment[];
}) {
return (
<>
<SectionTitle>Matn ko&apos;rish</SectionTitle>
<div
className="rounded-lg p-4 text-sm leading-relaxed mb-5"
style={{
background: blue[50],
border: `0.5px solid ${blue[100]}`,
color: blue[900],
lineHeight: '1.8',
}}
>
{segments.map((seg, i) =>
seg.plagiarized ? (
<mark
key={i}
style={{
background: '#FEE2E2',
color: '#991B1B',
borderRadius: '3px',
padding: '1px 2px',
}}
>
{seg.text}
</mark>
) : (
<span key={i}>{seg.text}</span>
),
)}
<p className="mt-3 text-[11px]" style={{ color: blue[400] }}>
<span
className="inline-block w-3 h-3 rounded-sm mr-1 align-middle"
style={{ background: '#FEE2E2', border: '1px solid #FCA5A5' }}
/>
Qizil bilan belgilangan qismlar plagiat deb topilgan.
</p>
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { blue } from '../../lib/constant';
interface Props {
humanPercent: number;
aiPercent: number;
}
export default function HumanAiBar({ humanPercent, aiPercent }: Props) {
return (
<div className="mb-5">
<div
className="flex justify-between text-xs mb-1.5"
style={{ color: blue[600] }}
>
<span>Inson: {humanPercent}%</span>
<span>II: {aiPercent}%</span>
</div>
<div
className="h-3 rounded-full overflow-hidden flex"
style={{ background: blue[100] }}
>
<div
className="h-full"
style={{
width: `${humanPercent}%`,
background: `linear-gradient(to right, ${blue[600]}, ${blue[400]})`,
transition: 'width 0.8s ease',
}}
/>
<div className="h-full flex-1" style={{ background: '#e879a0' }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { blue } from '../../lib/constant';
interface Props {
label: string;
value: string;
}
export default function MetricCard({ label, value }: Props) {
return (
<div className="rounded-lg p-3" style={{ background: blue[50] }}>
<p className="text-[11px] mb-1" style={{ color: blue[600] }}>
{label}
</p>
<p className="text-lg font-medium" style={{ color: blue[900] }}>
{value}
</p>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { blue } from '../../lib/constant';
export default function SectionTitle({
children,
}: {
children: React.ReactNode;
}) {
return (
<p
className="text-[12px] font-medium uppercase tracking-widest mb-3"
style={{ color: blue[400] }}
>
{children}
</p>
);
}

View File

@@ -0,0 +1,133 @@
import { blue } from '../../lib/constant';
import { SemanticMetrics } from '../../lib/types';
interface Props {
data: SemanticMetrics;
onClose: () => void;
}
export default function SemanticModal({ data, onClose }: Props) {
const rows: [string, string][] = [
["Umumiy so'zlar", String(data.totalWords)],
["Unikal so'zlar", String(data.uniqueWords)],
['Leksik unikalligi (%)', data.lexicalUniqueness.toFixed(2)],
["O'rtacha so'z uzunligi", data.avgWordLength.toFixed(2)],
["Geometrik o'rtacha uzunlik", data.geoAvgWordLength.toFixed(2)],
["Min. so'z uzunligi", String(data.minWordLength)],
["Maks. so'z uzunligi", String(data.maxWordLength)],
['Jumlalar soni', String(data.sentences)],
["O'rtacha so'z/jumla", data.avgWordsPerSentence.toFixed(2)],
["Ko'p bo'g'inli so'zlar", String(data.polysyllabicWords)],
["Ko'p bo'g'inli ulushi (%)", data.polysyllabicPercent.toFixed(2)],
['Jami belgilar', String(data.totalChars)],
["Bo'shliqlarsiz", String(data.charsNoSpaces)],
['Unlilar', String(data.vowels)],
['Undoshlar', String(data.consonants)],
['Tinish belgilari', String(data.punctuation)],
['Bosh harflar', String(data.uppercase)],
['Kichik harflar', String(data.lowercase)],
['Raqamlar', String(data.digits)],
["CAPSLOCK so'zlar", String(data.capsLockWords)],
["Stop-so'zlar", String(data.stopWords)],
["Stop-so'zlar ulushi (%)", data.stopWordsPercent.toFixed(2)],
["Keraksiz so'zlar", String(data.junkWords)],
['Keraksiz ulushi (%)', data.junkPercent.toFixed(2)],
['Maks. ketma-ket takrorlar', String(data.maxConsecutiveRepeats)],
["TOP-5 so'zlar", data.top5Words],
['Spam darajasi', data.spamRatio.toFixed(2)],
['HTML teglar', data.hasHtml ? 'Ha' : "Yo'q"],
['Email topildi', data.hasEmail ? 'Ha' : "Yo'q"],
['URL topildi', data.hasUrl ? 'Ha' : "Yo'q"],
['Sana topildi', data.hasDate ? 'Ha' : "Yo'q"],
['Telefon topildi', data.hasPhone ? 'Ha' : "Yo'q"],
['Boshi = Oxiri', data.startsEqualsEnd ? 'Ha' : "Yo'q"],
['Palindrom', data.isPalindrome ? 'Ha' : "Yo'q"],
['Savol belgisi', data.hasQuestion ? 'Ha' : "Yo'q"],
['Undov belgisi', data.hasExclamation ? 'Ha' : "Yo'q"],
['Paragraflar', String(data.paragraphs)],
['Qatorlar', String(data.lines)],
['Lotin ulushi (%)', data.latinPercent.toFixed(2)],
['Kirill ulushi (%)', data.cyrillicPercent.toFixed(2)],
["16+ belgili so'zlar", String(data.longWords16plus)],
];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(4,44,83,0.45)' }}
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-xl overflow-hidden"
style={{
background: '#fff',
border: `0.5px solid ${blue[100]}`,
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: `0.5px solid ${blue[100]}` }}
>
<span
className="text-[14px] font-medium"
style={{ color: blue[900] }}
>
Semantik tahlil
</span>
<button
onClick={onClose}
className="text-xl leading-none cursor-pointer"
style={{ color: blue[400], background: 'none', border: 'none' }}
>
×
</button>
</div>
<div className="overflow-y-auto flex-1">
<table className="w-full text-sm">
<thead>
<tr style={{ background: blue[50] }}>
<th
className="text-left px-5 py-2.5 font-medium"
style={{ color: blue[600] }}
>
Metrika
</th>
<th
className="text-right px-5 py-2.5 font-medium"
style={{ color: blue[600] }}
>
Qiymat
</th>
</tr>
</thead>
<tbody>
{rows.map(([label, value], i) => (
<tr
key={label}
style={{
background: i % 2 === 0 ? '#fff' : blue[50],
borderBottom: `0.5px solid ${blue[100]}`,
}}
>
<td className="px-5 py-2" style={{ color: blue[800] }}>
{label}
</td>
<td
className="px-5 py-2 text-right font-medium"
style={{ color: blue[900] }}
>
{value}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { blue } from '../../lib/constant';
import { Certificate } from '../../lib/types';
export default function CertificateCard({
certificate,
}: {
certificate: Certificate;
}) {
return (
<div
className="flex items-center gap-3 rounded-lg p-3.5 mb-5"
style={{ background: blue[50], border: `0.5px solid ${blue[100]}` }}
>
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: blue[600] }}
>
<svg
width="16"
height="16"
viewBox="0 0 18 18"
fill="none"
aria-hidden="true"
>
<path
d="M9 1L11.2 6.5H17L12.4 10L14.1 16L9 12.8L3.9 16L5.6 10L1 6.5H6.8L9 1Z"
fill="#E6F1FB"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium" style={{ color: blue[900] }}>
{certificate.issuerName} sertifikati
</p>
<p
className="text-[11px] mt-0.5 truncate"
style={{ color: blue[600], fontFamily: 'monospace' }}
>
{certificate.verificationCode}
</p>
</div>
<a
href={certificate.downloadUrl}
className="text-xs px-3 py-1.5 rounded-md shrink-0 transition-colors no-underline"
style={{ border: `0.5px solid ${blue[400]}`, color: blue[600] }}
>
Yuklab olish
</a>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { blue } from '../../lib/constant';
import { Source } from '../../lib/types';
import EmptySourcesList from '../emptyList';
import SectionTitle from './SectionTitle';
export default function SourcesList({ sources }: { sources: Source[] }) {
return (
<>
<SectionTitle>Manbalar ro&apos;yxati</SectionTitle>
{sources.length > 0 ? (
<div className="mb-5">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: `0.5px solid ${blue[100]}` }}>
<th
className="text-left pb-2 font-medium"
style={{ color: blue[600] }}
>
URL
</th>
<th
className="text-right pb-2 font-medium"
style={{ color: blue[600] }}
>
Modul
</th>
</tr>
</thead>
<tbody>
{sources.map((s, i) => (
<tr
key={s.url}
style={{ borderBottom: `0.5px solid ${blue[50]}` }}
>
<td className="py-1.5 pr-4">
<div className="flex items-center gap-2">
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: i < 3 ? blue[900] : blue[200] }}
/>
<span
className="text-[12px] truncate max-w-95 block"
style={{ color: blue[800] }}
>
{s.url}
</span>
</div>
</td>
<td
className="py-1.5 text-right text-[12px] whitespace-nowrap"
style={{ color: blue[600] }}
>
{s.module}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptySourcesList />
)}
</>
);
}

View File

@@ -0,0 +1,38 @@
import { SemanticMetrics } from '../../lib/types';
import MetricCard from './Metriccard';
import SectionTitle from './SectionTitle';
export default function TextAnalysis({
semantic,
}: {
semantic?: SemanticMetrics;
}) {
if (semantic === undefined) {
return <p>Ma&apos;lumot topilmadi</p>;
}
return (
<>
<SectionTitle>Matn tahlili</SectionTitle>
<div className="grid grid-cols-3 gap-2">
<MetricCard
label="Jami so'z"
value={semantic.totalWords.toLocaleString()}
/>
<MetricCard
label="Unikal so'z"
value={semantic.uniqueWords.toLocaleString()}
/>
<MetricCard
label="Leksik unikalligi"
value={`${semantic.lexicalUniqueness.toFixed(1)}%`}
/>
<MetricCard label="Jumlalar" value={String(semantic.sentences)} />
<MetricCard
label="O'rt. so'z/juml."
value={semantic.avgWordsPerSentence.toFixed(1)}
/>
<MetricCard label="Qatorlar" value={String(semantic.lines)} />
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import { PlagiatData } from '../../lib/types';
import MetricCard from './Metriccard';
type Props = Pick<
PlagiatData,
'plagiarismPercent' | 'aiPercent' | 'originalityPercent' | 'citationPercent'
>;
export default function TopMetrics({
plagiarismPercent,
aiPercent,
originalityPercent,
citationPercent,
}: Props) {
return (
<div className="grid grid-cols-4 gap-2.5 mb-5">
<MetricCard label="Plagiat darajasi" value={`${plagiarismPercent}%`} />
<MetricCard label="AI yozgan" value={`${aiPercent}%`} />
<MetricCard label="Originallik" value={`${originalityPercent}%`} />
<MetricCard label="Iqtibos" value={`${citationPercent}%`} />
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { blue } from '../../lib/constant';
interface Props {
saving: boolean;
onSave: () => void;
onSemantic: () => void;
onRecheck: () => void;
}
export default function ActionButtons({
saving,
onSave,
onSemantic,
onRecheck,
}: Props) {
return (
<div className="flex gap-2.5 flex-wrap mb-5">
<button
onClick={onSave}
className="text-sm px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer"
style={{
background: saving ? blue[800] : blue[900],
color: '#fff',
border: 'none',
}}
>
{saving ? 'Saqlanmoqda...' : 'Hisobotni saqlash'}
</button>
<button
onClick={onSemantic}
className="text-sm px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer"
style={{ background: blue[600], color: '#fff', border: 'none' }}
>
Semantik tahlil
</button>
<button
onClick={onRecheck}
className="text-sm px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer"
style={{
background: 'transparent',
color: blue[600],
border: `0.5px solid ${blue[400]}`,
}}
>
Yana bir fayl tekshirish
</button>
</div>
);
}

View File

@@ -1,510 +0,0 @@
// ─────────────────────────────────────────────────────────────
// 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>
);
};

View File

@@ -0,0 +1,30 @@
import { Search } from 'lucide-react';
export default function EmptySourcesList() {
return (
<div
className="flex flex-col items-center justify-center py-10 rounded-lg"
style={{ background: 'var(--color-background-secondary)' }}
>
<div
className="w-10 h-10 rounded-full flex items-center justify-center mb-3"
style={{ background: 'var(--color-background-tertiary)' }}
>
<Search
size={18}
style={{ color: 'var(--color-text-tertiary)' }}
strokeWidth={1.5}
/>
</div>
<p
className="text-sm font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Manbalar topilmadi
</p>
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
Plagiat manbalari aniqlanmagan
</p>
</div>
);
}

View File

@@ -0,0 +1,294 @@
'use client';
import { useState } from 'react';
import { PlagiatData, HighlightSegment, SemanticMetrics } from '../lib/types';
import TextAnalysis from './components/TextAnalysis';
import ActionButtons from './components/actionbuttons';
import HighlightedText from './components/HighlightedText';
import SourcesList from './components/SourcesList';
import GaugeWithBars from './components/GaugewithBars';
import TopMetrics from './components/TopMetrics';
import HumanAiBar from './components/HumanAiBar';
import Header from './components/Header';
import CertificateCard from './components/SertificateCard';
import SemanticModal from './components/SemanticModal';
import Divider from './components/Divider';
import { blue } from '../lib/constant';
import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '@/shared/request/apiRequest';
import { links } from '@/shared/request/links';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseHighlightedText(textRes: string): HighlightSegment[] {
const segments: HighlightSegment[] = [];
const parts = textRes.split(/(<sel>[\s\S]*?<\/sel>)/g);
for (const part of parts) {
if (part.startsWith('<sel>')) {
segments.push({ text: part.replace(/<\/?sel>/g, ''), plagiarized: true });
} else if (part) {
segments.push({ text: part, plagiarized: false });
}
}
return segments;
}
function parseAnalyzeText(a: Record<string, unknown>): SemanticMetrics {
const bool = (v: unknown) => v === 'Да';
return {
totalWords: Number(a['Общее количество слов'] ?? 0),
uniqueWords: Number(a['Уникальных слов'] ?? 0),
lexicalUniqueness: Number(a['Лексическая уникальность (%)'] ?? 0),
avgWordLength: Number(a['Средняя длина слов'] ?? 0),
geoAvgWordLength: Number(a['Геометрическая средняя длина слов'] ?? 0),
minWordLength: Number(a['Мин. длина слова'] ?? 0),
maxWordLength: Number(a['Макс. длина слова'] ?? 0),
sentences: Number(a['Количество предложений'] ?? 0),
avgWordsPerSentence: Number(
a['Среднее количество слов в предложении'] ?? 0,
),
polysyllabicWords: Number(a['Полисиллабических слов'] ?? 0),
polysyllabicPercent: Number(a['Доля полисиллабических слов (%)'] ?? 0),
totalChars: Number(a['Символов всего'] ?? 0),
charsNoSpaces: Number(a['Без пробелов'] ?? 0),
vowels: Number(a['Гласных'] ?? 0),
consonants: Number(a['Согласных'] ?? 0),
punctuation: Number(a['Знаков препинания'] ?? 0),
uppercase: Number(a['Заглавных букв'] ?? 0),
lowercase: Number(a['Строчных букв'] ?? 0),
digits: Number(a['Цифр'] ?? 0),
capsLockWords: Number(a['Слов в CAPSLOCK'] ?? 0),
stopWords: Number(a['Стоп-слов'] ?? 0),
stopWordsPercent: Number(a['Доля стоп-слов (%)'] ?? 0),
junkWords: Number(a['Мусорные слова (шт)'] ?? 0),
junkPercent: Number(a['Доля мусора (%)'] ?? 0),
maxConsecutiveRepeats: Number(a['Макс. подряд повторов слова'] ?? 0),
top5Words: String(a['ТОП-5 слов'] ?? ''),
spamRatio: Number(a['Заспамленность (max/total)'] ?? 0),
hasHtml: bool(a['Наличие HTML-тегов']),
hasEmail: bool(a['Email найден']),
hasUrl: bool(a['URL найден']),
hasDate: bool(a['Дата найдена']),
hasPhone: bool(a['Телефон найден']),
startsEqualsEnd: bool(a['Начало = Конец']),
isPalindrome: bool(a['Палиндром']),
hasQuestion: bool(a['Вопросительный знак']),
hasExclamation: bool(a['Восклицательный знак']),
paragraphs: Number(a['Количество абзацев'] ?? 0),
lines: Number(a['Количество строк'] ?? 0),
latinPercent: Number(a['Доля латиницы (%)'] ?? 0),
cyrillicPercent: Number(a['Доля кириллицы (%)'] ?? 0),
longWords16plus: Number(a['Слов длиной 16+ символов'] ?? 0),
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformResponse(raw: any): PlagiatData {
const result = raw?.results?.[0];
const resultJson = result?.result_json;
const res = resultJson?.res ?? {};
const analyze = resultJson?.analyze_text ?? {};
const textRes = resultJson?.text_res ?? '';
const nameParts = (raw?.text ?? '').trim().split(/\s+/);
const initials = nameParts
.slice(0, 2)
.map((n: string) => n[0]?.toUpperCase() ?? '')
.join('');
return {
name: raw?.text ?? '',
initials: initials ?? '',
email: '',
location: '',
fileName: raw?.file?.split('/').pop() ?? '',
checkedAt: raw?.created_at?.slice(0, 10) ?? '',
aiPercent: res?.ai ?? 0,
humanPercent: res?.ai !== null ? 100 - res.ai : 0,
plagiarismPercent: res?.plagiarism ?? 0,
originalityPercent: res?.originality ?? 0,
citationPercent: res?.citation ?? 0,
highlightedText: textRes ? parseHighlightedText(textRes) : [],
sources: [],
semantic: Object.keys(analyze).length ? parseAnalyzeText(analyze) : null,
certificate: null,
};
}
// ─── Not found state ──────────────────────────────────────────────────────────
function NotFound({ label }: { label: string }) {
return (
<p className="text-[12px] italic" style={{ color: blue[400] }}>
{label} ma&apos;lumot mavjud emas
</p>
);
}
// ─── Loading skeleton ─────────────────────────────────────────────────────────
function Skeleton() {
return (
<div className="min-h-screen flex items-start justify-center p-6 bg-slate-50">
<div
className="w-full max-w-2xl rounded-xl p-5 space-y-4"
style={{ background: '#fff', border: `0.5px solid ${blue[100]}` }}
>
{[100, 60, 80, 40, 90, 55].map((w, i) => (
<div
key={i}
className="rounded-lg animate-pulse"
style={{ height: 18, width: `${w}%`, background: blue[50] }}
/>
))}
</div>
</div>
);
}
// ─── Error state ──────────────────────────────────────────────────────────────
function ErrorState() {
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div
className="rounded-xl p-8 text-center"
style={{ background: '#fff', border: `0.5px solid ${blue[100]}` }}
>
<p className="text-sm font-medium mb-1" style={{ color: blue[900] }}>
Ma&apos;lumot topilmadi
</p>
<p className="text-xs" style={{ color: blue[400] }}>
Ushbu tekshiruv mavjud emas yoki o&apos;chirilgan
</p>
</div>
</div>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function PlagiatResult({ id }: { id: number }) {
const [showSemantic, setShowSemantic] = useState(false);
const [saving, setSaving] = useState(false);
const {
data: rawData,
isLoading,
isError,
} = useQuery({
queryKey: ['detail', id],
queryFn: async () => {
const res = await apiRequest('GET', links.detail(id));
return res;
},
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
if (isLoading) return <Skeleton />;
if (isError || !rawData) return <ErrorState />;
const data: PlagiatData = transformResponse(rawData);
const handleSave = () => {
setSaving(true);
setTimeout(() => setSaving(false), 1500);
};
const handleRecheck = () => {
alert('Yangi fayl yuklash sahifasiga yo&apos;naltirish...');
};
return (
<>
{showSemantic && data.semantic && (
<SemanticModal
data={data.semantic}
onClose={() => setShowSemantic(false)}
/>
)}
<div className="min-h-screen flex items-start justify-center p-6 bg-slate-50">
<div
className="w-full max-w-2xl rounded-xl p-5"
style={{ background: '#fff', border: `0.5px solid ${blue[100]}` }}
>
{/* Certificate */}
{data.certificate ? (
<CertificateCard certificate={data.certificate} />
) : null}
{/* Header */}
{data.name ? (
<Header
initials={data.initials}
name={data.name}
plagiarismPercent={data.plagiarismPercent}
fileName={data.fileName}
email={data.email}
location={data.location}
checkedAt={data.checkedAt}
/>
) : (
<NotFound label="Foydalanuvchi ma'lumotlari" />
)}
{/* Human / AI bar */}
<HumanAiBar
humanPercent={data.humanPercent}
aiPercent={data.aiPercent}
/>
<Divider />
{/* Score cards */}
<TopMetrics
plagiarismPercent={data.plagiarismPercent}
aiPercent={data.aiPercent}
originalityPercent={data.originalityPercent}
citationPercent={data.citationPercent}
/>
{/* Gauge */}
<GaugeWithBars
plagiarismPercent={data.plagiarismPercent}
aiPercent={data.aiPercent}
originalityPercent={data.originalityPercent}
citationPercent={data.citationPercent}
/>
<Divider />
{/* Sources */}
<SourcesList sources={data.sources} />
<Divider />
{/* Highlighted text */}
{data.highlightedText.length > 0 ? (
<HighlightedText segments={data.highlightedText} />
) : (
<NotFound label="Matn tahlili" />
)}
{/* Buttons */}
<ActionButtons
saving={saving}
onSave={handleSave}
onSemantic={() => setShowSemantic(true)}
onRecheck={handleRecheck}
/>
<Divider />
{/* Semantic analysis */}
{data.semantic ? (
<TextAnalysis semantic={data.semantic} />
) : (
<NotFound label="Semantik tahlil" />
)}
</div>
</div>
</>
);
}

View File

@@ -11,6 +11,7 @@ import { useUserPlagiatStore } from '@/shared/zustand/user';
import { useMutation } from '@tanstack/react-query';
import { links } from '@/shared/request/links';
import { apiRequest } from '@/shared/request/apiRequest';
import { useRouter } from '@/shared/config/i18n/navigation';
// ─── Initial States ──────────────────────────────────────────────────────────
@@ -36,12 +37,17 @@ export function usePlagiarismForm() {
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
const [submission, setSubmission] =
useState<SubmissionState>(INITIAL_SUBMISSION);
const route = useRouter();
const checkdocumentRequest = useMutation({
mutationKey: ['plagiarismCheck'],
mutationFn: (data: FormData) =>
apiRequest('POST', links.plagiarismCheck, data),
onSuccess: () => {
onSuccess: (res) => {
console.log('uploda: ', res);
const resdata = res.data as { id: number; order_id: number };
console.log('order_id:', resdata.id);
route.push(`/${resdata.id}`);
setSubmission({ status: 'success', error: null });
setForm(INITIAL_FORM);
setIsPaymentOpen(false);