FakeNews-XAI / frontend /src /components /ArticleInput.tsx
Marius16's picture
Testes + refactor
f22b61b
Raw
History Blame Contribute Delete
10.3 kB
import React, { useEffect, useRef, useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Chip,
CircularProgress,
FormControl,
FormControlLabel,
FormHelperText,
FormLabel,
InputLabel,
MenuItem,
Radio,
RadioGroup,
Select,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import type { SelectChangeEvent } from "@mui/material";
import { Clear, ExpandMore, PlayArrow, Upload } from "@mui/icons-material";
import type { AnalyzeRequest, ModelsResponse } from "../types";
import { getModelDescription, getModelLabel } from "../utils/modelLabels";
import { uploadFile } from "../api/client";
// Fields that ArticleInput owns (text, metadata — no pipeline/model)
type ArticleFields = Omit<AnalyzeRequest, "pipeline" | "model">;
interface ArticleInputProps {
onSubmit?: (request: AnalyzeRequest) => void;
isLoading: boolean;
showPipelineSelector?: boolean;
availableModels?: ModelsResponse;
// When true, hide the submit button (CompareTab renders its own button)
hideSubmitButton?: boolean;
// Fires on every text/metadata change so parent can track current content
onRequestChange?: (request: ArticleFields) => void;
}
function ArticleInput({
onSubmit,
isLoading,
showPipelineSelector = true,
availableModels,
hideSubmitButton = false,
onRequestChange,
}: ArticleInputProps): React.ReactElement {
const [inputMode, setInputMode] = useState<"text" | "file">("text");
const [textValue, setTextValue] = useState<string>("");
const [isUploading, setIsUploading] = useState<boolean>(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [extractedInfo, setExtractedInfo] = useState<{ filename: string; char_count: number } | null>(null);
const [title, setTitle] = useState<string>("");
const [publicationDate, setPublicationDate] = useState<string>("");
const [source, setSource] = useState<string>("");
const [pipeline, setPipeline] = useState<"spacy" | "llm">("spacy");
const [model, setModel] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync model default when pipeline changes or availableModels first loads
useEffect(() => {
if (availableModels) {
setModel(availableModels[pipeline].default);
}
}, [availableModels, pipeline]);
// Notify parent whenever article content changes (used by CompareTab)
const onRequestChangeRef = useRef(onRequestChange);
onRequestChangeRef.current = onRequestChange;
useEffect(() => {
onRequestChangeRef.current?.({
text: textValue,
title: title || undefined,
publication_date: publicationDate || null,
source: source || undefined,
});
}, [textValue, title, publicationDate, source]);
const handleModeChange = (
_: React.MouseEvent<HTMLElement>,
newMode: string | null,
): void => {
if (newMode !== null) {
setInputMode(newMode as "text" | "file");
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0];
if (!file) return;
setUploadError(null);
setExtractedInfo(null);
setIsUploading(true);
try {
const result = await uploadFile(file);
setTextValue(result.text);
setExtractedInfo({ filename: result.filename, char_count: result.char_count });
setInputMode("text");
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
"Upload failed";
setUploadError(msg);
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleClearFile = (): void => {
setExtractedInfo(null);
setUploadError(null);
setTextValue("");
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSubmit = (): void => {
if (!onSubmit) return;
const request: AnalyzeRequest = {
text: textValue,
title: title || undefined,
publication_date: publicationDate || null,
source: source || undefined,
pipeline: showPipelineSelector ? pipeline : undefined,
model: showPipelineSelector && model ? model : undefined,
};
onSubmit(request);
};
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ToggleButtonGroup
value={inputMode}
exclusive
onChange={handleModeChange}
size="small"
>
<ToggleButton value="text">Paste Text</ToggleButton>
<ToggleButton value="file">Upload File</ToggleButton>
</ToggleButtonGroup>
{inputMode === "text" && (
<Box>
<TextField
multiline
minRows={8}
fullWidth
placeholder="Paste article text here..."
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
sx={{ "& .MuiInputBase-input": { fontFamily: "IBM Plex Mono, monospace" } }}
/>
<Box sx={{ mt: 0.5, display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" color="text.secondary">
{textValue.length} characters
</Typography>
{extractedInfo && (
<Chip
size="small"
label={`Extracted from: ${extractedInfo.filename} (${extractedInfo.char_count} chars)`}
onDelete={handleClearFile}
color="info"
variant="outlined"
/>
)}
</Box>
</Box>
)}
{inputMode === "file" && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<input
type="file"
accept=".txt,.pdf,.docx,.doc"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: "none" }}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Button
variant="outlined"
startIcon={isUploading ? <CircularProgress size={18} color="inherit" /> : <Upload />}
onClick={() => !isUploading && fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? "Extracting..." : "Choose File"}
</Button>
{!isUploading && extractedInfo && (
<Button
size="small"
color="inherit"
startIcon={<Clear />}
onClick={handleClearFile}
>
Clear
</Button>
)}
</Box>
{uploadError && (
<Typography variant="caption" color="error">
{uploadError}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
Supported formats: .txt, .pdf, .docx
</Typography>
</Box>
)}
<Accordion
disableGutters
elevation={0}
sx={{
"&:before": { display: "none" },
border: "1px solid rgba(148,163,184,0.15)",
borderRadius: 1,
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant="body2">Article Metadata</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
label="Title"
fullWidth
value={title}
onChange={(e) => setTitle(e.target.value)}
size="small"
/>
<TextField
label="Publication Date"
type="date"
fullWidth
value={publicationDate}
onChange={(e) => setPublicationDate(e.target.value)}
size="small"
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Source"
fullWidth
value={source}
onChange={(e) => setSource(e.target.value)}
size="small"
/>
</Box>
</AccordionDetails>
</Accordion>
{showPipelineSelector && (
<>
<FormControl>
<FormLabel>Pipeline</FormLabel>
<RadioGroup
value={pipeline}
onChange={(e) => setPipeline(e.target.value as "spacy" | "llm")}
row
>
<FormControlLabel
value="spacy"
control={<Radio size="small" />}
label="Pipeline A"
/>
<FormControlLabel
value="llm"
control={<Radio size="small" />}
label="Pipeline B"
/>
</RadioGroup>
</FormControl>
{availableModels && (
<FormControl size="small" sx={{ maxWidth: 360 }}>
<InputLabel>Model</InputLabel>
<Select
value={model}
label="Model"
onChange={(e: SelectChangeEvent<string>) => setModel(e.target.value)}
>
{availableModels[pipeline].models.map((m) => (
<MenuItem key={m} value={m}>
{getModelLabel(m)}
</MenuItem>
))}
</Select>
{model && (
<FormHelperText>{getModelDescription(model)}</FormHelperText>
)}
</FormControl>
)}
</>
)}
{!hideSubmitButton && (
<Box>
<Button
variant="contained"
color="primary"
startIcon={
isLoading ? (
<CircularProgress size={20} color="inherit" />
) : (
<PlayArrow />
)
}
disabled={textValue.length < 20 || isLoading}
onClick={handleSubmit}
>
{isLoading ? "Analyzing..." : "Analyze"}
</Button>
</Box>
)}
</Box>
);
}
export default ArticleInput;