MAESTRO: add file drop zone component

This commit is contained in:
Mariusz Banach
2026-02-18 00:36:47 +01:00
parent 3e842eede1
commit c84e271e29
2 changed files with 140 additions and 1 deletions

View File

@@ -30,7 +30,7 @@ This phase implements the user-facing input layer: a multi-line textarea for pas
- [x] T015 [US1] Write failing tests (TDD Red) in `frontend/src/__tests__/HeaderInput.test.tsx` (render, paste simulation, empty/oversized validation), `frontend/src/__tests__/FileDropZone.test.tsx` (render, drop event, file type filtering), and `frontend/src/__tests__/AnalyseButton.test.tsx` (render, disabled state, Ctrl+Enter shortcut)
- [x] T016 [US1] Create main page layout in `frontend/src/app/page.tsx` with dark hacker theme (#1e1e2e background, monospace code areas, project title). Responsive from 320px to 2560px (NFR-04)
- [x] T017 [P] [US1] Create `frontend/src/components/HeaderInput.tsx` — multi-line textarea for SMTP headers with placeholder, character count, clear button (FontAwesome icon), monospace styling, keyboard accessible (NFR-02), validation for empty and oversized >1MB input (NFR-10). Verify `HeaderInput.test.tsx` passes (TDD Green)
- [ ] T018 [P] [US1] Create `frontend/src/components/FileDropZone.tsx` — drag-and-drop zone accepting `.eml` and `.txt` files, reads client-side via File API (FR-02), populates HeaderInput on drop, shows drag-over highlight and rejection feedback, FontAwesome upload icon. Verify `FileDropZone.test.tsx` passes (TDD Green)
- [x] T018 [P] [US1] Create `frontend/src/components/FileDropZone.tsx` — drag-and-drop zone accepting `.eml` and `.txt` files, reads client-side via File API (FR-02), populates HeaderInput on drop, shows drag-over highlight and rejection feedback, FontAwesome upload icon. Verify `FileDropZone.test.tsx` passes (TDD Green)
- [ ] T019 [US1] Create `frontend/src/components/AnalyseButton.tsx` — primary action button with FontAwesome analyse icon, Ctrl+Enter shortcut (FR-05), disabled when input empty, loading state during analysis (NFR-05), hacker accent colour. Verify `AnalyseButton.test.tsx` passes (TDD Green)
## Completion

View File

@@ -0,0 +1,139 @@
"use client";
import { type DragEventHandler, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowUpFromBracket } from "@fortawesome/free-solid-svg-icons";
const MAX_FILE_BYTES = 1024 * 1024;
const ACCEPTED_EXTENSIONS = new Set([".eml", ".txt"]);
const ACCEPTED_MIME_TYPES = new Set(["message/rfc822", "text/plain"]);
type FileDropZoneProps = {
onFileContent: (content: string) => void;
};
const getExtension = (fileName: string): string => {
const index = fileName.lastIndexOf(".");
if (index === -1) {
return "";
}
return fileName.slice(index).toLowerCase();
};
const isSupportedFile = (file: File): boolean => {
const extension = getExtension(file.name);
if (ACCEPTED_EXTENSIONS.has(extension)) {
return true;
}
return ACCEPTED_MIME_TYPES.has(file.type);
};
const getFirstFile = (transfer: DataTransfer | null): File | null => {
if (!transfer) {
return null;
}
if (transfer.items && transfer.items.length > 0) {
for (const item of Array.from(transfer.items)) {
if (item.kind === "file") {
return item.getAsFile();
}
}
}
if (transfer.files && transfer.files.length > 0) {
return transfer.files[0] ?? null;
}
return null;
};
export default function FileDropZone({ onFileContent }: FileDropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDragOver: DragEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
if (!isDragging) {
setIsDragging(true);
}
if (error) {
setError(null);
}
};
const handleDragLeave: DragEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
setIsDragging(false);
};
const handleDrop: DragEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
setIsDragging(false);
if (error) {
setError(null);
}
const file = getFirstFile(event.dataTransfer);
if (!file) {
return;
}
if (!isSupportedFile(file)) {
setError("Only .eml or .txt files are supported.");
return;
}
if (file.size > MAX_FILE_BYTES) {
setError("File exceeds the 1 MB limit.");
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const content = typeof result === "string" ? result : "";
onFileContent(content);
};
reader.onerror = () => {
setError("Unable to read the dropped file.");
};
reader.readAsText(file);
};
const borderClass = error
? "border-spam/70"
: isDragging
? "border-info"
: "border-info/40";
const surfaceClass = isDragging ? "bg-surface" : "bg-surface/70";
return (
<section className="flex flex-col gap-3">
<div
className={`rounded-2xl border border-dashed ${borderClass} ${surfaceClass} p-6 text-center transition-colors`}
data-testid="file-drop-zone"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
tabIndex={0}
role="button"
aria-label="Drop an EML or TXT file"
>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full border border-info/30 bg-background/40">
<FontAwesomeIcon icon={faArrowUpFromBracket} className="text-sm text-info" />
</div>
<p className="mt-4 text-sm text-text/80">
Drop an EML or TXT file to auto-populate the header field.
</p>
<p className="mt-2 font-mono text-xs text-text/50">Max size 1MB</p>
</div>
{error ? (
<p role="alert" className="text-xs text-spam">
{error}
</p>
) : null}
</section>
);
}