"use client"; import { useRef, useState } from "react"; import { useTranslations } from "next-intl"; import CsvMappingStep from "./CsvMappingStep"; import type { WizardInput, WizardCsvMapping, WizardDestination } from "@/lib/batches/types"; const MAX_FULL_BYTES = 5_000_000; // 5 MB — full read threshold (D7) const SAMPLE_HEAD_BYTES = 5_000_000; const SAMPLE_TAIL_BYTES = 100_000; function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } async function readFileContent(file: File): Promise { if (file.size <= MAX_FULL_BYTES) { return await file.text(); } // Sampling for large files (D7) const headText = await file.slice(0, SAMPLE_HEAD_BYTES).text(); const tailText = await file.slice(file.size - SAMPLE_TAIL_BYTES).text(); return headText + "\n[...sample only...]\n" + tailText; } interface InputStepProps { input: WizardInput; onChange: (input: WizardInput) => void; destination: WizardDestination | null; } export default function InputStep({ input, onChange, destination }: InputStepProps) { const t = useTranslations("common"); const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isReading, setIsReading] = useState(false); // B-2b — synchronous stale-proof guard for concurrent drops/picks in the same // event-loop tick (React state lags behind dispatches within a tick). const isReadingRef = useRef(false); const [csvJsonl, setCsvJsonl] = useState(null); const isJsonl = input.kind === "jsonl"; const isCsv = input.kind === "csv"; function handleKindChange(kind: "jsonl" | "csv") { onChange({ kind, fileName: null, rawContent: null, csvMapping: kind === "csv" ? {} : undefined }); setCsvJsonl(null); } async function processFile(file: File) { // B-2/B-2b race guard — ref is checked & flipped synchronously so concurrent // calls in the same tick (e.g. drop + file-input-change firing back-to-back) // cannot both pass. State mirror is for UI only. if (isReadingRef.current) return; isReadingRef.current = true; const expectedExt = isJsonl ? ".jsonl" : ".csv"; if (!file.name.toLowerCase().endsWith(expectedExt)) { // Soft warning — don't block, let validation catch it } setIsReading(true); try { const content = await readFileContent(file); onChange({ kind: input.kind, fileName: file.name, rawContent: content, csvMapping: input.kind === "csv" ? (input.csvMapping ?? {}) : undefined, }); } catch (err) { console.error("[InputStep] file read error:", err); } finally { isReadingRef.current = false; setIsReading(false); } } function handleFileInputChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (file) processFile(file); // Reset input so same file can be re-picked e.target.value = ""; } function handleDrop(e: React.DragEvent) { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files?.[0]; if (file) processFile(file); } function handleDragOver(e: React.DragEvent) { e.preventDefault(); setIsDragging(true); } function handleDragLeave() { setIsDragging(false); } function handleCsvMappingChange(mapping: WizardCsvMapping) { onChange({ ...input, csvMapping: mapping }); setCsvJsonl(null); } function handleJsonlReady(jsonl: string, _rowsParsed: number, _rowsSkipped: number) { setCsvJsonl(jsonl); // Replace rawContent with the converted JSONL so validation step gets it onChange({ ...input, rawContent: jsonl, csvMapping: input.csvMapping }); } const isLargeFile = input.rawContent != null && input.rawContent.includes("[...sample only...]"); const hasFile = input.fileName != null && input.rawContent != null; const csvMappingReady = isCsv && csvJsonl != null; // For CSV: needs mapping complete + jsonl generated to be "ready" // rawContent will be updated by handleJsonlReady once mapping applied const acceptAttr = isJsonl ? ".jsonl" : ".csv"; return (
{/* Kind toggle */}
{/* Drop zone — disabled while reading to prevent race (B-2) */}
!isReading && fileInputRef.current?.click()} onKeyDown={(e) => { if (isReading) return; if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }} aria-disabled={isReading} className={`rounded-xl border-2 border-dashed p-8 flex flex-col items-center gap-3 transition-colors ${isReading ? "cursor-wait opacity-60 pointer-events-none" : "cursor-pointer"} ${isDragging ? "border-[var(--color-accent)] bg-[var(--color-accent)]/5" : "border-[var(--color-border)] hover:border-[var(--color-accent)]/50"}`} > upload_file {isReading ? ( {t("wizardInputReading")} ) : hasFile ? (
{input.fileName} {isLargeFile ? t("wizardInputLargeFileLabel") : t("wizardInputReady")}
) : ( {t("wizardDropOrPick")} )}
{/* Large file warning */} {isLargeFile && (
{t("wizardInputLargeFileWarning")}
)} {/* CSV mapping (inline step 2.5) */} {isCsv && hasFile && input.rawContent && (
{csvMappingReady && (

{t("wizardInputCsvJsonlReady")}

)}
)}
); }