humanizerx / src /components /HumanizerInterface.tsx
mmrwinston001's picture
Upload 32 files (#3)
6ce679b verified
// 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 = <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 */
/* ---------------------------------------------------------------------------------- */