Dialog component for importing match results with file picker, preview, and merge strategy selection. Supports three merge strategies: Replace, Merge (preserve existing), and Skip Duplicates.
const ImportMatchesDialog: React.FC<ImportMatchesDialogProps> = ({ open, onOpenChange, onImportComplete,}) => { const [selectedFile, setSelectedFile] = useState<File | null>(null); const [importPreview, setImportPreview] = useState<ImportPreview | null>( null, ); const [mergeStrategy, setMergeStrategy] = useState<ImportMergeStrategy>("merge"); const [isValidating, setIsValidating] = useState(false); const [isImporting, setIsImporting] = useState(false); const [validationErrors, setValidationErrors] = useState<string[]>([]); /** * Handles file selection and triggers validation/preview. */ const handleFileChange = useCallback( async (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; if (!file) return; setSelectedFile(file); setValidationErrors([]); setImportPreview(null); // Validate and get preview setIsValidating(true); try { const preview = await getImportPreview(file); setImportPreview(preview); if (preview.validationErrors.length > 0) { setValidationErrors(preview.validationErrors); } } catch (error) { // Handle AppError or generic Error const errorMsg = error && typeof error === "object" && "message" in error ? (error as { message: string }).message : "Unknown error during validation"; setValidationErrors([errorMsg]); } finally { setIsValidating(false); } }, [], ); /** * Handles the import action. */ const handleImport = useCallback(async () => { if (!selectedFile) { toast.error("No file selected"); return; } setIsImporting(true); try { const result = await importMatchResults(selectedFile, { strategy: mergeStrategy, }); toast.success( `Successfully imported ${result.imported} matches (${result.merged} merged, ${result.skipped} skipped)`, ); onImportComplete({ imported: result.imported, merged: result.merged, skipped: result.skipped, }); // Reset and close dialog setSelectedFile(null); setImportPreview(null); setValidationErrors([]); onOpenChange(false); } catch (error) { // Handle AppError or generic Error const errorMsg = error && typeof error === "object" && "message" in error ? (error as { message: string }).message : "Unknown error during import"; toast.error(errorMsg); } finally { setIsImporting(false); } }, [selectedFile, mergeStrategy, onImportComplete, onOpenChange]); /** * Resets dialog state when closing. */ const handleOpenChange = useCallback( (newOpen: boolean) => { if (!newOpen) { setSelectedFile(null); setImportPreview(null); setValidationErrors([]); } onOpenChange(newOpen); }, [onOpenChange], ); return ( <AlertDialog open={open} onOpenChange={handleOpenChange}> <AlertDialogContent className="max-w-2xl"> <AlertDialogHeader> <AlertDialogTitle>Import Match Results</AlertDialogTitle> <AlertDialogDescription> Import previously exported match results to merge with your current data. Only JSON format is supported. </AlertDialogDescription> </AlertDialogHeader> <div className="space-y-4 py-4"> {/* File Picker Section */} <div className="space-y-2"> <label htmlFor="import-file" className="text-foreground block text-sm font-medium" > Select Import File </label> <div className="flex w-full items-center justify-center"> <label htmlFor="import-file" className="border-muted-foreground/30 bg-muted/20 hover:bg-muted/40 flex h-32 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors" > <div className="flex flex-col items-center justify-center pb-6 pt-5"> <Upload className="text-muted-foreground mb-2 h-8 w-8" /> <p className="text-muted-foreground text-sm"> {selectedFile ? `Selected: ${selectedFile.name}` : "Click to select JSON file or drag and drop"} </p> </div> <input id="import-file" type="file" accept=".json" onChange={handleFileChange} disabled={isValidating || isImporting} className="hidden" /> </label> </div> </div> {/* Validation Errors */} {validationErrors.length > 0 && ( <Alert variant="destructive"> <AlertCircle className="h-4 w-4" /> <AlertDescription> <p className="mb-2 font-medium">Validation Errors:</p> <ul className="list-inside list-disc space-y-1 text-sm"> {validationErrors.map((error) => ( <li key={error}>{error}</li> ))} </ul> </AlertDescription> </Alert> )} {/* Preview Section */} {importPreview && validationErrors.length === 0 && ( <div className="space-y-3"> <div className="bg-muted/40 space-y-3 rounded-lg p-4"> <div className="flex items-start gap-2"> <Info className="mt-0.5 h-4 w-4 shrink-0 text-blue-600" /> <div className="space-y-1 text-sm"> <p className="font-medium">Import Preview</p> <div className="mt-2 grid grid-cols-2 gap-2"> <div> <span className="text-muted-foreground"> Total Matches: </span> <p className="font-semibold"> {importPreview.totalCount} </p> </div> <div> <span className="text-muted-foreground"> New Matches: </span> <p className="font-semibold text-green-600"> {importPreview.newCount} </p> </div> <div> <span className="text-muted-foreground"> Duplicates: </span> <p className="font-semibold"> {importPreview.duplicateCount} </p> </div> <div> <span className="text-muted-foreground"> Conflicts: </span> <p className="font-semibold text-orange-600"> {importPreview.conflictCount} </p> </div> </div> </div> </div> </div> {/* Warnings */} {importPreview.validationWarnings.length > 0 && ( <Alert> <AlertCircle className="h-4 w-4" /> <AlertDescription> <p className="mb-2 font-medium">Warnings:</p> <ul className="list-inside list-disc space-y-1 text-sm"> {importPreview.validationWarnings .slice(0, 5) .map((warning) => ( <li key={warning}>{warning}</li> ))} {importPreview.validationWarnings.length > 5 && ( <li> ... and {importPreview.validationWarnings.length - 5}{" "} more </li> )} </ul> </AlertDescription> </Alert> )} {/* Merge Strategy Selection */} <fieldset className="space-y-2"> <legend className="text-foreground block text-sm font-medium"> Merge Strategy </legend> <div className="space-y-2"> <label className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg border p-3"> <input type="radio" name="strategy" value="replace" checked={mergeStrategy === "replace"} onChange={(e) => setMergeStrategy(e.target.value as ImportMergeStrategy) } className="h-4 w-4" aria-label="Replace all existing matches" /> <div> <p className="font-medium">Replace All</p> <p className="text-muted-foreground text-sm"> Overwrite all existing matches with imported data </p> </div> </label> <label className="hover:bg-muted/50 bg-muted/20 flex cursor-pointer items-center gap-3 rounded-lg border p-3"> <input type="radio" name="strategy" value="merge" checked={mergeStrategy === "merge"} onChange={(e) => setMergeStrategy(e.target.value as ImportMergeStrategy) } className="h-4 w-4" aria-label="Merge with existing matches" /> <div> <p className="font-medium"> Merge (Recommended) <CheckCircle2 className="ml-2 inline h-4 w-4 text-green-600" /> </p> <p className="text-muted-foreground text-sm"> Combine with existing matches, preserving your selections </p> </div> </label> <label className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg border p-3"> <input type="radio" name="strategy" value="skip-duplicates" checked={mergeStrategy === "skip-duplicates"} onChange={(e) => setMergeStrategy(e.target.value as ImportMergeStrategy) } className="h-4 w-4" aria-label="Skip duplicate entries" /> <div> <p className="font-medium">Skip Duplicates</p> <p className="text-muted-foreground text-sm"> Only import new manga not in your current results </p> </div> </label> </div> </fieldset> </div> )} {isValidating && ( <Alert> <AlertCircle className="h-4 w-4" /> <AlertDescription>Validating file...</AlertDescription> </Alert> )} </div> <AlertDialogFooter> <AlertDialogCancel disabled={isImporting}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={handleImport} disabled={ !selectedFile || validationErrors.length > 0 || isValidating || isImporting } > {isImporting ? "Importing..." : "Import"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}; Copy
const ImportMatchesDialog: React.FC<ImportMatchesDialogProps> = ({ open, onOpenChange, onImportComplete,}) => { const [selectedFile, setSelectedFile] = useState<File | null>(null); const [importPreview, setImportPreview] = useState<ImportPreview | null>( null, ); const [mergeStrategy, setMergeStrategy] = useState<ImportMergeStrategy>("merge"); const [isValidating, setIsValidating] = useState(false); const [isImporting, setIsImporting] = useState(false); const [validationErrors, setValidationErrors] = useState<string[]>([]); /** * Handles file selection and triggers validation/preview. */ const handleFileChange = useCallback( async (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; if (!file) return; setSelectedFile(file); setValidationErrors([]); setImportPreview(null); // Validate and get preview setIsValidating(true); try { const preview = await getImportPreview(file); setImportPreview(preview); if (preview.validationErrors.length > 0) { setValidationErrors(preview.validationErrors); } } catch (error) { // Handle AppError or generic Error const errorMsg = error && typeof error === "object" && "message" in error ? (error as { message: string }).message : "Unknown error during validation"; setValidationErrors([errorMsg]); } finally { setIsValidating(false); } }, [], ); /** * Handles the import action. */ const handleImport = useCallback(async () => { if (!selectedFile) { toast.error("No file selected"); return; } setIsImporting(true); try { const result = await importMatchResults(selectedFile, { strategy: mergeStrategy, }); toast.success( `Successfully imported ${result.imported} matches (${result.merged} merged, ${result.skipped} skipped)`, ); onImportComplete({ imported: result.imported, merged: result.merged, skipped: result.skipped, }); // Reset and close dialog setSelectedFile(null); setImportPreview(null); setValidationErrors([]); onOpenChange(false); } catch (error) { // Handle AppError or generic Error const errorMsg = error && typeof error === "object" && "message" in error ? (error as { message: string }).message : "Unknown error during import"; toast.error(errorMsg); } finally { setIsImporting(false); } }, [selectedFile, mergeStrategy, onImportComplete, onOpenChange]); /** * Resets dialog state when closing. */ const handleOpenChange = useCallback( (newOpen: boolean) => { if (!newOpen) { setSelectedFile(null); setImportPreview(null); setValidationErrors([]); } onOpenChange(newOpen); }, [onOpenChange], ); return ( <AlertDialog open={open} onOpenChange={handleOpenChange}> <AlertDialogContent className="max-w-2xl"> <AlertDialogHeader> <AlertDialogTitle>Import Match Results</AlertDialogTitle> <AlertDialogDescription> Import previously exported match results to merge with your current data. Only JSON format is supported. </AlertDialogDescription> </AlertDialogHeader> <div className="space-y-4 py-4"> {/* File Picker Section */} <div className="space-y-2"> <label htmlFor="import-file" className="text-foreground block text-sm font-medium" > Select Import File </label> <div className="flex w-full items-center justify-center"> <label htmlFor="import-file" className="border-muted-foreground/30 bg-muted/20 hover:bg-muted/40 flex h-32 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors" > <div className="flex flex-col items-center justify-center pb-6 pt-5"> <Upload className="text-muted-foreground mb-2 h-8 w-8" /> <p className="text-muted-foreground text-sm"> {selectedFile ? `Selected: ${selectedFile.name}` : "Click to select JSON file or drag and drop"} </p> </div> <input id="import-file" type="file" accept=".json" onChange={handleFileChange} disabled={isValidating || isImporting} className="hidden" /> </label> </div> </div> {/* Validation Errors */} {validationErrors.length > 0 && ( <Alert variant="destructive"> <AlertCircle className="h-4 w-4" /> <AlertDescription> <p className="mb-2 font-medium">Validation Errors:</p> <ul className="list-inside list-disc space-y-1 text-sm"> {validationErrors.map((error) => ( <li key={error}>{error}</li> ))} </ul> </AlertDescription> </Alert> )} {/* Preview Section */} {importPreview && validationErrors.length === 0 && ( <div className="space-y-3"> <div className="bg-muted/40 space-y-3 rounded-lg p-4"> <div className="flex items-start gap-2"> <Info className="mt-0.5 h-4 w-4 shrink-0 text-blue-600" /> <div className="space-y-1 text-sm"> <p className="font-medium">Import Preview</p> <div className="mt-2 grid grid-cols-2 gap-2"> <div> <span className="text-muted-foreground"> Total Matches: </span> <p className="font-semibold"> {importPreview.totalCount} </p> </div> <div> <span className="text-muted-foreground"> New Matches: </span> <p className="font-semibold text-green-600"> {importPreview.newCount} </p> </div> <div> <span className="text-muted-foreground"> Duplicates: </span> <p className="font-semibold"> {importPreview.duplicateCount} </p> </div> <div> <span className="text-muted-foreground"> Conflicts: </span> <p className="font-semibold text-orange-600"> {importPreview.conflictCount} </p> </div> </div> </div> </div> </div> {/* Warnings */} {importPreview.validationWarnings.length > 0 && ( <Alert> <AlertCircle className="h-4 w-4" /> <AlertDescription> <p className="mb-2 font-medium">Warnings:</p> <ul className="list-inside list-disc space-y-1 text-sm"> {importPreview.validationWarnings .slice(0, 5) .map((warning) => ( <li key={warning}>{warning}</li> ))} {importPreview.validationWarnings.length > 5 && ( <li> ... and {importPreview.validationWarnings.length - 5}{" "} more </li> )} </ul> </AlertDescription> </Alert> )} {/* Merge Strategy Selection */} <fieldset className="space-y-2"> <legend className="text-foreground block text-sm font-medium"> Merge Strategy </legend> <div className="space-y-2"> <label className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg border p-3"> <input type="radio" name="strategy" value="replace" checked={mergeStrategy === "replace"} onChange={(e) => setMergeStrategy(e.target.value as ImportMergeStrategy) } className="h-4 w-4" aria-label="Replace all existing matches" /> <div> <p className="font-medium">Replace All</p> <p className="text-muted-foreground text-sm"> Overwrite all existing matches with imported data </p> </div> </label> <label className="hover:bg-muted/50 bg-muted/20 flex cursor-pointer items-center gap-3 rounded-lg border p-3"> <input type="radio" name="strategy" value="merge" checked={mergeStrategy === "merge"} onChange={(e) => setMergeStrategy(e.target.value as ImportMergeStrategy) } className="h-4 w-4" aria-label="Merge with existing matches" /> <div> <p className="font-medium"> Merge (Recommended) <CheckCircle2 className="ml-2 inline h-4 w-4 text-green-600" /> </p> <p className="text-muted-foreground text-sm"> Combine with existing matches, preserving your selections </p> </div> </label> <label className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg border p-3"> <input type="radio" name="strategy" value="skip-duplicates" checked={mergeStrategy === "skip-duplicates"} onChange={(e) => setMergeStrategy(e.target.value as ImportMergeStrategy) } className="h-4 w-4" aria-label="Skip duplicate entries" /> <div> <p className="font-medium">Skip Duplicates</p> <p className="text-muted-foreground text-sm"> Only import new manga not in your current results </p> </div> </label> </div> </fieldset> </div> )} {isValidating && ( <Alert> <AlertCircle className="h-4 w-4" /> <AlertDescription>Validating file...</AlertDescription> </Alert> )} </div> <AlertDialogFooter> <AlertDialogCancel disabled={isImporting}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={handleImport} disabled={ !selectedFile || validationErrors.length > 0 || isValidating || isImporting } > {isImporting ? "Importing..." : "Import"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );};
Component props.
ImportMatchesDialog component.
Dialog component for importing match results with file picker, preview, and merge strategy selection. Supports three merge strategies: Replace, Merge (preserve existing), and Skip Duplicates.
Source