@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -208,5 +208,8 @@
|
||||
"total": "Total",
|
||||
"connecting": "Connecting to Payme…",
|
||||
"payButton": "Pay with Payme"
|
||||
}
|
||||
},
|
||||
"unknownUser": "Username not found",
|
||||
"file": "File",
|
||||
"upload":"Download certificate"
|
||||
}
|
||||
|
||||
@@ -208,5 +208,8 @@
|
||||
"total": "Итого",
|
||||
"connecting": "Подключение к Payme…",
|
||||
"payButton": "Оплатить через Payme"
|
||||
}
|
||||
},
|
||||
"unknownUser": "Имя пользователя не найдено",
|
||||
"file":"Файл",
|
||||
"upload":"Скачать сертификат"
|
||||
}
|
||||
|
||||
@@ -212,5 +212,8 @@ declare const messages: {
|
||||
connecting: 'Paymega ulanmoqda…';
|
||||
payButton: "Payme orqali to'lash";
|
||||
};
|
||||
unknownUser: 'Foydalanuvchi topilmadi';
|
||||
file: 'Fayl';
|
||||
upload: 'Sertifikatni yuklab olish';
|
||||
};
|
||||
export default messages;
|
||||
|
||||
@@ -208,5 +208,8 @@
|
||||
"total": "Jami",
|
||||
"connecting": "Paymega ulanmoqda…",
|
||||
"payButton": "Payme orqali to'lash"
|
||||
}
|
||||
},
|
||||
"unknownUser":"Foydalanuvchi topilmadi",
|
||||
"file":"Fayl",
|
||||
"upload":"Sertifikatni yuklab olish"
|
||||
}
|
||||
|
||||
@@ -3,4 +3,8 @@ export const links = {
|
||||
register: '/users/register/',
|
||||
plagiarismCheck: '/shared/documents/',
|
||||
history: '/shared/documents/list/',
|
||||
detail: (id: number) => `/shared/documents/${id}/`,
|
||||
payment: (order_id: number) => `/users/payme/link/${order_id}/`,
|
||||
sertifikat: (document_id: number) =>
|
||||
`/shared/certificate/${document_id}/pdf/`,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
260
src/widgets/detail/lib/formatter.ts
Normal file
260
src/widgets/detail/lib/formatter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
130
src/widgets/detail/lib/mock.ts
Normal file
130
src/widgets/detail/lib/mock.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
648
src/widgets/detail/pageDetail.tsx
Normal file
648
src/widgets/detail/pageDetail.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { links } from '@/shared/request/links';
|
||||
import { apiRequest } from '@/shared/request/apiRequest';
|
||||
import Sertifikat from './sertifikat';
|
||||
|
||||
// ── 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>)/g);
|
||||
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 wrap-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-16.25" />
|
||||
<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-16.25" />
|
||||
<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 && <Sertifikat document_id={Number(id)} />}
|
||||
{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 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 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>
|
||||
);
|
||||
}
|
||||
@@ -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‘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>
|
||||
);
|
||||
}
|
||||
782
src/widgets/detail/ui/PlagiatResult2.tsx
Normal file
782
src/widgets/detail/ui/PlagiatResult2.tsx
Normal 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>
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
42
src/widgets/detail/ui/components/BarRow.tsx
Normal file
42
src/widgets/detail/ui/components/BarRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/widgets/detail/ui/components/CircleGauge.tsx
Normal file
50
src/widgets/detail/ui/components/CircleGauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/widgets/detail/ui/components/Divider.tsx
Normal file
13
src/widgets/detail/ui/components/Divider.tsx
Normal 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
src/widgets/detail/ui/components/GaugewithBars.tsx
Normal file
44
src/widgets/detail/ui/components/GaugewithBars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/widgets/detail/ui/components/Header.tsx
Normal file
61
src/widgets/detail/ui/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/widgets/detail/ui/components/HighlightedText.tsx
Normal file
49
src/widgets/detail/ui/components/HighlightedText.tsx
Normal 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/widgets/detail/ui/components/HumanAiBar.tsx
Normal file
34
src/widgets/detail/ui/components/HumanAiBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/widgets/detail/ui/components/Metriccard.tsx
Normal file
19
src/widgets/detail/ui/components/Metriccard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/widgets/detail/ui/components/SectionTitle.tsx
Normal file
16
src/widgets/detail/ui/components/SectionTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/widgets/detail/ui/components/SemanticModal.tsx
Normal file
133
src/widgets/detail/ui/components/SemanticModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/widgets/detail/ui/components/SertificateCard.tsx
Normal file
51
src/widgets/detail/ui/components/SertificateCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/widgets/detail/ui/components/SourcesList.tsx
Normal file
65
src/widgets/detail/ui/components/SourcesList.tsx
Normal 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'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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/widgets/detail/ui/components/TextAnalysis.tsx
Normal file
38
src/widgets/detail/ui/components/TextAnalysis.tsx
Normal 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
src/widgets/detail/ui/components/TopMetrics.tsx
Normal file
23
src/widgets/detail/ui/components/TopMetrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/widgets/detail/ui/components/actionbuttons.tsx
Normal file
49
src/widgets/detail/ui/components/actionbuttons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
30
src/widgets/detail/ui/emptyList.tsx
Normal file
30
src/widgets/detail/ui/emptyList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
294
src/widgets/detail/ui/index.tsx
Normal file
294
src/widgets/detail/ui/index.tsx
Normal 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'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'lumot topilmadi
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: blue[400] }}>
|
||||
Ushbu tekshiruv mavjud emas yoki o'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
PlagiarismFormErrors,
|
||||
PlagiarismFormState,
|
||||
@@ -31,19 +31,58 @@ const INITIAL_SUBMISSION: SubmissionState = {
|
||||
|
||||
export function usePlagiarismForm() {
|
||||
const user = useUserPlagiatStore((state) => state.user);
|
||||
const [localUser, setLocalUser] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
surname: string;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
const data = localStorage.getItem('user');
|
||||
|
||||
if (data) {
|
||||
setLocalUser(JSON.parse(data));
|
||||
} else {
|
||||
setLocalUser(null);
|
||||
}
|
||||
}, [user]);
|
||||
const [form, setForm] = useState<PlagiarismFormState>(INITIAL_FORM);
|
||||
const [errors, setErrors] = useState<PlagiarismFormErrors>({});
|
||||
const [isPaymentOpen, setIsPaymentOpen] = useState(false);
|
||||
const [submission, setSubmission] =
|
||||
useState<SubmissionState>(INITIAL_SUBMISSION);
|
||||
// const route = useRouter();
|
||||
// const [document_id, setDocument_id] = useState<number>(0);
|
||||
const [order_id, setOrder_id] = useState<number>(0);
|
||||
|
||||
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);
|
||||
// setDocument_id(resdata.id);
|
||||
setOrder_id(resdata.order_id);
|
||||
setSubmission({ status: 'success', error: null });
|
||||
setForm(INITIAL_FORM);
|
||||
setIsPaymentOpen(true);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'An unexpected error occurred.';
|
||||
setSubmission({ status: 'error', error: message });
|
||||
},
|
||||
});
|
||||
|
||||
const payment = useMutation({
|
||||
mutationKey: ['payload'],
|
||||
mutationFn: ({ order_id }: { order_id: number }) =>
|
||||
apiRequest<{ payment_link: string }>('POST', links.payment(order_id)),
|
||||
onSuccess: (res) => {
|
||||
console.log('payment res: ', res);
|
||||
window.open(res.data.payment_link, '_self');
|
||||
//route.push(`/${document_id}`);
|
||||
setIsPaymentOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -78,7 +117,7 @@ export function usePlagiarismForm() {
|
||||
e.preventDefault();
|
||||
|
||||
console.log('Form submitted user:', user); // Debugging log
|
||||
if (user === null) {
|
||||
if (localUser === null) {
|
||||
toast.error('Iltimos, avval tizimga kiring!');
|
||||
return;
|
||||
}
|
||||
@@ -89,14 +128,6 @@ export function usePlagiarismForm() {
|
||||
return; // Don't open modal if invalid
|
||||
}
|
||||
|
||||
// Validation passed → open the payment modal
|
||||
setIsPaymentOpen(true);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setSubmission({ status: 'loading', error: null });
|
||||
const fd = new FormData();
|
||||
fd.append('title', form.title.trim());
|
||||
fd.append('text', `${user?.name} ${user?.surname}` || '');
|
||||
@@ -104,6 +135,14 @@ export function usePlagiarismForm() {
|
||||
fd.append('certificate', String(form.certificate));
|
||||
fd.append('total_price', '41200');
|
||||
checkdocumentRequest.mutate(fd);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setSubmission({ status: 'loading', error: null });
|
||||
|
||||
payment.mutate({ order_id });
|
||||
}, [form, user]);
|
||||
|
||||
const resetSubmission = useCallback(() => {
|
||||
|
||||
@@ -12,17 +12,10 @@ export { EmptyState, SkeletonRow, ErrorState } from './ui/tableStates';
|
||||
export { useHistory } from './lib/useHistory';
|
||||
|
||||
// Utils
|
||||
export {
|
||||
fetchPlagiarismHistory,
|
||||
fetchPlagiarismDetail,
|
||||
formatDate,
|
||||
formatAmount,
|
||||
truncateFileName,
|
||||
} from './lib/utils';
|
||||
export { formatDate, formatAmount, truncateFileName } from './lib/utils';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
PlagiarismCheck,
|
||||
PlagiarismCheckDetail,
|
||||
CheckResult,
|
||||
HistoryApiResponse,
|
||||
@@ -38,8 +31,4 @@ export {
|
||||
TABLE_COLUMNS,
|
||||
RESULT_CONFIG,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
API_ENDPOINTS,
|
||||
} from './lib/constants';
|
||||
|
||||
// Mock (for development/testing)
|
||||
export { DEFAULT_HISTORY_ITEMS } from './lib/mock';
|
||||
|
||||
@@ -6,7 +6,6 @@ export const TABLE_COLUMNS = [
|
||||
{ key: 'senderFullName', labelKey: 'sender' },
|
||||
{ key: 'fileName', labelKey: 'file' },
|
||||
{ key: 'date', labelKey: 'date' },
|
||||
{ key: 'paymentAmount', labelKey: 'amount' },
|
||||
{ key: 'result', labelKey: 'result' },
|
||||
{ key: 'actions', labelKey: 'actions' },
|
||||
] as const;
|
||||
@@ -38,10 +37,3 @@ export const RESULT_CONFIG: Record<
|
||||
// ─── Pagination ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
// ─── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
HISTORY: '/api/plagiarism/history',
|
||||
DETAIL: (id: string) => `/api/plagiarism/${id}`,
|
||||
} as const;
|
||||
|
||||
@@ -1,71 +1,69 @@
|
||||
import { PlagiarismCheck } from './types';
|
||||
|
||||
/**
|
||||
* Default mock items used to preview the history table design.
|
||||
* Replace with real API data in production via useHistory() hook.
|
||||
*/
|
||||
export const DEFAULT_HISTORY_ITEMS: PlagiarismCheck[] = [
|
||||
{
|
||||
id: '1',
|
||||
senderFullName: 'Alijon Toshmatov',
|
||||
fileName: 'thesis-final-v3.pdf',
|
||||
date: '2024-03-15T10:30:00Z',
|
||||
paymentAmount: 45000,
|
||||
currency: 'UZS',
|
||||
result: 'clean',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
senderFullName: 'Malika Yusupova',
|
||||
fileName: 'research-paper-2024.docx',
|
||||
date: '2024-03-14T09:15:00Z',
|
||||
paymentAmount: 60000,
|
||||
currency: 'UZS',
|
||||
result: 'plagiarism_found',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
senderFullName: 'Bobur Rahimov',
|
||||
fileName: 'coursework-economics.pdf',
|
||||
date: '2024-03-13T14:45:00Z',
|
||||
paymentAmount: 45000,
|
||||
currency: 'UZS',
|
||||
result: 'pending',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
senderFullName: 'Zulfiya Nazarova',
|
||||
fileName: 'dissertation-chapter1.pdf',
|
||||
date: '2024-03-12T11:20:00Z',
|
||||
paymentAmount: 60000,
|
||||
currency: 'UZS',
|
||||
result: 'clean',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
senderFullName: 'Jasur Mirzayev',
|
||||
fileName: 'lab-report-chemistry.docx',
|
||||
date: '2024-03-11T16:10:00Z',
|
||||
paymentAmount: 45000,
|
||||
currency: 'UZS',
|
||||
result: 'failed',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
senderFullName: 'Nilufar Karimova',
|
||||
fileName: 'bachelor-thesis-law.pdf',
|
||||
date: '2024-03-10T08:55:00Z',
|
||||
paymentAmount: 60000,
|
||||
currency: 'UZS',
|
||||
result: 'plagiarism_found',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
senderFullName: 'Dilnoza Ergasheva',
|
||||
fileName: 'essay-history-uzbekistan.pdf',
|
||||
date: '2024-03-09T13:40:00Z',
|
||||
paymentAmount: 45000,
|
||||
currency: 'UZS',
|
||||
result: 'clean',
|
||||
},
|
||||
];
|
||||
// export const DEFAULT_HISTORY_ITEMS: PlagiarismCheck[] = [
|
||||
// {
|
||||
// id: '1',
|
||||
// senderFullName: 'Alijon Toshmatov',
|
||||
// fileName: 'thesis-final-v3.pdf',
|
||||
// date: '2024-03-15T10:30:00Z',
|
||||
// paymentAmount: 45000,
|
||||
// currency: 'UZS',
|
||||
// result: 'clean',
|
||||
// },
|
||||
// {
|
||||
// id: '2',
|
||||
// senderFullName: 'Malika Yusupova',
|
||||
// fileName: 'research-paper-2024.docx',
|
||||
// date: '2024-03-14T09:15:00Z',
|
||||
// paymentAmount: 60000,
|
||||
// currency: 'UZS',
|
||||
// result: 'plagiarism_found',
|
||||
// },
|
||||
// {
|
||||
// id: '3',
|
||||
// senderFullName: 'Bobur Rahimov',
|
||||
// fileName: 'coursework-economics.pdf',
|
||||
// date: '2024-03-13T14:45:00Z',
|
||||
// paymentAmount: 45000,
|
||||
// currency: 'UZS',
|
||||
// result: 'pending',
|
||||
// },
|
||||
// {
|
||||
// id: '4',
|
||||
// senderFullName: 'Zulfiya Nazarova',
|
||||
// fileName: 'dissertation-chapter1.pdf',
|
||||
// date: '2024-03-12T11:20:00Z',
|
||||
// paymentAmount: 60000,
|
||||
// currency: 'UZS',
|
||||
// result: 'clean',
|
||||
// },
|
||||
// {
|
||||
// id: '5',
|
||||
// senderFullName: 'Jasur Mirzayev',
|
||||
// fileName: 'lab-report-chemistry.docx',
|
||||
// date: '2024-03-11T16:10:00Z',
|
||||
// paymentAmount: 45000,
|
||||
// currency: 'UZS',
|
||||
// result: 'failed',
|
||||
// },
|
||||
// {
|
||||
// id: '6',
|
||||
// senderFullName: 'Nilufar Karimova',
|
||||
// fileName: 'bachelor-thesis-law.pdf',
|
||||
// date: '2024-03-10T08:55:00Z',
|
||||
// paymentAmount: 60000,
|
||||
// currency: 'UZS',
|
||||
// result: 'plagiarism_found',
|
||||
// },
|
||||
// {
|
||||
// id: '7',
|
||||
// senderFullName: 'Dilnoza Ergasheva',
|
||||
// fileName: 'essay-history-uzbekistan.pdf',
|
||||
// date: '2024-03-09T13:40:00Z',
|
||||
// paymentAmount: 45000,
|
||||
// currency: 'UZS',
|
||||
// result: 'clean',
|
||||
// },
|
||||
// ];
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
export type CheckResult = 'clean' | 'plagiarism_found' | 'pending' | 'failed';
|
||||
|
||||
export interface PlagiarismCheck {
|
||||
id: string;
|
||||
senderFullName: string;
|
||||
fileName: string;
|
||||
date: string; // ISO 8601
|
||||
paymentAmount: number;
|
||||
currency: string;
|
||||
result: CheckResult;
|
||||
export interface DocumentData {
|
||||
id: number;
|
||||
title: string;
|
||||
text: string;
|
||||
file: string;
|
||||
certificate: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
results: [];
|
||||
}
|
||||
|
||||
export interface PlagiarismCheckDetail extends PlagiarismCheck {
|
||||
export interface PlagiarismCheckDetail extends DocumentData {
|
||||
topic: string;
|
||||
plagiarismPercentage?: number;
|
||||
reportUrl?: string;
|
||||
@@ -22,7 +23,7 @@ export interface PlagiarismCheckDetail extends PlagiarismCheck {
|
||||
// ─── API Response Types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface HistoryApiResponse {
|
||||
items: PlagiarismCheck[];
|
||||
items: DocumentData[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
@@ -31,12 +32,12 @@ export interface HistoryApiResponse {
|
||||
// ─── Component Props ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface HistoryTableProps {
|
||||
items: PlagiarismCheck[];
|
||||
items: DocumentData[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryTableRowProps {
|
||||
item: PlagiarismCheck;
|
||||
item: DocumentData;
|
||||
}
|
||||
|
||||
export interface ResultBadgeProps {
|
||||
@@ -56,7 +57,7 @@ export interface SkeletonRowProps {
|
||||
export type FetchStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export interface HistoryState {
|
||||
items: PlagiarismCheck[];
|
||||
items: DocumentData[];
|
||||
status: FetchStatus;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DEFAULT_PAGE_SIZE } from './constants';
|
||||
import { HistoryState } from './types';
|
||||
import { DEFAULT_HISTORY_ITEMS } from './mock';
|
||||
import { DocumentData, HistoryState } from './types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { links } from '@/shared/request/links';
|
||||
import { apiRequest } from '@/shared/request/apiRequest';
|
||||
import { useUserPlagiatStore } from '@/shared/zustand/user';
|
||||
|
||||
interface UseHistoryReturn extends HistoryState {
|
||||
refetch: () => void;
|
||||
@@ -16,22 +16,21 @@ interface UseHistoryReturn extends HistoryState {
|
||||
|
||||
export const useHistory = (pageSize = DEFAULT_PAGE_SIZE): UseHistoryReturn => {
|
||||
const [state, setState] = useState<HistoryState>({
|
||||
items: DEFAULT_HISTORY_ITEMS,
|
||||
items: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const user = useUserPlagiatStore((state) => state.user);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['history'],
|
||||
queryFn: () => apiRequest('GET', links.history),
|
||||
select: (response) => {
|
||||
const { results, total } = response.data as {
|
||||
results: [];
|
||||
total: number;
|
||||
};
|
||||
return { results, total };
|
||||
console.log(response);
|
||||
const results = response?.data as DocumentData[];
|
||||
return { results, total: results.length };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,13 +41,13 @@ export const useHistory = (pageSize = DEFAULT_PAGE_SIZE): UseHistoryReturn => {
|
||||
status: 'success',
|
||||
error: null,
|
||||
});
|
||||
setTotal(data?.total || 0);
|
||||
setTotal(data.total || 0);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [currentPage]);
|
||||
}, [currentPage, user]);
|
||||
|
||||
const goToPage = useCallback((page: number) => {
|
||||
setCurrentPage(page);
|
||||
|
||||
@@ -1,43 +1,3 @@
|
||||
// ─── API Functions ─────────────────────────────────────────────────────────────
|
||||
|
||||
import { API_ENDPOINTS } from './constants';
|
||||
import { HistoryApiResponse, PlagiarismCheckDetail } from './types';
|
||||
|
||||
/**
|
||||
* Fetches the paginated list of plagiarism checks for the current user.
|
||||
*/
|
||||
export const fetchPlagiarismHistory = async (
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
): Promise<HistoryApiResponse> => {
|
||||
const url = new URL(API_ENDPOINTS.HISTORY, window.location.origin);
|
||||
url.searchParams.set('page', String(page));
|
||||
url.searchParams.set('pageSize', String(pageSize));
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load history (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<HistoryApiResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the full detail for a single plagiarism check.
|
||||
*/
|
||||
export const fetchPlagiarismDetail = async (
|
||||
id: string,
|
||||
): Promise<PlagiarismCheckDetail> => {
|
||||
const response = await fetch(API_ENDPOINTS.DETAIL(id));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load record (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PlagiarismCheckDetail>;
|
||||
};
|
||||
|
||||
// ─── Formatting Utilities ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,26 +2,37 @@
|
||||
import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { HistoryTableRowProps } from '../lib/types';
|
||||
import { formatDate, truncateFileName } from '../lib/utils';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import { ResultBadge } from './resultBadge';
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { useUserPlagiatStore } from '@/shared/zustand/user';
|
||||
|
||||
export const HistoryTableRow: React.FC<HistoryTableRowProps> = ({ item }) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('HistoryPage');
|
||||
const tUnknown = useTranslations();
|
||||
const user = useUserPlagiatStore((state) => state.user);
|
||||
|
||||
const userName = user
|
||||
? `${user.name} ${user.surname}`
|
||||
: tUnknown('unknownUser');
|
||||
|
||||
return (
|
||||
<tr className="border-b border-slate-100 hover:bg-slate-50/70 transition-colors duration-100 group">
|
||||
{/* Sender */}
|
||||
<td className="px-4 py-3.5">
|
||||
<span className="text-sm font-medium text-slate-800 whitespace-nowrap">
|
||||
{item.senderFullName}
|
||||
{userName}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* File Name */}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={item.file}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 underline"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -33,39 +44,36 @@ export const HistoryTableRow: React.FC<HistoryTableRowProps> = ({ item }) => {
|
||||
>
|
||||
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-sm text-slate-600 font-mono"
|
||||
title={item.fileName}
|
||||
>
|
||||
{truncateFileName(item.fileName)}
|
||||
<span className="text-sm text-slate-600 font-mono" title={item.file}>
|
||||
{tUnknown('file')}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
{/* Date */}
|
||||
<td className="px-4 py-3.5">
|
||||
<span className="text-sm text-slate-500 whitespace-nowrap">
|
||||
{formatDate(item.date)}
|
||||
{formatDate(item.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Amount */}
|
||||
<td className="px-4 py-3.5">
|
||||
{/* <td className="px-4 py-3.5">
|
||||
<span className="text-sm font-medium text-slate-700 whitespace-nowrap tabular-nums">
|
||||
{item.paymentAmount}
|
||||
{item.} UZS
|
||||
</span>
|
||||
</td>
|
||||
</td> */}
|
||||
|
||||
{/* Result */}
|
||||
<td className="px-4 py-3.5">
|
||||
<ResultBadge result={item.result} />
|
||||
<ResultBadge result={'clean'} />
|
||||
</td>
|
||||
|
||||
{/* View Button */}
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<button
|
||||
onClick={() => router.push(`/${item.id}`)}
|
||||
aria-label={t('viewDetails', { sender: item.senderFullName })}
|
||||
aria-label={t('viewDetails', { sender: item.title })}
|
||||
className="
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
text-xs font-medium text-slate-600
|
||||
|
||||
@@ -13,25 +13,48 @@ import { ChangeLang } from './ChangeLang';
|
||||
import { useLoginModal, useRegisterModal } from '@/shared/zustand/auth';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useUserPlagiatStore } from '@/shared/zustand/user';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function AuthButtons() {
|
||||
const t = useTranslations('Navbar');
|
||||
const [localUser, setLocalUser] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
surname: string;
|
||||
} | null>(null);
|
||||
|
||||
const auth = {
|
||||
login: { title: t('login'), url: '#' },
|
||||
signup: { title: t('signup'), url: '#' },
|
||||
};
|
||||
|
||||
const userItem = [{ title: t('logout'), url: '#' }];
|
||||
const userItem = [{ title: t('logout'), url: '/', icon: LogOut }];
|
||||
|
||||
const toggleLoginModal = useLoginModal((state) => state.toggleLoginModal);
|
||||
const toggleRegisterModal = useRegisterModal(
|
||||
(state) => state.toggleRegisterModal,
|
||||
);
|
||||
const user = useUserPlagiatStore((state) => state.user);
|
||||
const clearUser = useUserPlagiatStore((state) => state.clearUser);
|
||||
const clearTokens = () => {
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
console.log('Current user:', user);
|
||||
|
||||
if (user) {
|
||||
useEffect(() => {
|
||||
const data = localStorage.getItem('user');
|
||||
|
||||
if (data) {
|
||||
setLocalUser(JSON.parse(data));
|
||||
} else {
|
||||
setLocalUser(null);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (localUser) {
|
||||
return (
|
||||
<div className="flex flex-row max-sm:items-center max-sm:justify-around gap-3">
|
||||
<div className="sm:flex hidden">
|
||||
@@ -40,7 +63,7 @@ function AuthButtons() {
|
||||
<NavigationMenu>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className="text-xl">
|
||||
{user.name} {user.surname}
|
||||
{localUser.name} {localUser.surname}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="bg-popover text-popover-foreground">
|
||||
{userItem.map((subItem) => (
|
||||
@@ -48,6 +71,10 @@ function AuthButtons() {
|
||||
asChild
|
||||
key={subItem.title}
|
||||
className="w-80"
|
||||
onClick={() => {
|
||||
clearTokens();
|
||||
clearUser();
|
||||
}}
|
||||
>
|
||||
<SubMenuLink item={subItem} />
|
||||
</NavigationMenuLink>
|
||||
|
||||
Reference in New Issue
Block a user