profile page ui complated

This commit is contained in:
nabijonovdavronbek619@gmail.com
2026-04-06 17:55:54 +05:00
parent 27b1510842
commit db0fad7e00
18 changed files with 976 additions and 85 deletions

View 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';

View 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&apos;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}
/>
</>
);
}

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

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

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

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

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

View 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,
};
}

View 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',
];