Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
7.85 kB
"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<string> {
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<HTMLInputElement>(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<string | null>(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<HTMLInputElement>) {
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<HTMLDivElement>) {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) processFile(file);
}
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
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 (
<div className="flex flex-col gap-6">
{/* Kind toggle */}
<div className="flex rounded-lg border border-[var(--color-border)] overflow-hidden self-start">
<button
type="button"
onClick={() => handleKindChange("jsonl")}
className={`px-4 py-2 text-sm font-medium transition-colors ${isJsonl ? "bg-[var(--color-accent)] text-white" : "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"}`}
>
{t("wizardInputKindJsonl")}
</button>
<button
type="button"
onClick={() => handleKindChange("csv")}
className={`px-4 py-2 text-sm font-medium transition-colors ${isCsv ? "bg-[var(--color-accent)] text-white" : "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"}`}
>
{t("wizardInputKindCsv")}
</button>
</div>
{/* Drop zone — disabled while reading to prevent race (B-2) */}
<div
role="button"
tabIndex={isReading ? -1 : 0}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !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"}`}
>
<span className="material-symbols-outlined text-3xl text-[var(--color-text-muted)]">
upload_file
</span>
{isReading ? (
<span className="text-sm text-[var(--color-text-muted)]">{t("wizardInputReading")}</span>
) : hasFile ? (
<div className="flex flex-col items-center gap-1">
<span className="text-sm text-[var(--color-text)] font-medium">{input.fileName}</span>
<span className="text-xs text-[var(--color-text-muted)]">
{isLargeFile ? t("wizardInputLargeFileLabel") : t("wizardInputReady")}
</span>
</div>
) : (
<span className="text-sm text-[var(--color-text-muted)]">{t("wizardDropOrPick")}</span>
)}
<input
ref={fileInputRef}
type="file"
accept={acceptAttr}
className="hidden"
onChange={handleFileInputChange}
/>
</div>
{/* Large file warning */}
{isLargeFile && (
<div className="rounded-lg border border-yellow-500/25 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-400">
{t("wizardInputLargeFileWarning")}
</div>
)}
{/* CSV mapping (inline step 2.5) */}
{isCsv && hasFile && input.rawContent && (
<div className="rounded-xl border border-[var(--color-border)] p-4">
<CsvMappingStep
csvContent={input.rawContent}
mapping={input.csvMapping ?? {}}
onChange={handleCsvMappingChange}
destination={destination}
onJsonlReady={handleJsonlReady}
/>
{csvMappingReady && (
<p className="mt-3 text-xs text-emerald-400">{t("wizardInputCsvJsonlReady")}</p>
)}
</div>
)}
</div>
);
}