diff options
Diffstat (limited to 'src/client/components')
| -rw-r--r-- | src/client/components/ImportNotesModal.tsx | 535 |
1 files changed, 535 insertions, 0 deletions
diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx new file mode 100644 index 0000000..b49b276 --- /dev/null +++ b/src/client/components/ImportNotesModal.tsx @@ -0,0 +1,535 @@ +import { + faCheck, + faExclamationTriangle, + faFileImport, + faSpinner, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { type ChangeEvent, useCallback, useEffect, useState } from "react"; +import { ApiClientError, apiClient } from "../api"; +import { parseCSV } from "../utils/csvParser"; + +interface NoteField { + id: string; + name: string; + order: number; +} + +interface NoteType { + id: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + fields: NoteField[]; +} + +interface ImportNotesModalProps { + isOpen: boolean; + deckId: string; + onClose: () => void; + onImportComplete: () => void; +} + +type ImportPhase = + | "upload" + | "validating" + | "preview" + | "importing" + | "complete"; + +interface ValidationError { + rowNumber: number; + message: string; +} + +interface ValidatedRow { + rowNumber: number; + noteTypeId: string; + noteTypeName: string; + fields: Record<string, string>; + preview: Record<string, string>; +} + +interface ImportResult { + created: number; + failed: { index: number; error: string }[]; +} + +export function ImportNotesModal({ + isOpen, + deckId, + onClose, + onImportComplete, +}: ImportNotesModalProps) { + const [phase, setPhase] = useState<ImportPhase>("upload"); + const [error, setError] = useState<string | null>(null); + const [noteTypes, setNoteTypes] = useState<NoteType[]>([]); + const [validatedRows, setValidatedRows] = useState<ValidatedRow[]>([]); + const [validationErrors, setValidationErrors] = useState<ValidationError[]>( + [], + ); + const [importResult, setImportResult] = useState<ImportResult | null>(null); + + const fetchNoteTypes = useCallback(async () => { + try { + const authHeader = apiClient.getAuthHeader(); + if (!authHeader) { + throw new ApiClientError("Not authenticated", 401); + } + + const res = await fetch("/api/note-types", { + headers: authHeader, + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})); + throw new ApiClientError( + (errorBody as { error?: string }).error || + `Request failed with status ${res.status}`, + res.status, + ); + } + + const data = await res.json(); + + // Fetch details for each note type to get fields + const noteTypesWithFields: NoteType[] = []; + for (const nt of data.noteTypes) { + const detailRes = await fetch(`/api/note-types/${nt.id}`, { + headers: authHeader, + }); + if (detailRes.ok) { + const detailData = await detailRes.json(); + noteTypesWithFields.push(detailData.noteType); + } + } + + setNoteTypes(noteTypesWithFields); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.message); + } else { + setError("Failed to load note types. Please try again."); + } + } + }, []); + + useEffect(() => { + if (isOpen && noteTypes.length === 0) { + fetchNoteTypes(); + } + }, [isOpen, noteTypes.length, fetchNoteTypes]); + + const resetState = () => { + setPhase("upload"); + setError(null); + setValidatedRows([]); + setValidationErrors([]); + setImportResult(null); + }; + + const handleClose = () => { + resetState(); + onClose(); + }; + + const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (!file) return; + + setPhase("validating"); + setError(null); + setValidationErrors([]); + setValidatedRows([]); + + try { + const content = await file.text(); + const parseResult = parseCSV(content); + + if (!parseResult.success) { + setError(parseResult.error.message); + setPhase("upload"); + return; + } + + const { headers, rows } = parseResult.data; + + // Validate headers: must have deck, note_type, and at least one field + if (headers.length < 3) { + setError( + "CSV must have at least 3 columns: deck, note_type, and field(s)", + ); + setPhase("upload"); + return; + } + + if (headers[0] !== "deck" || headers[1] !== "note_type") { + setError("First two columns must be 'deck' and 'note_type'"); + setPhase("upload"); + return; + } + + const fieldNames = headers.slice(2); + const validated: ValidatedRow[] = []; + const errors: ValidationError[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row) continue; + + const rowNumber = i + 2; // +2 because 1-indexed and header is row 1 + + const noteTypeName = row.note_type ?? ""; + const noteType = noteTypes.find( + (nt) => nt.name.toLowerCase() === noteTypeName.toLowerCase(), + ); + + if (!noteType) { + errors.push({ + rowNumber, + message: `Note type "${noteTypeName}" not found`, + }); + continue; + } + + // Map field names to field type IDs + const fields: Record<string, string> = {}; + const preview: Record<string, string> = {}; + let fieldError = false; + + for (const fieldName of fieldNames) { + const fieldType = noteType.fields.find( + (f) => f.name.toLowerCase() === fieldName.toLowerCase(), + ); + + if (!fieldType) { + errors.push({ + rowNumber, + message: `Field "${fieldName}" not found in note type "${noteTypeName}"`, + }); + fieldError = true; + break; + } + + const value = row[fieldName] ?? ""; + fields[fieldType.id] = value; + preview[fieldName] = value; + } + + if (fieldError) continue; + + validated.push({ + rowNumber, + noteTypeId: noteType.id, + noteTypeName: noteType.name, + fields, + preview, + }); + } + + setValidatedRows(validated); + setValidationErrors(errors); + setPhase("preview"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to parse CSV"); + setPhase("upload"); + } + + // Reset file input + e.target.value = ""; + }; + + const handleImport = async () => { + if (validatedRows.length === 0) return; + + setPhase("importing"); + setError(null); + + try { + const authHeader = apiClient.getAuthHeader(); + if (!authHeader) { + throw new ApiClientError("Not authenticated", 401); + } + + const res = await fetch(`/api/decks/${deckId}/notes/import`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeader, + }, + body: JSON.stringify({ + notes: validatedRows.map((row) => ({ + noteTypeId: row.noteTypeId, + fields: row.fields, + })), + }), + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})); + throw new ApiClientError( + (errorBody as { error?: string }).error || + `Request failed with status ${res.status}`, + res.status, + ); + } + + const result = await res.json(); + setImportResult(result); + setPhase("complete"); + onImportComplete(); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.message); + } else { + setError("Failed to import notes. Please try again."); + } + setPhase("preview"); + } + }; + + if (!isOpen) return null; + + return ( + <div + role="dialog" + aria-modal="true" + aria-labelledby="import-notes-title" + className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in" + onClick={(e) => { + if (e.target === e.currentTarget && phase !== "importing") { + handleClose(); + } + }} + onKeyDown={(e) => { + if (e.key === "Escape" && phase !== "importing") { + handleClose(); + } + }} + > + <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in max-h-[90vh] flex flex-col"> + <div className="p-6 flex-shrink-0"> + <h2 + id="import-notes-title" + className="font-display text-xl font-medium text-ink mb-4" + > + Import Notes from CSV + </h2> + + {error && ( + <div + role="alert" + className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4" + > + {error} + </div> + )} + + {/* Phase: Upload */} + {phase === "upload" && ( + <div className="space-y-4"> + <div className="border-2 border-dashed border-border rounded-lg p-8 text-center"> + <FontAwesomeIcon + icon={faFileImport} + className="w-10 h-10 text-muted mb-4" + /> + <p className="text-slate mb-4">Select a CSV file to import</p> + <label className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg cursor-pointer transition-all duration-200"> + <span>Choose File</span> + <input + type="file" + accept=".csv,text/csv" + onChange={handleFileChange} + className="hidden" + /> + </label> + </div> + <div className="bg-ivory rounded-lg px-4 py-3 text-sm text-muted"> + <p className="font-medium text-slate mb-1">Expected format:</p> + <code className="text-xs"> + deck,note_type,Front,Back + <br /> + MyDeck,Basic,hello,world + </code> + </div> + </div> + )} + + {/* Phase: Validating */} + {phase === "validating" && ( + <div className="flex items-center justify-center py-8"> + <FontAwesomeIcon + icon={faSpinner} + className="w-8 h-8 text-primary animate-spin" + /> + <span className="ml-3 text-muted">Validating CSV...</span> + </div> + )} + </div> + + {/* Phase: Preview - scrollable content */} + {phase === "preview" && ( + <div className="flex-1 overflow-y-auto px-6"> + {validationErrors.length > 0 && ( + <div className="bg-warning/5 border border-warning/20 rounded-lg p-4 mb-4"> + <div className="flex items-center gap-2 text-warning font-medium mb-2"> + <FontAwesomeIcon icon={faExclamationTriangle} /> + {validationErrors.length} error(s) found + </div> + <ul className="text-sm text-slate space-y-1 max-h-32 overflow-y-auto"> + {validationErrors.map((err) => ( + <li key={err.rowNumber}> + Row {err.rowNumber}: {err.message} + </li> + ))} + </ul> + </div> + )} + + {validatedRows.length > 0 && ( + <div className="space-y-4"> + <p className="text-slate"> + <span className="font-medium">{validatedRows.length}</span>{" "} + note(s) ready to import + </p> + + {/* Preview table */} + <div className="border border-border rounded-lg overflow-hidden"> + <div className="max-h-48 overflow-y-auto"> + <table className="w-full text-sm"> + <thead className="bg-ivory sticky top-0"> + <tr> + <th className="px-3 py-2 text-left font-medium text-slate"> + # + </th> + <th className="px-3 py-2 text-left font-medium text-slate"> + Type + </th> + <th className="px-3 py-2 text-left font-medium text-slate"> + Preview + </th> + </tr> + </thead> + <tbody className="divide-y divide-border"> + {validatedRows.slice(0, 10).map((row) => ( + <tr key={row.rowNumber}> + <td className="px-3 py-2 text-muted"> + {row.rowNumber} + </td> + <td className="px-3 py-2 text-slate"> + {row.noteTypeName} + </td> + <td className="px-3 py-2 text-slate truncate max-w-[200px]"> + {Object.values(row.preview).join(" | ")} + </td> + </tr> + ))} + </tbody> + </table> + </div> + {validatedRows.length > 10 && ( + <div className="bg-ivory px-3 py-2 text-xs text-muted text-center"> + ...and {validatedRows.length - 10} more + </div> + )} + </div> + </div> + )} + </div> + )} + + {/* Phase: Importing */} + {phase === "importing" && ( + <div className="px-6 flex items-center justify-center py-8"> + <FontAwesomeIcon + icon={faSpinner} + className="w-8 h-8 text-primary animate-spin" + /> + <span className="ml-3 text-muted">Importing notes...</span> + </div> + )} + + {/* Phase: Complete */} + {phase === "complete" && importResult && ( + <div className="px-6"> + <div className="text-center py-4"> + <div className="w-14 h-14 mx-auto mb-4 bg-success/10 rounded-full flex items-center justify-center"> + <FontAwesomeIcon + icon={faCheck} + className="w-7 h-7 text-success" + /> + </div> + <p className="text-lg font-medium text-slate mb-2"> + Import complete! + </p> + <p className="text-muted"> + {importResult.created} note(s) imported successfully + </p> + {importResult.failed.length > 0 && ( + <div className="mt-4 bg-error/5 border border-error/20 rounded-lg p-4 text-left"> + <p className="text-error font-medium mb-2"> + {importResult.failed.length} failed: + </p> + <ul className="text-sm text-slate space-y-1 max-h-24 overflow-y-auto"> + {importResult.failed.map((f) => ( + <li key={f.index}> + Row {validatedRows[f.index]?.rowNumber ?? f.index + 1}:{" "} + {f.error} + </li> + ))} + </ul> + </div> + )} + </div> + </div> + )} + + {/* Footer buttons */} + <div className="p-6 flex-shrink-0 border-t border-border mt-auto"> + <div className="flex gap-3 justify-end"> + {phase === "upload" && ( + <button + type="button" + onClick={handleClose} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors" + > + Cancel + </button> + )} + + {phase === "preview" && ( + <> + <button + type="button" + onClick={resetState} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors" + > + Back + </button> + <button + type="button" + onClick={handleImport} + disabled={validatedRows.length === 0} + className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" + > + Import {validatedRows.length} Note(s) + </button> + </> + )} + + {phase === "complete" && ( + <button + type="button" + onClick={handleClose} + className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200" + > + Done + </button> + )} + </div> + </div> + </div> + </div> + ); +} |
