Spaces:
Configuration error
Configuration error
| // src/components/HumanizerInterface.tsx | |
| /** | |
| * HumanizerInterface.tsx | |
| * ------------------------------------------------------------------------------------- | |
| * A complete, production-ready Humanizer UI module with session analytics wired to | |
| * Firestore and a hash-based cache layer. This file is intentionally verbose and | |
| * heavily commented for clarity, with defensive checks for all Firestore updates. | |
| * | |
| * Key Features | |
| * ------------ | |
| * - Modes: Normal, Advanced, Expert | |
| * - Upload .txt / .docx | |
| * - Hash-based cache lookup to avoid repeated processing (Firestore) | |
| * - Session analytics: | |
| * • stats.totalSessions (increment when user begins a session) | |
| * • stats.averageSessionTime (weighted running average in minutes) | |
| * - Content stats: | |
| * • stats.textsHumanized (increment when output produced) | |
| * • stats.wordsProcessed (increment by input word count) | |
| * - Activity logging to `user_activities` | |
| * - Clean UI with Tailwind classes, light/dark support via ThemeContext | |
| * | |
| * Dependencies | |
| * ------------ | |
| * - React, React Router (`useNavigate`) | |
| * - Firebase: `auth`, `db` provided from project-level firebase config | |
| * - Firestore: doc, updateDoc, getDoc, getDocs, addDoc, collection, | |
| * Timestamp, increment, where, query | |
| * - Hash: sha256 (crypto-hash) | |
| * - Mammoth: dynamic import for DOCX extraction | |
| * - lucide-react: icons | |
| * | |
| * Firestore Structure (expected) | |
| * ------------------------------ | |
| * users/{uid}: | |
| * - stats: { | |
| * textsHumanized: number, | |
| * wordsProcessed: number, | |
| * gptAverage: number, // not changed here, used elsewhere | |
| * totalSessions: number, | |
| * averageSessionTime: number // minutes, rounded to integer | |
| * } | |
| * - preferences: { theme, ... } | |
| * - lastActive: ISO string | |
| * | |
| * user_activities (collection): | |
| * - { email, action, mode, wordCount, timestamp, type } | |
| * | |
| * humanized_texts (collection): | |
| * - { userId, originalText, textHash, humanizedText, createdAt } | |
| * | |
| * Notes | |
| * ----- | |
| * - averageSessionTime is stored in MINUTES as an integer weighted running average. | |
| * - totalSessions is incremented at "session start" (when user clicks "Humanize"). | |
| * - We compute duration on completion (cache hit or API success) and update running avg. | |
| * - If session fails to produce output, we simply do not update the average (the session | |
| * counter already went up; you can adapt this if you want to only count successful | |
| * sessions by moving the increment to the success branch). | |
| * - To keep the UX snappy and resilient, stats updates are "fire-and-forget" where safe, | |
| * and wrapped with try/catch. | |
| * | |
| * ------------------------------------------------------------------------------------- | |
| */ | |
| import React, { | |
| useState, | |
| useContext, | |
| useMemo, | |
| useRef, | |
| useCallback, | |
| useEffect, | |
| } from "react"; | |
| import { ThemeContext } from "../contexts/ThemeContext"; | |
| import { sha256 } from "crypto-hash"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| import { | |
| Wand2, | |
| Copy, | |
| Download, | |
| AlertCircle, | |
| CheckCircle, | |
| Upload, | |
| Info, | |
| } from "lucide-react"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { auth, db } from "../firebase"; | |
| import { | |
| doc, | |
| updateDoc, | |
| increment, | |
| addDoc, | |
| collection, | |
| Timestamp, | |
| query, | |
| where, | |
| getDocs, | |
| getDoc, | |
| Firestore, | |
| DocumentReference, | |
| DocumentData, | |
| } from "firebase/firestore"; | |
| /* ---------------------------------------------------------------------------------- */ | |
| /* Types */ | |
| /* ---------------------------------------------------------------------------------- */ | |
| type HumanizeMode = "normal" | "advanced" | "expert"; | |
| interface HumanizerProps { | |
| onModeChange?: (mode: HumanizeMode) => void; | |
| } | |
| /** | |
| * Firestore user.stats model (subset used here). | |
| */ | |
| interface UserStats { | |
| textsHumanized?: number; | |
| wordsProcessed?: number; | |
| gptAverage?: number; | |
| totalSessions?: number; | |
| averageSessionTime?: number; // minutes | |
| } | |
| /** | |
| * Firestore user record (subset used here). | |
| */ | |
| interface UserDoc { | |
| email?: string; | |
| stats?: UserStats; | |
| preferences?: { | |
| theme?: "dark" | "light"; | |
| [k: string]: unknown; | |
| }; | |
| [k: string]: unknown; | |
| } | |
| /** | |
| * Response shape from the backend humanizer API (`/api/routes.php`). | |
| */ | |
| interface HumanizerAPIResponse { | |
| success: boolean; | |
| humanizedText?: string; | |
| error?: string; | |
| } | |
| /** | |
| * Record in `humanized_texts` collection. | |
| */ | |
| interface HumanizedTextRecord { | |
| userId: string; | |
| originalText: string; // stored for convenience (you can remove if sensitive) | |
| textHash: string; // sha256 of originalText | |
| humanizedText: string; | |
| createdAt: Timestamp; | |
| } | |
| /* ---------------------------------------------------------------------------------- */ | |
| /* Utilities */ | |
| /* ---------------------------------------------------------------------------------- */ | |
| /** | |
| * Safely returns the current user or null. | |
| */ | |
| const getCurrentUser = () => auth.currentUser ?? null; | |
| /** | |
| * Convert a duration in milliseconds to a whole number of minutes, lower-bounded at 0. | |
| * We round down to keep a stable integer for averageSessionTime. | |
| */ | |
| const msToWholeMinutes = (ms: number): number => { | |
| const mins = Math.floor(ms / 60000); | |
| return mins < 0 ? 0 : mins; | |
| }; | |
| /** | |
| * Weighted running average: | |
| * newAvg = (prevAvg * prevCount + newValue) / (prevCount + 1) | |
| * Caller must supply the counts correctly (e.g., totalSessions already includes | |
| * this new session, or not, depending on where you do the increment). | |
| */ | |
| const weightedAverage = ( | |
| prevAvg: number, | |
| prevCount: number, | |
| newValue: number | |
| ) => { | |
| if (!Number.isFinite(prevAvg) || prevAvg < 0) prevAvg = 0; | |
| if (!Number.isFinite(prevCount) || prevCount < 0) prevCount = 0; | |
| if (!Number.isFinite(newValue) || newValue < 0) newValue = 0; | |
| // If prevCount is 0, average is simply newValue | |
| if (prevCount === 0) return newValue; | |
| // Normal running average for (prevCount + 1) samples | |
| return (prevAvg * prevCount + newValue) / (prevCount + 1); | |
| }; | |
| /** | |
| * Count words in a text by splitting on whitespace. | |
| */ | |
| const countWords = (t: string) => (t.trim() ? t.trim().split(/\s+/).length : 0); | |
| /** | |
| * Defensive localStorage helper (optional use). | |
| */ | |
| const safeLocalSet = (key: string, value: unknown) => { | |
| try { | |
| localStorage.setItem(key, JSON.stringify(value)); | |
| } catch { | |
| /* no-op */ | |
| } | |
| }; | |
| const safeLocalGet = <T,>(key: string, fallback: T): T => { | |
| try { | |
| const raw = localStorage.getItem(key); | |
| return raw ? (JSON.parse(raw) as T) : fallback; | |
| } catch { | |
| return fallback; | |
| } | |
| }; | |
| /* ---------------------------------------------------------------------------------- */ | |
| /* Firestore Helper Functions */ | |
| /* ---------------------------------------------------------------------------------- */ | |
| /** | |
| * Returns the user document reference for the current user, or null if not logged in. | |
| */ | |
| const getUserRef = (dbInst: Firestore): DocumentReference<DocumentData> | null => | |
| getCurrentUser() ? doc(dbInst, "users", getCurrentUser()!.uid) : null; | |
| /** | |
| * Increment `stats.totalSessions` at session *start*. This function does not await the | |
| * result (fire-and-forget); you can await if you want strict ordering. | |
| */ | |
| const incrementTotalSessions = async (dbInst: Firestore) => { | |
| const userRef = getUserRef(dbInst); | |
| if (!userRef) return; | |
| try { | |
| await updateDoc(userRef, { | |
| "stats.totalSessions": increment(1), | |
| }); | |
| } catch (e) { | |
| console.warn("[incrementTotalSessions] Failed to update total sessions:", e); | |
| } | |
| }; | |
| /** | |
| * Update average session time with a new duration (in minutes). | |
| * The logic: | |
| * - We first read the current stats to obtain current average and totalSessions. | |
| * - We compute a new weighted average using totalSessions as the count. | |
| * - We then write the new average. | |
| * | |
| * Note: We assume `totalSessions` was incremented at session start. | |
| */ | |
| const updateAverageSessionTime = async ( | |
| dbInst: Firestore, | |
| durationMinutes: number | |
| ) => { | |
| const userRef = getUserRef(dbInst); | |
| if (!userRef) return; | |
| try { | |
| const snap = await getDoc(userRef); | |
| if (!snap.exists()) return; | |
| const user = snap.data() as UserDoc; | |
| const prevAvg = user?.stats?.averageSessionTime || 0; | |
| const totalSessions = user?.stats?.totalSessions || 0; | |
| // If for any reason totalSessions is 0, treat as new sample (avoid div by zero). | |
| const countForAvg = Math.max(totalSessions, 0); | |
| // Weighted average with the *new* value included. | |
| // Because totalSessions was incremented at session start, that value already | |
| // represents the new count. We should use (countForAvg - 1) as prevCount. | |
| const prevCount = Math.max(countForAvg - 1, 0); | |
| const nextAvg = weightedAverage(prevAvg, prevCount, durationMinutes); | |
| await updateDoc(userRef, { | |
| "stats.averageSessionTime": Math.max(0, Math.round(nextAvg)), // store rounded integer minutes | |
| }); | |
| } catch (e) { | |
| console.warn( | |
| "[updateAverageSessionTime] Failed to compute/write average:", | |
| e | |
| ); | |
| } | |
| }; | |
| /** | |
| * Update basic counters after successful result (either from cache or API). | |
| * - textsHumanized +1 | |
| * - wordsProcessed +{wordCount} | |
| * - lastActive (ISO string) | |
| */ | |
| const bumpContentStats = async (dbInst: Firestore, wordCount: number) => { | |
| const userRef = getUserRef(dbInst); | |
| if (!userRef) return; | |
| try { | |
| await updateDoc(userRef, { | |
| "stats.textsHumanized": increment(1), | |
| "stats.wordsProcessed": increment(Math.max(0, wordCount)), | |
| lastActive: new Date().toISOString(), | |
| }); | |
| } catch (e) { | |
| console.warn("[bumpContentStats] Failed to update stats:", e); | |
| } | |
| }; | |
| /** | |
| * Log user activity to `user_activities`. | |
| */ | |
| const logActivity = async (dbInst: Firestore, payload: Record<string, any>) => { | |
| try { | |
| await addDoc(collection(dbInst, "user_activities"), { | |
| ...payload, | |
| timestamp: Timestamp.fromDate(new Date()), | |
| }); | |
| } catch (e) { | |
| console.warn("[logActivity] Failed to write activity log:", e); | |
| } | |
| }; | |
| /** | |
| * Query `humanized_texts` by (userId + textHash). Returns the first found record or null. | |
| */ | |
| const findCachedHumanizedText = async ( | |
| dbInst: Firestore, | |
| userId: string, | |
| textHash: string | |
| ): Promise<HumanizedTextRecord | null> => { | |
| try { | |
| const q = query( | |
| collection(dbInst, "humanized_texts"), | |
| where("userId", "==", userId), | |
| where("textHash", "==", textHash) | |
| ); | |
| const snap = await getDocs(q); | |
| if (snap.empty) return null; | |
| return snap.docs[0].data() as HumanizedTextRecord; | |
| } catch (e) { | |
| console.warn("[findCachedHumanizedText] Query error:", e); | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Persist a new record into `humanized_texts`. | |
| */ | |
| const saveHumanizedText = async ( | |
| dbInst: Firestore, | |
| record: Omit<HumanizedTextRecord, "createdAt"> | |
| ) => { | |
| try { | |
| await addDoc(collection(dbInst, "humanized_texts"), { | |
| ...record, | |
| createdAt: Timestamp.fromDate(new Date()), | |
| }); | |
| } catch (e) { | |
| console.warn("[saveHumanizedText] Failed to save cache record:", e); | |
| } | |
| }; | |
| /* ---------------------------------------------------------------------------------- */ | |
| /* Component: Humanizer */ | |
| /* ---------------------------------------------------------------------------------- */ | |
| const HumanizerInterface: React.FC<HumanizerProps> = ({ onModeChange }) => { | |
| const { theme } = useContext(ThemeContext); | |
| const navigate = useNavigate(); | |
| // ---------------------------------------------------------------------------------- | |
| // UI State | |
| // ---------------------------------------------------------------------------------- | |
| const [inputText, setInputText] = useState<string>(""); | |
| const [outputText, setOutputText] = useState<string>(""); | |
| const [mode, setMode] = useState<HumanizeMode>("normal"); | |
| const [loading, setLoading] = useState<boolean>(false); | |
| const [error, setError] = useState<string>(""); | |
| const [fileName, setFileName] = useState<string>(""); | |
| const [copied, setCopied] = useState<boolean>(false); | |
| // Session start timestamp (ms). Reset on completion or error. | |
| const sessionStartRef = useRef<number | null>(null); | |
| // Memoized counts for UX | |
| const inputWordCount = useMemo(() => countWords(inputText), [inputText]); | |
| const outputWordCount = useMemo(() => countWords(outputText), [outputText]); | |
| // Tailwind class shortcuts based on theme | |
| const cardBg = theme === "dark" ? "bg-gray-800" : "bg-white"; | |
| const textPrimary = theme === "dark" ? "text-white" : "text-gray-900"; | |
| const textSecondary = theme === "dark" ? "text-gray-300" : "text-gray-600"; | |
| const borderCard = theme === "dark" ? "border-gray-700" : "border-gray-200"; | |
| // ---------------------------------------------------------------------------------- | |
| // Mode Handling | |
| // ---------------------------------------------------------------------------------- | |
| const handleModeChange = (newMode: HumanizeMode) => { | |
| setMode(newMode); | |
| onModeChange?.(newMode); | |
| }; | |
| // ---------------------------------------------------------------------------------- | |
| // File Upload (.txt / .docx) | |
| // ---------------------------------------------------------------------------------- | |
| const handleFileUpload = async ( | |
| e: React.ChangeEvent<HTMLInputElement> | |
| ): Promise<void> => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| setError(""); | |
| setFileName(file.name); | |
| try { | |
| if (file.type === "text/plain" || file.name.endsWith(".txt")) { | |
| const text = await file.text(); | |
| setInputText(text); | |
| } else if ( | |
| file.type === | |
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || | |
| file.name.endsWith(".docx") | |
| ) { | |
| const reader = new FileReader(); | |
| reader.onload = async (event) => { | |
| try { | |
| const arrayBuffer = event.target?.result as ArrayBuffer; | |
| // Dynamic import for lightweight initial bundle | |
| const mammoth = await import("mammoth"); | |
| const result = await mammoth.extractRawText({ arrayBuffer }); | |
| setInputText(result.value || ""); | |
| } catch (docxErr) { | |
| console.error(docxErr); | |
| setError("Failed to parse DOCX file"); | |
| } | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| } else { | |
| setError("Unsupported file type. Please upload .txt or .docx"); | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| setError("Error reading file"); | |
| } | |
| }; | |
| // ---------------------------------------------------------------------------------- | |
| // Hash-based Cache Lookup | |
| // ---------------------------------------------------------------------------------- | |
| const checkHumanizedText = useCallback( | |
| async (text: string): Promise<HumanizedTextRecord | null> => { | |
| const user = getCurrentUser(); | |
| if (!user) return null; | |
| try { | |
| const textHash = await sha256(text); | |
| const cached = await findCachedHumanizedText(db, user.uid, textHash); | |
| return cached; | |
| } catch (e) { | |
| console.warn("[checkHumanizedText] Error:", e); | |
| return null; | |
| } | |
| }, | |
| [] | |
| ); | |
| // ---------------------------------------------------------------------------------- | |
| // Copy & Download helpers | |
| // ---------------------------------------------------------------------------------- | |
| const copyOutput = () => { | |
| if (!outputText) return; | |
| navigator.clipboard.writeText(outputText); | |
| setCopied(true); | |
| window.setTimeout(() => setCopied(false), 2000); | |
| }; | |
| const downloadOutput = () => { | |
| if (!outputText) return; | |
| const blob = new Blob([outputText], { type: "text/plain" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `humanized-text-${Date.now()}.txt`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| // ---------------------------------------------------------------------------------- | |
| // Session Flow | |
| // ---------------------------------------------------------------------------------- | |
| /** | |
| * Entry point from the "Humanize" button. | |
| * 1) Ensures user is logged in (redirects if not). | |
| * 2) Saves session start time. | |
| * 3) Increments totalSessions immediately. | |
| * 4) Calls humanizeText() which handles cache/API + result writes. | |
| */ | |
| const handleHumanizeClick = async () => { | |
| const user = getCurrentUser(); | |
| if (!user) { | |
| navigate("/login"); | |
| return; | |
| } | |
| // Start a new session | |
| sessionStartRef.current = Date.now(); | |
| // Count this as a session start (you can move this to success if you only | |
| // want to count successful sessions). | |
| await incrementTotalSessions(db); | |
| // Proceed with processing | |
| await humanizeText(); | |
| }; | |
| /** | |
| * Compute duration since session start and write to average if we have a start time. | |
| * We use "minutes" as our stored unit (rounded). | |
| */ | |
| const finalizeSessionDuration = async () => { | |
| if (!sessionStartRef.current) return; | |
| const ms = Date.now() - sessionStartRef.current; | |
| const durationMinutes = msToWholeMinutes(ms); | |
| await updateAverageSessionTime(db, durationMinutes); | |
| sessionStartRef.current = null; | |
| }; | |
| // ---------------------------------------------------------------------------------- | |
| // Humanization Orchestration | |
| // ---------------------------------------------------------------------------------- | |
| /** | |
| * Central function that: | |
| * - Validates input | |
| * - Checks cache | |
| * - Calls the API if needed | |
| * - Writes Firestore stats and activity logs | |
| * - Updates average session time | |
| */ | |
| const humanizeText = async () => { | |
| if (!inputText.trim()) { | |
| setError("Please enter or upload text to humanize"); | |
| return; | |
| } | |
| const wordCount = countWords(inputText); | |
| if (wordCount > 6000) { | |
| setError(`Word limit exceeded. Max 6,000 words. Current: ${wordCount}.`); | |
| return; | |
| } | |
| const user = getCurrentUser(); | |
| if (!user) { | |
| setError("Please log in to continue"); | |
| navigate("/login"); | |
| return; | |
| } | |
| try { | |
| setLoading(true); | |
| setError(""); | |
| // Step 1: Cache lookup | |
| const cached = await checkHumanizedText(inputText); | |
| if (cached?.humanizedText) { | |
| setOutputText(cached.humanizedText); | |
| // Step 2a: Update stats for a successful "result" (from cache) | |
| await bumpContentStats(db, wordCount); | |
| // Step 2b: Update average session time | |
| await finalizeSessionDuration(); | |
| // Step 2c: Log activity | |
| await logActivity(db, { | |
| email: user.email ?? null, | |
| action: "Humanized Text (cache)", | |
| mode, | |
| wordCount, | |
| type: "humanizer", | |
| }); | |
| return; // Done | |
| } | |
| // Step 2: Call backend API | |
| const resp = await fetch("https://humanizerx.pro/api/routes.php", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| text: inputText, | |
| mode, | |
| userId: user.uid, | |
| }), | |
| }); | |
| let data: HumanizerAPIResponse | null = null; | |
| try { | |
| data = (await resp.json()) as HumanizerAPIResponse; | |
| } catch { | |
| data = null; | |
| } | |
| if (!resp.ok || !data || !data.success || !data.humanizedText) { | |
| const msg = | |
| data?.error || | |
| `Failed to humanize text (HTTP ${resp.status}). Please try again.`; | |
| setError(msg); | |
| // NOTE: If you only want to count successful sessions, consider decrementing | |
| // totalSessions here, or move the increment to after success. We keep the counter | |
| // as-is to reflect attempted sessions. We also skip average time update on error. | |
| return; | |
| } | |
| // Step 3: Display result | |
| setOutputText(data.humanizedText); | |
| // Step 4: Save to cache (best-effort) | |
| try { | |
| await saveHumanizedText(db, { | |
| userId: user.uid, | |
| originalText: inputText, // keep or remove based on your privacy needs | |
| textHash: await sha256(inputText), | |
| humanizedText: data.humanizedText, | |
| }); | |
| } catch { | |
| /* non-fatal */ | |
| } | |
| // Step 5: Update stats | |
| await bumpContentStats(db, wordCount); | |
| // Step 6: Average session time | |
| await finalizeSessionDuration(); | |
| // Step 7: Log activity | |
| await logActivity(db, { | |
| email: user.email ?? null, | |
| action: "Humanized Text", | |
| mode, | |
| wordCount, | |
| type: "humanizer", | |
| }); | |
| } catch (err: any) { | |
| console.error(err); | |
| setError(err?.message || "Error processing text"); | |
| // On error, we do not finalize session time; you can choose to do it if desired. | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| // ---------------------------------------------------------------------------------- | |
| // Optional: Persist last used mode locally (so it survives reloads) | |
| // ---------------------------------------------------------------------------------- | |
| useEffect(() => { | |
| const lastMode = safeLocalGet<HumanizeMode>("humanizer:lastMode", "normal"); | |
| if (lastMode !== mode) { | |
| setMode(lastMode); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| useEffect(() => { | |
| safeLocalSet("humanizer:lastMode", mode); | |
| }, [mode]); | |
| // ---------------------------------------------------------------------------------- | |
| // Render | |
| // ---------------------------------------------------------------------------------- | |
| return ( | |
| <div className="space-y-6"> | |
| {/* MODE SELECTION */} | |
| <div className={`${cardBg} rounded-xl shadow-lg p-6`}> | |
| <div className="flex items-start justify-between mb-4"> | |
| <h2 className={`text-lg font-semibold ${textPrimary}`}> | |
| Humanization Mode | |
| </h2> | |
| <div | |
| className={`flex items-center gap-2 text-xs px-2 py-1 rounded-md ${ | |
| theme === "dark" | |
| ? "bg-gray-700 text-gray-300" | |
| : "bg-gray-100 text-gray-600" | |
| }`} | |
| title="Modes adjust how aggressively the text is rephrased." | |
| > | |
| <Info size={14} /> | |
| <span>Modes adjust rewrite strength</span> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> | |
| {([ | |
| { id: "normal", name: "Normal", desc: "Balanced humanization" }, | |
| { id: "advanced", name: "Advanced", desc: "Stronger rephrasing" }, | |
| { id: "expert", name: "Expert", desc: "Maximum transformation" }, | |
| ] as { id: HumanizeMode; name: string; desc: string }[]).map( | |
| (modeOption) => ( | |
| <button | |
| key={modeOption.id} | |
| onClick={() => handleModeChange(modeOption.id)} | |
| className={`p-3 rounded-lg border-2 transition-all text-left ${ | |
| mode === modeOption.id | |
| ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" | |
| : theme === "dark" | |
| ? "border-gray-600 hover:border-gray-500" | |
| : "border-gray-200 hover:border-gray-300" | |
| }`} | |
| > | |
| <div className={`font-medium ${textPrimary}`}> | |
| {modeOption.name} | |
| </div> | |
| <div className={`text-sm ${textSecondary}`}> | |
| {modeOption.desc} | |
| </div> | |
| </button> | |
| ) | |
| )} | |
| </div> | |
| </div> | |
| {/* MAIN GRID */} | |
| <div className="grid lg:grid-cols-2 gap-6"> | |
| {/* INPUT SECTION */} | |
| <div | |
| className={`${cardBg} rounded-xl shadow-lg p-6 border ${borderCard} space-y-4`} | |
| > | |
| <div className="flex justify-between items-center mb-2"> | |
| <h3 className={`text-sm font-medium ${textPrimary}`}>Input Text</h3> | |
| <label className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg cursor-pointer hover:bg-blue-700 transition-colors shadow-md"> | |
| <Upload size={18} /> | |
| <span>Upload</span> | |
| <input | |
| type="file" | |
| accept=".txt,.docx" | |
| onChange={handleFileUpload} | |
| className="hidden" | |
| /> | |
| </label> | |
| </div> | |
| <div | |
| className={`relative h-96 border rounded-xl shadow-inner transition-all duration-200 ${ | |
| theme === "dark" | |
| ? "bg-gradient-to-br from-gray-800 to-gray-900 border-gray-700 text-white" | |
| : "bg-gradient-to-br from-gray-50 to-white border-gray-300 text-gray-900" | |
| }`} | |
| > | |
| <textarea | |
| value={inputText} | |
| onChange={(e) => setInputText(e.target.value)} | |
| placeholder="Paste text here or upload a file..." | |
| className="w-full h-full p-4 resize-none bg-transparent focus:ring-2 focus:ring-blue-500 focus:border-transparent rounded-xl" | |
| style={{ | |
| lineHeight: "1.6", | |
| fontSize: "0.95rem", | |
| fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system", | |
| }} | |
| /> | |
| {fileName && ( | |
| <p | |
| className={`absolute bottom-2 left-4 text-xs ${textSecondary} truncate max-w-[70%]`} | |
| > | |
| Uploaded: {fileName} | |
| </p> | |
| )} | |
| </div> | |
| <div className="flex justify-between items-center"> | |
| <span | |
| className={`text-sm ${ | |
| inputWordCount > 6000 | |
| ? theme === "dark" | |
| ? "text-red-400" | |
| : "text-red-600" | |
| : textSecondary | |
| }`} | |
| > | |
| {inputWordCount.toLocaleString()} / 6,000 words | |
| </span> | |
| <button | |
| onClick={handleHumanizeClick} | |
| disabled={loading || !inputText.trim() || inputWordCount > 6000} | |
| className="px-6 py-3 bg-gradient-to-r from-indigo-900 to-slate-900 text-white rounded-lg font-medium hover:from-indigo-600 hover:to-slate-700 disabled:opacity-50 transition-all duration-200 flex items-center gap-2" | |
| > | |
| <Wand2 size={16} /> | |
| {loading ? "Humanizing..." : "Humanize Text"} | |
| </button> | |
| </div> | |
| </div> | |
| {/* OUTPUT SECTION */} | |
| <div | |
| className={`${cardBg} rounded-xl shadow-lg p-6 border ${borderCard} space-y-4`} | |
| > | |
| <div className="flex items-center justify-between"> | |
| <h3 className={`text-lg font-semibold ${textPrimary}`}> | |
| Humanized Text | |
| </h3> | |
| <div className="flex gap-2"> | |
| {outputText && ( | |
| <> | |
| <button | |
| onClick={copyOutput} | |
| className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-300 ${ | |
| copied | |
| ? "bg-green-500 text-white" | |
| : "bg-gray-600 text-white hover:bg-gray-700" | |
| }`} | |
| > | |
| <Copy size={16} /> | |
| {copied ? "Copied!" : "Copy"} | |
| </button> | |
| <button | |
| onClick={downloadOutput} | |
| className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors" | |
| > | |
| <Download size={16} /> | |
| Download | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {loading ? ( | |
| <div className="flex flex-col items-center justify-center h-96"> | |
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mb-3"></div> | |
| <p className={textSecondary}>Processing your text...</p> | |
| </div> | |
| ) : outputText ? ( | |
| <> | |
| <div | |
| className={`w-full h-96 p-4 border rounded-xl overflow-y-auto shadow-inner text-sm leading-relaxed prose prose-sm max-w-none ${ | |
| theme === "dark" | |
| ? "prose-invert bg-gradient-to-br from-gray-800 to-gray-900 border-gray-700 text-gray-100" | |
| : "bg-gradient-to-br from-gray-50 to-white border-gray-300 text-gray-800" | |
| }`} | |
| style={{ whiteSpace: "pre-wrap" }} // ✅ preserve spaces & blank lines | |
| > | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}> | |
| {outputText} | |
| </ReactMarkdown> | |
| </div> | |
| <div className="flex justify-between items-center"> | |
| <span className={`text-sm ${textSecondary}`}> | |
| {outputWordCount.toLocaleString()} words | |
| </span> | |
| <div | |
| className={`flex items-center gap-2 ${ | |
| theme === "dark" ? "text-green-400" : "text-green-600" | |
| }`} | |
| > | |
| <CheckCircle size={16} /> | |
| <span className="text-sm font-medium"> | |
| Text humanized successfully | |
| </span> | |
| </div> | |
| </div> | |
| </> | |
| ) : ( | |
| <div | |
| className={`flex flex-col items-center justify-center h-96 ${textSecondary}`} | |
| > | |
| <Wand2 size={48} className="mb-3 opacity-50" /> | |
| <p>Humanized text will appear here...</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* ERROR STATE */} | |
| {error && ( | |
| <div | |
| className={`rounded-lg p-4 border ${ | |
| theme === "dark" | |
| ? "bg-red-900/20 border-red-800 text-red-300" | |
| : "bg-red-50 border-red-200 text-red-700" | |
| }`} | |
| > | |
| <div className="flex items-center gap-2 text-sm"> | |
| <AlertCircle size={16} /> | |
| <span>{error}</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* FOOTNOTE / HINTS */} | |
| <div | |
| className={`text-xs ${ | |
| theme === "dark" ? "text-gray-400" : "text-gray-500" | |
| }`} | |
| > | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default HumanizerInterface; | |
| /* ---------------------------------------------------------------------------------- */ | |
| /* EOF */ | |
| /* ---------------------------------------------------------------------------------- */ | |