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