diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:25:40 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:25:40 +0900 |
| commit | deef992b8cc7e57b880c1c38f994d38825240ca1 (patch) | |
| tree | 7309c798df469fb249eb6a30da350712ce38f66f /src/client/components/CreateCardModal.tsx | |
| parent | af9a4912f914bb198fe13bd3421ea33ff3bf9d45 (diff) | |
| download | kioku-deef992b8cc7e57b880c1c38f994d38825240ca1.tar.gz kioku-deef992b8cc7e57b880c1c38f994d38825240ca1.tar.zst kioku-deef992b8cc7e57b880c1c38f994d38825240ca1.zip | |
feat(client): add create card modal with form validation
Add CreateCardModal component to allow users to create new flashcards
with front and back text fields. Integrate the modal into DeckDetailPage
with an "Add Card" button. Include comprehensive unit tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/components/CreateCardModal.tsx')
| -rw-r--r-- | src/client/components/CreateCardModal.tsx | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/src/client/components/CreateCardModal.tsx b/src/client/components/CreateCardModal.tsx new file mode 100644 index 0000000..c28cf0f --- /dev/null +++ b/src/client/components/CreateCardModal.tsx @@ -0,0 +1,194 @@ +import { type FormEvent, useState } from "react"; +import { ApiClientError, apiClient } from "../api"; + +interface CreateCardModalProps { + isOpen: boolean; + deckId: string; + onClose: () => void; + onCardCreated: () => void; +} + +export function CreateCardModal({ + isOpen, + deckId, + onClose, + onCardCreated, +}: CreateCardModalProps) { + const [front, setFront] = useState(""); + const [back, setBack] = useState(""); + const [error, setError] = useState<string | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const resetForm = () => { + setFront(""); + setBack(""); + setError(null); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + const res = await apiClient.rpc.api.decks[":deckId"].cards.$post( + { + param: { deckId }, + json: { + front: front.trim(), + back: back.trim(), + }, + }, + { + headers: apiClient.getAuthHeader(), + }, + ); + + 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, + ); + } + + resetForm(); + onCardCreated(); + onClose(); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.message); + } else { + setError("Failed to create card. Please try again."); + } + } finally { + setIsSubmitting(false); + } + }; + + if (!isOpen) { + return null; + } + + const isFormValid = front.trim() && back.trim(); + + return ( + <div + role="dialog" + aria-modal="true" + aria-labelledby="create-card-title" + style={{ + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + }} + onClick={(e) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + handleClose(); + } + }} + > + <div + style={{ + backgroundColor: "white", + padding: "1.5rem", + borderRadius: "8px", + width: "100%", + maxWidth: "500px", + margin: "1rem", + }} + > + <h2 id="create-card-title" style={{ marginTop: 0 }}> + Create New Card + </h2> + + <form onSubmit={handleSubmit}> + {error && ( + <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> + {error} + </div> + )} + + <div style={{ marginBottom: "1rem" }}> + <label + htmlFor="card-front" + style={{ display: "block", marginBottom: "0.25rem" }} + > + Front + </label> + <textarea + id="card-front" + value={front} + onChange={(e) => setFront(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Question or prompt" + style={{ + width: "100%", + boxSizing: "border-box", + resize: "vertical", + }} + /> + </div> + + <div style={{ marginBottom: "1rem" }}> + <label + htmlFor="card-back" + style={{ display: "block", marginBottom: "0.25rem" }} + > + Back + </label> + <textarea + id="card-back" + value={back} + onChange={(e) => setBack(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Answer or explanation" + style={{ + width: "100%", + boxSizing: "border-box", + resize: "vertical", + }} + /> + </div> + + <div + style={{ + display: "flex", + gap: "0.5rem", + justifyContent: "flex-end", + }} + > + <button type="button" onClick={handleClose} disabled={isSubmitting}> + Cancel + </button> + <button type="submit" disabled={isSubmitting || !isFormValid}> + {isSubmitting ? "Creating..." : "Create"} + </button> + </div> + </form> + </div> + </div> + ); +} |
