• FileDropZone component for uploading and parsing Kenmei CSV export files. Provides drag-and-drop and file selection UI, validates file type and size, parses CSV, and reports progress and errors.

    Parameters

    Returns Element

    React element displaying file upload interface.

    export function FileDropZone({
    onFileLoaded,
    onError,
    }: Readonly<FileDropZoneProps>) {
    const [isDragging, setIsDragging] = useState(false);
    const [fileName, setFileName] = useState<string | null>(null);
    const [fileSize, setFileSize] = useState<number | null>(null);
    const [isLoading, setIsLoading] = useState(false);
    const [loadingProgress, setLoadingProgress] = useState(0);
    const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
    const fileInputRef = useRef<HTMLInputElement>(null);
    const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
    null,
    );

    /**
    * Handles drag-over event to highlight drop zone.
    * @source
    */
    const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
    event.preventDefault();
    setIsDragging(true);
    };

    /**
    * Handles drag-leave event to reset drop zone highlight.
    * @source
    */
    const handleDragLeave = (event: React.DragEvent<HTMLLabelElement>) => {
    event.preventDefault();
    setIsDragging(false);
    };

    /**
    * Handles file drop event and delegates to processFile.
    * @param e - Drag event.
    * @source
    */
    const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
    event.preventDefault();
    setIsDragging(false);

    if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
    const file = event.dataTransfer.files[0];
    processFile(file);
    }
    };

    /**
    * Handles file selection from input element and delegates to processFile.
    * @param e - Change event from file input.
    * @source
    */
    const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files && event.target.files.length > 0) {
    const file = event.target.files[0];
    // Reset state on new file selection
    resetState();
    processFile(file);
    }
    };

    /**
    * Validates and processes a file for CSV parsing.
    * Validates file type (.csv), size (max 10MB), and parses content using worker pool.
    * Calls onFileLoaded or onError callback based on result.
    * Falls back to main thread for small files.
    * @param file - File object to process.
    * @source
    */
    const processFile = async (file: File) => {
    // Clear any existing progress interval
    if (progressIntervalRef.current) {
    clearInterval(progressIntervalRef.current);
    progressIntervalRef.current = null;
    }

    setFileName(file.name);
    setFileSize(file.size);
    setIsLoading(true);
    setLoadingProgress(0);

    // Simulate progress increment for better UX
    progressIntervalRef.current = setInterval(() => {
    setLoadingProgress((prev) => {
    // Cap progress at 80% until file is fully processed
    return prev < 80 ? prev + 10 : 80;
    });
    }, 500);

    try {
    // Validate file type
    if (!file.name.toLowerCase().endsWith(".csv")) {
    throw new Error("Invalid file format. Please upload a CSV file.");
    }

    // Check file size (max 10MB)
    if (file.size > 10 * 1024 * 1024) {
    throw new Error("File is too large. Maximum size is 10MB.");
    }

    // Read file content
    const content = await file.text();

    // Use CSV worker pool for parsing
    const workerPool = getCSVWorkerPool({
    enableWorkers: true,
    fallbackToMainThread: true,
    });

    // Clear simulated progress, now use worker progress
    if (progressIntervalRef.current) {
    clearInterval(progressIntervalRef.current);
    progressIntervalRef.current = null;
    }
    setLoadingProgress(90);

    // Parse using worker pool with progress callback and store taskId for cancellation
    const { taskId, promise } = workerPool.startParsing(
    content,
    { defaultStatus: "plan_to_read" as const },
    (progress: {
    payload: { processedBytes?: number; totalBytes?: number };
    }) => {
    // Update progress bar based on worker progress (byte-based)
    if (progress.payload.processedBytes && progress.payload.totalBytes) {
    const byteProgress =
    (progress.payload.processedBytes / progress.payload.totalBytes) *
    100;
    const clampedProgress = Math.min(100, Math.max(0, byteProgress));
    setLoadingProgress(clampedProgress);
    }
    },
    );

    // Store taskId for potential cancellation
    setCurrentTaskId(taskId);

    const { manga } = await promise;

    if (!manga || manga.length === 0) {
    throw new Error(
    "No manga entries found in the CSV file. Please check the file format.",
    );
    }

    const kenmeiData: KenmeiData = {
    version: "1.0.0",
    exportedAt: new Date().toISOString(),
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    manga: manga.map((item: any) => ({
    title: item.title,
    status: item.status,
    score: item.score,
    chaptersRead: item.chaptersRead ?? item.chapters_read,
    volumesRead: item.volumesRead ?? item.volumes_read,
    createdAt: item.createdAt ?? item.created_at,
    updatedAt: item.updatedAt ?? item.updated_at,
    lastReadAt: item.lastReadAt ?? item.last_read_at,
    notes: item.notes,
    url: item.url,
    })),
    };

    setLoadingProgress(100);
    setIsLoading(false);
    onFileLoaded(kenmeiData);
    } catch (error) {
    console.error("CSV parsing error:", error);

    // Handle cancellation silently without error callback
    if (error instanceof CancelledError) {
    setIsLoading(false);
    return;
    }

    setIsLoading(false);

    // Build error based on error type
    let appError: AppError;
    if (error instanceof Error) {
    switch (error.message) {
    case "Invalid file format. Please upload a CSV file.":
    appError = createError(
    ErrorType.VALIDATION,
    error.message,
    undefined,
    "INVALID_FORMAT",
    ErrorRecoveryAction.NONE,
    "Please export a fresh CSV file from Kenmei and try again. Ensure you're using the latest Kenmei version.",
    );
    break;
    case "File is too large. Maximum size is 10MB.":
    appError = createError(
    ErrorType.VALIDATION,
    error.message,
    undefined,
    "FILE_TOO_LARGE",
    ErrorRecoveryAction.NONE,
    "Try exporting a smaller date range from Kenmei, or split your library into multiple imports.",
    );
    break;
    case "No manga entries found in the CSV file. Please check the file format.":
    appError = createError(
    ErrorType.VALIDATION,
    error.message,
    undefined,
    "NO_ENTRIES",
    ErrorRecoveryAction.NONE,
    "Verify your CSV contains manga data. Check the export settings in Kenmei.",
    );
    break;
    default:
    appError = createError(
    ErrorType.VALIDATION,
    "Failed to parse CSV file. Please ensure it's a valid Kenmei export file.",
    error,
    "PARSE_FAILED",
    ErrorRecoveryAction.NONE,
    "Ensure the file is UTF-8 encoded and hasn't been modified. Re-export from Kenmei if needed.",
    );
    }
    } else {
    appError = createError(
    ErrorType.VALIDATION,
    "Failed to parse CSV file. Please ensure it's a valid Kenmei export file.",
    error,
    "PARSE_FAILED",
    ErrorRecoveryAction.NONE,
    "Ensure the file is UTF-8 encoded and hasn't been modified. Re-export from Kenmei if needed.",
    );
    }

    onError(appError);
    } finally {
    // Ensure interval is cleared in all cases
    if (progressIntervalRef.current) {
    clearInterval(progressIntervalRef.current);
    progressIntervalRef.current = null;
    }
    // Clear task ID as parsing is complete or failed
    setCurrentTaskId(null);
    }
    };

    /**
    * Formats file size in bytes to human-readable string.
    * @param bytes - File size in bytes.
    * @returns Formatted string (e.g., "1.5 MB", "256 KB").
    * @source
    */
    const formatFileSize = (bytes: number): string => {
    if (bytes < 1024) return bytes + " bytes";
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
    return (bytes / (1024 * 1024)).toFixed(1) + " MB";
    };

    /**
    * Resets component state when new file is selected.
    * Clears previous file information and progress state.
    * @source
    */
    const resetState = () => {
    setFileName(null);
    setFileSize(null);
    setIsLoading(false);
    setLoadingProgress(0);
    setCurrentTaskId(null);
    // Clear any existing progress interval
    if (progressIntervalRef.current) {
    clearInterval(progressIntervalRef.current);
    progressIntervalRef.current = null;
    }
    };

    return (
    <label
    htmlFor="file-input"
    aria-label="Upload Kenmei CSV Export - Click to select file or drag and drop"
    onDragOver={handleDragOver}
    onDragLeave={handleDragLeave}
    onDrop={handleDrop}
    className={`relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-all ${
    isDragging
    ? "border-primary/50 bg-primary/5"
    : "border-border/50 hover:border-primary/30 hover:bg-muted/50"
    }`}
    >
    <input
    id="file-input"
    ref={fileInputRef}
    type="file"
    accept=".csv"
    className="hidden"
    onChange={handleFileSelect}
    data-onboarding="file-input"
    />

    {fileName ? (
    // File selected state
    <div className="flex w-full flex-col items-center p-6 text-center">
    {isLoading ? (
    <div className="w-full max-w-md space-y-4">
    <div className="flex items-center justify-center">
    <div className="bg-primary/20 mr-3 h-10 w-10 animate-pulse rounded-full">
    <File className="text-primary/50 h-10 w-10" />
    </div>
    <div className="text-left">
    <h3 className="text-sm font-medium">{fileName}</h3>
    <p className="text-muted-foreground text-xs">
    {fileSize && formatFileSize(fileSize)}
    </p>
    </div>
    </div>
    <div className="space-y-2">
    <Progress value={loadingProgress} className="h-1.5 w-full" />
    <p className="text-muted-foreground text-xs">
    Processing file...
    </p>
    </div>
    {currentTaskId && (
    <Button
    size="sm"
    variant="outline"
    type="button"
    onClick={(clickEvent) => {
    clickEvent.stopPropagation();
    const workerPool = getCSVWorkerPool({
    enableWorkers: true,
    fallbackToMainThread: true,
    });
    workerPool.cancelTask(currentTaskId);
    // Clear loading state
    if (progressIntervalRef.current) {
    clearInterval(progressIntervalRef.current);
    progressIntervalRef.current = null;
    }
    setIsLoading(false);
    setLoadingProgress(0);
    setCurrentTaskId(null);
    }}
    >
    Cancel
    </Button>
    )}
    </div>
    ) : (
    <div className="flex flex-col items-center">
    <div className="bg-primary/10 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
    <File className="text-primary h-8 w-8" />
    </div>
    <h3 className="mb-1 text-lg font-medium">{fileName}</h3>
    <p className="text-muted-foreground mb-4 text-sm">
    {fileSize && formatFileSize(fileSize)}
    </p>
    <Button
    size="sm"
    type="button"
    onClick={(clickEvent) => {
    clickEvent.stopPropagation();
    if (fileInputRef.current) {
    fileInputRef.current.value = "";
    fileInputRef.current.click();
    }
    }}
    >
    Choose Different File
    </Button>
    </div>
    )}
    </div>
    ) : (
    // Empty state - show upload instructions
    <div className="flex flex-col items-center justify-center space-y-3 p-6 text-center">
    <div className="bg-primary/10 text-primary mb-2 rounded-full p-3">
    <UploadCloud className="h-8 w-8" />
    </div>
    <h3 className="text-lg font-medium">Upload Kenmei CSV Export</h3>
    <p className="text-muted-foreground max-w-md text-sm">
    Drag and drop your Kenmei CSV export file here
    </p>

    <div className="mt-2">
    <Button
    type="button"
    variant="outline"
    className="mt-2"
    onClick={(clickEvent) => {
    clickEvent.preventDefault();
    fileInputRef.current?.click();
    }}
    >
    <FileText className="mr-2 h-4 w-4" />
    Browse Files
    </Button>
    </div>
    </div>
    )}
    </label>
    );
    }