profile page ui complated
This commit is contained in:
100
package-lock.json
generated
100
package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"framer-motion": "^12.38.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"next": "15.5.9",
|
||||
"next-intl": "^4.3.9",
|
||||
@@ -4965,6 +4966,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -6570,6 +6577,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6597,6 +6610,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -7165,6 +7184,18 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7209,6 +7240,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -8160,6 +8200,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -8314,6 +8360,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -8505,6 +8557,27 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
@@ -8713,6 +8786,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-json-stringify": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz",
|
||||
@@ -8822,6 +8901,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
@@ -9049,6 +9134,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-argv": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||
@@ -9680,6 +9774,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"framer-motion": "^12.38.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"next": "15.5.9",
|
||||
"next-intl": "^4.3.9",
|
||||
|
||||
@@ -1,57 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://antiplagiat.uz/uz</loc>
|
||||
<lastmod>2026-04-04</lastmod>
|
||||
<loc>https://anti-plagiat.uz/uz</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://antiplagiat.uz/uz"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://antiplagiat.uz/ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antiplagiat.uz/en"/>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antiplagiat.uz/uz/plagat</loc>
|
||||
<lastmod>2026-04-04</lastmod>
|
||||
<loc>https://anti-plagiat.uz/uz/plagat</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://antiplagiat.uz/uz/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://antiplagiat.uz/ru/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antiplagiat.uz/en/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/plagat"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antiplagiat.uz/ru</loc>
|
||||
<lastmod>2026-04-04</lastmod>
|
||||
<loc>https://anti-plagiat.uz/uz/cabinet</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/cabinet"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/cabinet"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/cabinet"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://anti-plagiat.uz/ru</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://antiplagiat.uz/uz"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://antiplagiat.uz/ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antiplagiat.uz/en"/>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antiplagiat.uz/ru/plagat</loc>
|
||||
<lastmod>2026-04-04</lastmod>
|
||||
<loc>https://anti-plagiat.uz/ru/plagat</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://antiplagiat.uz/uz/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://antiplagiat.uz/ru/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antiplagiat.uz/en/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/plagat"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antiplagiat.uz/en</loc>
|
||||
<lastmod>2026-04-04</lastmod>
|
||||
<loc>https://anti-plagiat.uz/ru/cabinet</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/cabinet"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/cabinet"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/cabinet"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://anti-plagiat.uz/en</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://antiplagiat.uz/uz"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://antiplagiat.uz/ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antiplagiat.uz/en"/>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antiplagiat.uz/en/plagat</loc>
|
||||
<lastmod>2026-04-04</lastmod>
|
||||
<loc>https://anti-plagiat.uz/en/plagat</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://antiplagiat.uz/uz/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://antiplagiat.uz/ru/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antiplagiat.uz/en/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/plagat"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/plagat"/>
|
||||
</url>
|
||||
</urlset>
|
||||
<url>
|
||||
<loc>https://anti-plagiat.uz/en/cabinet</loc>
|
||||
<lastmod>2026-04-06</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
<xhtml:link rel="alternate" hreflang="uz" href="https://anti-plagiat.uz/uz/cabinet"/>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="https://anti-plagiat.uz/ru/cabinet"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://anti-plagiat.uz/en/cabinet"/>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://antiplagiat.uz';
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anti-plagiat.uz';
|
||||
const LOCALES = ['uz', 'ru', 'en'] as const;
|
||||
|
||||
// Add your static page slugs here
|
||||
const STATIC_ROUTES = ['', '/plagat'];
|
||||
const STATIC_ROUTES = [
|
||||
{ path: '', changeFreq: 'daily' as const, priority: 1.0 },
|
||||
{ path: '/plagat', changeFreq: 'weekly' as const, priority: 0.8 },
|
||||
{ path: '/cabinet', changeFreq: 'weekly' as const, priority: 0.7 },
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
@@ -12,13 +15,13 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
for (const locale of LOCALES) {
|
||||
for (const route of STATIC_ROUTES) {
|
||||
entries.push({
|
||||
url: `${SITE_URL}/${locale}${route}`,
|
||||
url: `${SITE_URL}/${locale}${route.path}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: route === '' ? 'daily' : 'weekly',
|
||||
priority: route === '' ? 1.0 : 0.8,
|
||||
changeFrequency: route.changeFreq,
|
||||
priority: route.priority,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
LOCALES.map((l) => [l, `${SITE_URL}/${l}${route}`]),
|
||||
LOCALES.map((l) => [l, `${SITE_URL}/${l}${route.path}`]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
14
src/features/modals/siModal/index.ts
Normal file
14
src/features/modals/siModal/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { FileUploadModal } from './ui/fileUploadModal';
|
||||
export { useFileUpload } from './utils/useFileUpload';
|
||||
export {
|
||||
countWordsFromFile,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
SUPPORTED_MIME_TYPES,
|
||||
} from './utils/wordCount';
|
||||
export { calculatePrice, formatPrice, DEFAULT_PRICING } from './utils/pricing';
|
||||
export type {
|
||||
FileUploadModalProps,
|
||||
UploadedFile,
|
||||
PricingConfig,
|
||||
WordCountResult,
|
||||
} from './utils/tyeps';
|
||||
52
src/features/modals/siModal/page.tsx
Normal file
52
src/features/modals/siModal/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ArrowRight, BrainCircuit } from 'lucide-react';
|
||||
import { FileUploadModal } from './ui/fileUploadModal';
|
||||
|
||||
export default function SiCTACard() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [lastSubmission, setLastSubmission] = useState<{
|
||||
name: string;
|
||||
words: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSubmit = (
|
||||
documentName: string,
|
||||
_file: File,
|
||||
wordCount: number,
|
||||
) => {
|
||||
// Here you would send the file to your backend for plagiarism check.
|
||||
// The word count is already computed client-side for instant pricing display.
|
||||
console.log(lastSubmission);
|
||||
console.log('Submitting:', { documentName, wordCount });
|
||||
setLastSubmission({ name: documentName, words: wordCount });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-violet-500 to-violet-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
|
||||
>
|
||||
<div className="absolute right-3 top-3 opacity-10 pointer-events-none">
|
||||
<BrainCircuit size={72} className="text-white" />
|
||||
</div>
|
||||
<BrainCircuit size={26} className="text-white mb-4" />
|
||||
<h3 className="text-white font-semibold text-base mb-1">SI detektor</h3>
|
||||
<p className="text-violet-100 text-sm mb-4 leading-relaxed">
|
||||
Matnni sun'iy intellekt uchun tekshiring
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 text-white text-xs font-medium bg-white/20 rounded-lg px-3 py-1.5 group-hover:bg-white/30 transition-colors">
|
||||
Yuborish <ArrowRight size={12} />
|
||||
</span>
|
||||
</button>
|
||||
<FileUploadModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
src/features/modals/siModal/ui/dropZone.tsx
Normal file
67
src/features/modals/siModal/ui/dropZone.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Upload } from 'lucide-react';
|
||||
import { SUPPORTED_EXTENSIONS } from '../utils/wordCount';
|
||||
|
||||
interface DropZoneProps {
|
||||
isDragging: boolean;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragLeave: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DropZone({
|
||||
isDragging,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onClick,
|
||||
}: DropZoneProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onClick()}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center gap-3
|
||||
rounded-xl border-2 border-dashed px-6 py-2
|
||||
cursor-pointer select-none transition-all duration-200 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2
|
||||
${
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-50 scale-[1.01]'
|
||||
: 'border-slate-200 bg-slate-50 hover:border-blue-300 hover:bg-blue-50/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-12 h-12 rounded-full flex items-center justify-center
|
||||
transition-all duration-200
|
||||
${isDragging ? 'bg-blue-100 scale-110' : 'bg-white shadow-sm'}
|
||||
`}
|
||||
>
|
||||
<Upload
|
||||
size={22}
|
||||
className={`transition-colors duration-200 ${
|
||||
isDragging ? 'text-blue-500' : 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{isDragging ? 'Drop file here' : 'Drag & drop or click to browse'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Supported: {SUPPORTED_EXTENSIONS.join(', ')} · Max 50 MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/features/modals/siModal/ui/fileUploadModal.tsx
Normal file
199
src/features/modals/siModal/ui/fileUploadModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { calculatePrice, DEFAULT_PRICING, formatPrice } from '../utils/pricing';
|
||||
import { FileUploadModalProps } from '../utils/tyeps';
|
||||
import { useFileUpload } from '../utils/useFileUpload';
|
||||
import { SUPPORTED_EXTENSIONS } from '../utils/wordCount';
|
||||
import { ErrorBanner, FileChip, PricingInfo, Spinner } from './modalParts';
|
||||
import { DropZone } from './dropZone';
|
||||
|
||||
export function FileUploadModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
pricing = DEFAULT_PRICING,
|
||||
}: FileUploadModalProps) {
|
||||
const {
|
||||
documentName,
|
||||
setDocumentName,
|
||||
uploadedFile,
|
||||
isDragging,
|
||||
isProcessing,
|
||||
error,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleRemoveFile,
|
||||
openFilePicker,
|
||||
} = useFileUpload();
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Lock body scroll while open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : '';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canSubmit =
|
||||
documentName.trim().length > 0 &&
|
||||
uploadedFile?.status === 'done' &&
|
||||
!isProcessing;
|
||||
|
||||
const wordCount = uploadedFile?.wordCount ?? 0;
|
||||
const totalPrice = calculatePrice(wordCount, pricing);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit || !uploadedFile) return;
|
||||
onSubmit(documentName.trim(), uploadedFile.file, wordCount);
|
||||
};
|
||||
|
||||
return (
|
||||
// Backdrop
|
||||
<div
|
||||
className="
|
||||
fixed inset-0 z-50 flex items-center justify-center p-4
|
||||
bg-slate-900/40 backdrop-blur-sm
|
||||
animate-in fade-in duration-200
|
||||
"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* Panel */}
|
||||
<div
|
||||
className="
|
||||
relative w-full max-w-140 rounded-2xl bg-white shadow-2xl
|
||||
animate-in zoom-in-95 slide-in-from-bottom-4 duration-300
|
||||
flex flex-col gap-5 p-6
|
||||
"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-xl font-semibold text-slate-800 tracking-tight"
|
||||
>
|
||||
Select file
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="
|
||||
w-8 h-8 rounded-full flex items-center justify-center
|
||||
text-slate-400 hover:text-slate-600 hover:bg-slate-100
|
||||
transition-colors focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-slate-300
|
||||
"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Document name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="doc-name"
|
||||
className="text-sm font-medium text-slate-700"
|
||||
>
|
||||
Document name{' '}
|
||||
<span className="text-red-500" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="doc-name"
|
||||
type="text"
|
||||
value={documentName}
|
||||
onChange={(e) => setDocumentName(e.target.value)}
|
||||
placeholder="Enter document name…"
|
||||
className="
|
||||
w-full rounded-xl border border-slate-200 bg-white px-4 py-3
|
||||
text-sm text-slate-800 placeholder:text-slate-400
|
||||
outline-none transition-all duration-150
|
||||
focus:border-blue-400 focus:ring-4 focus:ring-blue-400/10
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SUPPORTED_EXTENSIONS.join(',')}
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drop zone OR file chip */}
|
||||
{uploadedFile ? (
|
||||
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<FileChip file={uploadedFile} onRemove={handleRemoveFile} />
|
||||
</div>
|
||||
) : (
|
||||
<DropZone
|
||||
isDragging={isDragging}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={openFilePicker}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{/* Pricing info — shown once word count is ready */}
|
||||
{uploadedFile?.status === 'done' && wordCount > 0 && (
|
||||
<PricingInfo
|
||||
wordCount={wordCount}
|
||||
minimumPrice={formatPrice(pricing.minimumPayment)}
|
||||
totalPrice={formatPrice(totalPrice)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="
|
||||
relative flex items-center gap-2
|
||||
rounded-xl bg-blue-600 px-6 py-3
|
||||
text-sm font-semibold text-white
|
||||
shadow-lg shadow-blue-500/25
|
||||
transition-all duration-150
|
||||
hover:bg-blue-700 hover:shadow-blue-500/40
|
||||
active:scale-[0.98]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none
|
||||
focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-blue-400 focus-visible:ring-offset-2
|
||||
"
|
||||
>
|
||||
{isProcessing && <Spinner />}
|
||||
Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/features/modals/siModal/ui/modalParts.tsx
Normal file
126
src/features/modals/siModal/ui/modalParts.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { X, FileText, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { UploadedFile } from '../utils/tyeps';
|
||||
|
||||
// ── Spinner ─────────────────────────────────────────────────────────────────
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<Loader2
|
||||
className={`animate-spin text-white ${className}`}
|
||||
size={16}
|
||||
aria-label="Processing…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File chip shown after upload ─────────────────────────────────────────────
|
||||
interface FileChipProps {
|
||||
file: UploadedFile;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function FileChip({ file, onRemove }: FileChipProps) {
|
||||
const isLoading = file.status === 'uploading';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between rounded-xl px-4 py-3 transition-all duration-300
|
||||
${
|
||||
file.status === 'error'
|
||||
? 'bg-red-500/10 border border-red-400/30'
|
||||
: 'bg-emerald-500 shadow-lg shadow-emerald-500/25'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileText
|
||||
size={18}
|
||||
className={file.status === 'error' ? 'text-red-400' : 'text-white/80'}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium truncate max-w-65 ${
|
||||
file.status === 'error' ? 'text-red-300' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{file.name}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
file.status === 'error' ? 'text-red-400' : 'text-emerald-100/70'
|
||||
}`}
|
||||
>
|
||||
{file.sizeKB} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
file.status === 'error' ? 'text-red-400' : 'text-white/90'
|
||||
}`}
|
||||
>
|
||||
{file.status === 'error' ? 'Error' : 'Upload complete'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center transition-colors"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<X size={12} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pricing info block ───────────────────────────────────────────────────────
|
||||
interface PricingInfoProps {
|
||||
wordCount: number;
|
||||
minimumPrice: string;
|
||||
totalPrice: string;
|
||||
}
|
||||
|
||||
export function PricingInfo({
|
||||
wordCount,
|
||||
minimumPrice,
|
||||
totalPrice,
|
||||
}: PricingInfoProps) {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
animate-in fade-in slide-in-from-bottom-2 duration-300
|
||||
rounded-xl bg-amber-50 border border-amber-100 px-4 py-3 space-y-1
|
||||
"
|
||||
>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Document check price is determined by the number of words in the
|
||||
document.
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-amber-500">
|
||||
Minimum payment for one document: {minimumPrice}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-emerald-600">
|
||||
Document check price: {wordCount.toLocaleString()} words for{' '}
|
||||
{totalPrice}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error banner ─────────────────────────────────────────────────────────────
|
||||
export function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-xl bg-red-50 border border-red-200 px-4 py-3 animate-in fade-in duration-200">
|
||||
<AlertCircle size={16} className="text-red-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-red-600">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/features/modals/siModal/utils/pricing.ts
Normal file
20
src/features/modals/siModal/utils/pricing.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PricingConfig } from './tyeps';
|
||||
|
||||
const DEFAULT_PRICING: PricingConfig = {
|
||||
pricePerWord: 4, // 4 so'm per word
|
||||
minimumPayment: 10000, // 10 000 so'm minimum
|
||||
};
|
||||
|
||||
export function calculatePrice(
|
||||
wordCount: number,
|
||||
config: PricingConfig = DEFAULT_PRICING,
|
||||
): number {
|
||||
const calculated = wordCount * config.pricePerWord;
|
||||
return Math.max(calculated, config.minimumPayment);
|
||||
}
|
||||
|
||||
export function formatPrice(amount: number): string {
|
||||
return new Intl.NumberFormat('uz-UZ').format(amount) + " so'm";
|
||||
}
|
||||
|
||||
export { DEFAULT_PRICING };
|
||||
24
src/features/modals/siModal/utils/tyeps.ts
Normal file
24
src/features/modals/siModal/utils/tyeps.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface UploadedFile {
|
||||
file: File;
|
||||
name: string;
|
||||
sizeKB: number;
|
||||
wordCount: number;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
}
|
||||
|
||||
export interface PricingConfig {
|
||||
pricePerWord: number; // in so'm
|
||||
minimumPayment: number; // in so'm
|
||||
}
|
||||
|
||||
export interface FileUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (documentName: string, file: File, wordCount: number) => void;
|
||||
pricing?: PricingConfig;
|
||||
}
|
||||
|
||||
export interface WordCountResult {
|
||||
count: number;
|
||||
error?: string;
|
||||
}
|
||||
128
src/features/modals/siModal/utils/useFileUpload.ts
Normal file
128
src/features/modals/siModal/utils/useFileUpload.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { UploadedFile } from './tyeps';
|
||||
import { countWordsFromFile, SUPPORTED_EXTENSIONS } from './wordCount';
|
||||
|
||||
interface UseFileUploadReturn {
|
||||
documentName: string;
|
||||
setDocumentName: (name: string) => void;
|
||||
uploadedFile: UploadedFile | null;
|
||||
isDragging: boolean;
|
||||
isProcessing: boolean;
|
||||
error: string | null;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
handleFileSelect: (file: File) => Promise<void>;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
handleDragOver: (e: React.DragEvent) => void;
|
||||
handleDragLeave: () => void;
|
||||
handleRemoveFile: () => void;
|
||||
openFilePicker: () => void;
|
||||
}
|
||||
|
||||
export function useFileUpload(): UseFileUploadReturn {
|
||||
const [documentName, setDocumentName] = useState('');
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedFile | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
||||
return `Unsupported file type. Allowed: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
return 'File size must be less than 50 MB';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFileSelect = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic UI: show file immediately
|
||||
const optimistic: UploadedFile = {
|
||||
file,
|
||||
name: file.name,
|
||||
sizeKB: Math.round(file.size / 1024),
|
||||
wordCount: 0,
|
||||
status: 'uploading',
|
||||
};
|
||||
setUploadedFile(optimistic);
|
||||
setIsProcessing(true);
|
||||
|
||||
// Auto-fill document name if empty
|
||||
setDocumentName((prev) =>
|
||||
prev.trim() === '' ? file.name.replace(/\.[^/.]+$/, '') : prev,
|
||||
);
|
||||
|
||||
// Count words on the frontend (no round-trip needed)
|
||||
const result = await countWordsFromFile(file);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
setUploadedFile({ ...optimistic, status: 'error', wordCount: 0 });
|
||||
} else {
|
||||
setUploadedFile({
|
||||
...optimistic,
|
||||
status: 'done',
|
||||
wordCount: result.count,
|
||||
});
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
},
|
||||
[handleFileSelect],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFile = useCallback(() => {
|
||||
setUploadedFile(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}, []);
|
||||
|
||||
const openFilePicker = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documentName,
|
||||
setDocumentName,
|
||||
uploadedFile,
|
||||
isDragging,
|
||||
isProcessing,
|
||||
error,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleRemoveFile,
|
||||
openFilePicker,
|
||||
};
|
||||
}
|
||||
93
src/features/modals/siModal/utils/wordCount.ts
Normal file
93
src/features/modals/siModal/utils/wordCount.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { WordCountResult } from './tyeps';
|
||||
|
||||
// ── Plain text ──────────────────────────────────────────────────────────────
|
||||
function countWordsFromText(text: string): number {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 0).length;
|
||||
}
|
||||
|
||||
// ── .txt ────────────────────────────────────────────────────────────────────
|
||||
async function countFromTxt(file: File): Promise<WordCountResult> {
|
||||
const text = await file.text();
|
||||
return { count: countWordsFromText(text) };
|
||||
}
|
||||
|
||||
// ── .docx (reads raw XML, strips tags) ─────────────────────────────────────
|
||||
async function countFromDocx(file: File): Promise<WordCountResult> {
|
||||
try {
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const xmlFile = zip.file('word/document.xml');
|
||||
|
||||
if (!xmlFile) {
|
||||
return { count: 0, error: 'Could not read .docx content' };
|
||||
}
|
||||
|
||||
const xml = await xmlFile.async('string');
|
||||
const text = xml
|
||||
.replace(/<[^>]+>/g, ' ') // strip XML tags
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return { count: countWordsFromText(text) };
|
||||
} catch {
|
||||
return { count: 0, error: 'Failed to parse .docx file' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── .pdf (reads raw text layer; won't work for scanned PDFs) ───────────────
|
||||
async function countFromPdf(file: File): Promise<WordCountResult> {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const raw = new TextDecoder('latin1').decode(bytes);
|
||||
|
||||
// Extract text between BT/ET stream operators
|
||||
const textMatches = raw.match(/BT[\s\S]*?ET/g) ?? [];
|
||||
const words: string[] = [];
|
||||
|
||||
for (const block of textMatches) {
|
||||
// Match Tj / TJ operators
|
||||
const strings = block.match(/\(([^)]*)\)\s*Tj|\[([^\]]*)\]\s*TJ/g) ?? [];
|
||||
for (const s of strings) {
|
||||
const inner = s.replace(/\(|\)\s*Tj|\[|\]\s*TJ/g, '');
|
||||
words.push(...inner.split(/\s+/).filter((w) => w.length > 0));
|
||||
}
|
||||
}
|
||||
|
||||
return { count: words.length };
|
||||
} catch {
|
||||
return { count: 0, error: 'Failed to parse .pdf file' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
export async function countWordsFromFile(file: File): Promise<WordCountResult> {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'txt':
|
||||
return countFromTxt(file);
|
||||
case 'docx':
|
||||
return countFromDocx(file);
|
||||
case 'pdf':
|
||||
return countFromPdf(file);
|
||||
default:
|
||||
// Fallback: try reading as text
|
||||
try {
|
||||
const text = await file.text();
|
||||
return { count: countWordsFromText(text) };
|
||||
} catch {
|
||||
return { count: 0, error: `Unsupported file type: .${ext}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = ['.txt', '.docx', '.pdf'];
|
||||
export const SUPPORTED_MIME_TYPES = [
|
||||
'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/pdf',
|
||||
];
|
||||
@@ -145,7 +145,7 @@ function NavigationMenuIndicator({
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
43
src/widgets/cabinet/ui/CabinetNav.tsx
Normal file
43
src/widgets/cabinet/ui/CabinetNav.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import type { CabinetSection } from '../lib/types';
|
||||
|
||||
// ─── Labels ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SECTION_LABELS: Record<CabinetSection, string> = {
|
||||
dashboard: 'Dashboard',
|
||||
plagiat: 'Plagiat tekshiruvlar',
|
||||
si: 'SI detektor',
|
||||
payments: "To'lovlar tarixi",
|
||||
profile: 'Profil',
|
||||
};
|
||||
|
||||
// ─── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CabinetNavProps {
|
||||
activeSection: CabinetSection;
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CabinetNav: React.FC<CabinetNavProps> = ({
|
||||
activeSection,
|
||||
onMenuClick,
|
||||
}) => (
|
||||
<header className="h-14 px-4 md:px-6 flex lg:hidden items-center justify-between border-b border-slate-100 bg-white/80 backdrop-blur-sm sticky top-0 z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100 transition-colors"
|
||||
aria-label="Menu"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-slate-800">
|
||||
{SECTION_LABELS[activeSection]}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@@ -140,6 +140,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-4 border-t border-slate-100">
|
||||
<p className="text-[11px] text-slate-400 text-center">
|
||||
© 2026 Plagat.uz
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,49 +1,33 @@
|
||||
import React from 'react';
|
||||
import { FileSearch, BrainCircuit, ArrowRight } from 'lucide-react';
|
||||
import type { CabinetSection } from '../../lib/types';
|
||||
import { FileSearch, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import SiCTACard from '@/features/modals/siModal/page';
|
||||
|
||||
interface CtaCardsProps {
|
||||
onNavigate: (section: CabinetSection) => void;
|
||||
}
|
||||
export const CtaCards = () => (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Plagiat */}
|
||||
<Link
|
||||
href={'/plagat'}
|
||||
className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
|
||||
>
|
||||
<div className="absolute right-3 top-3 opacity-10 pointer-events-none">
|
||||
<FileSearch size={72} className="text-white" />
|
||||
</div>
|
||||
<FileSearch size={26} className="text-white mb-4" />
|
||||
<h3 className="text-white font-semibold text-base mb-1">
|
||||
Plagiat tekshiruvi
|
||||
</h3>
|
||||
<p className="text-blue-100 text-sm mb-4 leading-relaxed">
|
||||
Hujjatingizni originallik uchun tekshiring
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 text-white text-xs font-medium bg-white/20 rounded-lg px-3 py-1.5 group-hover:bg-white/30 transition-colors">
|
||||
Yuborish <ArrowRight size={12} />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
export const CtaCards: React.FC<CtaCardsProps> = ({ onNavigate }) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Plagiat */}
|
||||
<button
|
||||
onClick={() => onNavigate('plagiat')}
|
||||
className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
|
||||
>
|
||||
<div className="absolute right-3 top-3 opacity-10 pointer-events-none">
|
||||
<FileSearch size={72} className="text-white" />
|
||||
</div>
|
||||
<FileSearch size={26} className="text-white mb-4" />
|
||||
<h3 className="text-white font-semibold text-base mb-1">
|
||||
Plagiat tekshiruvi
|
||||
</h3>
|
||||
<p className="text-blue-100 text-sm mb-4 leading-relaxed">
|
||||
Hujjatingizni originallik uchun tekshiring
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 text-white text-xs font-medium bg-white/20 rounded-lg px-3 py-1.5 group-hover:bg-white/30 transition-colors">
|
||||
Yuborish <ArrowRight size={12} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* SI */}
|
||||
<button
|
||||
onClick={() => onNavigate('si')}
|
||||
className="group relative overflow-hidden rounded-2xl bg-linear-to-br from-violet-500 to-violet-600 p-6 text-left shadow-md hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:shadow-md"
|
||||
>
|
||||
<div className="absolute right-3 top-3 opacity-10 pointer-events-none">
|
||||
<BrainCircuit size={72} className="text-white" />
|
||||
</div>
|
||||
<BrainCircuit size={26} className="text-white mb-4" />
|
||||
<h3 className="text-white font-semibold text-base mb-1">SI detektor</h3>
|
||||
<p className="text-violet-100 text-sm mb-4 leading-relaxed">
|
||||
Matnni sun'iy intellekt uchun tekshiring
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 text-white text-xs font-medium bg-white/20 rounded-lg px-3 py-1.5 group-hover:bg-white/30 transition-colors">
|
||||
Yuborish <ArrowRight size={12} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* SI */}
|
||||
<SiCTACard />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { CabinetNav } from './CabinetNav';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { useCabinet } from '../lib/hooks/useCabinet';
|
||||
import {
|
||||
@@ -91,7 +92,7 @@ export const CabinetLayout: React.FC = () => {
|
||||
const fullName = `${MOCK_USER.name} ${MOCK_USER.surname}`;
|
||||
|
||||
return (
|
||||
<div className="flex border-t min-h-screen">
|
||||
<div className="flex bg-slate-50 min-h-screen">
|
||||
<Sidebar
|
||||
active={activeSection}
|
||||
onNavigate={navigate}
|
||||
@@ -101,6 +102,8 @@ export const CabinetLayout: React.FC = () => {
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<CabinetNav activeSection={activeSection} onMenuClick={toggleSidebar} />
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-6xl mx-auto w-full">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={activeSection} {...FADE}>
|
||||
|
||||
Reference in New Issue
Block a user