// 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}: * - email * - 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 = (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 | 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) => { 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 => { 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 ) => { 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 = ({ onModeChange }) => { const { theme } = useContext(ThemeContext); const navigate = useNavigate(); // ---------------------------------------------------------------------------------- // UI State // ---------------------------------------------------------------------------------- const [inputText, setInputText] = useState(""); const [outputText, setOutputText] = useState(""); const [mode, setMode] = useState("normal"); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [fileName, setFileName] = useState(""); const [copied, setCopied] = useState(false); // Session start timestamp (ms). Reset on completion or error. const sessionStartRef = useRef(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 ): Promise => { 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 => { 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("humanizer:lastMode", "normal"); if (lastMode !== mode) { setMode(lastMode); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { safeLocalSet("humanizer:lastMode", mode); }, [mode]); // ---------------------------------------------------------------------------------- // Render // ---------------------------------------------------------------------------------- return (
{/* MODE SELECTION */}

Humanization Mode

Modes adjust rewrite strength
{([ { 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) => ( ) )}
{/* MAIN GRID */}
{/* INPUT SECTION */}

Input Text