diff options
Diffstat (limited to 'src/client/pages')
| -rw-r--r-- | src/client/pages/DeckDetailPage.test.tsx | 68 | ||||
| -rw-r--r-- | src/client/pages/DeckDetailPage.tsx | 452 | ||||
| -rw-r--r-- | src/client/pages/HomePage.test.tsx | 65 | ||||
| -rw-r--r-- | src/client/pages/HomePage.tsx | 252 | ||||
| -rw-r--r-- | src/client/pages/LoginPage.test.tsx | 15 | ||||
| -rw-r--r-- | src/client/pages/LoginPage.tsx | 139 | ||||
| -rw-r--r-- | src/client/pages/NotFoundPage.tsx | 50 | ||||
| -rw-r--r-- | src/client/pages/StudyPage.test.tsx | 13 | ||||
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 454 |
9 files changed, 961 insertions, 547 deletions
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index 0589073..e4ecade 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -151,7 +151,8 @@ describe("DeckDetailPage", () => { renderWithProviders(); - expect(screen.getByText("Loading...")).toBeDefined(); + // Loading state shows spinner (svg with animate-spin class) + expect(document.querySelector(".animate-spin")).toBeDefined(); }); it("displays empty state when no cards exist", async () => { @@ -168,9 +169,9 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("This deck has no cards yet.")).toBeDefined(); + expect(screen.getByText("No cards yet")).toBeDefined(); }); - expect(screen.getByText("Add cards to start studying!")).toBeDefined(); + expect(screen.getByText("Add cards to start studying")).toBeDefined(); }); it("displays list of cards", async () => { @@ -208,7 +209,7 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("heading", { name: "Cards (2)" })).toBeDefined(); + expect(screen.getByText("(2)")).toBeDefined(); }); }); @@ -226,9 +227,9 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("State: New")).toBeDefined(); + expect(screen.getByText("New")).toBeDefined(); }); - expect(screen.getByText("State: Review")).toBeDefined(); + expect(screen.getByText("Review")).toBeDefined(); }); it("displays card stats (reps and lapses)", async () => { @@ -245,11 +246,10 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Reviews: 0")).toBeDefined(); + expect(screen.getByText("0 reviews")).toBeDefined(); }); - expect(screen.getByText("Reviews: 5")).toBeDefined(); - expect(screen.getByText("Lapses: 0")).toBeDefined(); - expect(screen.getByText("Lapses: 1")).toBeDefined(); + expect(screen.getByText("5 reviews")).toBeDefined(); + expect(screen.getByText("1 lapses")).toBeDefined(); }); it("displays error on API failure for deck", async () => { @@ -391,7 +391,9 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); expect(deleteButtons.length).toBe(2); }); @@ -414,7 +416,9 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); @@ -445,7 +449,9 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); @@ -488,18 +494,20 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } - // Find the Delete button in the modal (not the card list) - const modalDeleteButtons = screen.getAllByRole("button", { - name: "Delete", - }); - const confirmDeleteButton = modalDeleteButtons.find((btn) => - btn.closest('[role="dialog"]'), + // Find the Delete button in the modal (using the button's text content) + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + // Find the button with "Delete" text (not "Cancel") + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), ); if (confirmDeleteButton) { await user.click(confirmDeleteButton); @@ -518,9 +526,7 @@ describe("DeckDetailPage", () => { // Verify card count updated await waitFor(() => { - expect( - screen.getByRole("heading", { name: "Cards (1)" }), - ).toBeDefined(); + expect(screen.getByText("(1)")).toBeDefined(); }); }); @@ -550,18 +556,20 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } - // Find the Delete button in the modal - const modalDeleteButtons = screen.getAllByRole("button", { - name: "Delete", - }); - const confirmDeleteButton = modalDeleteButtons.find((btn) => - btn.closest('[role="dialog"]'), + // Find the Delete button in the modal (using the button's text content) + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + // Find the button with "Delete" text (not "Cancel") + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), ); if (confirmDeleteButton) { await user.click(confirmDeleteButton); diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index 3d7ffb5..cb1e3fb 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -31,6 +31,13 @@ const CardStateLabels: Record<number, string> = { 3: "Relearning", }; +const CardStateColors: Record<number, string> = { + 0: "bg-info/10 text-info", + 1: "bg-warning/10 text-warning", + 2: "bg-success/10 text-success", + 3: "bg-error/10 text-error", +}; + export function DeckDetailPage() { const { deckId } = useParams<{ deckId: string }>(); const [deck, setDeck] = useState<Deck | null>(null); @@ -114,195 +121,318 @@ export function DeckDetailPage() { if (!deckId) { return ( - <div> - <p>Invalid deck ID</p> - <Link href="/">Back to decks</Link> + <div className="min-h-screen bg-cream flex items-center justify-center"> + <div className="text-center"> + <p className="text-muted mb-4">Invalid deck ID</p> + <Link + href="/" + className="text-primary hover:text-primary-dark font-medium" + > + Back to decks + </Link> + </div> </div> ); } return ( - <div> - <header style={{ marginBottom: "1rem" }}> - <Link href="/" style={{ textDecoration: "none" }}> - ← Back to Decks - </Link> - </header> - - {isLoading && <p>Loading...</p>} - - {error && ( - <div role="alert" style={{ color: "red" }}> - {error} - <button - type="button" - onClick={fetchData} - style={{ marginLeft: "0.5rem" }} + <div className="min-h-screen bg-cream"> + {/* Header */} + <header className="bg-white border-b border-border/50"> + <div className="max-w-4xl mx-auto px-4 py-4"> + <Link + href="/" + className="inline-flex items-center gap-2 text-muted hover:text-slate transition-colors text-sm" > - Retry - </button> + <svg + className="w-4 h-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M15 19l-7-7 7-7" + /> + </svg> + Back to Decks + </Link> </div> - )} + </header> - {!isLoading && !error && deck && ( - <main> - <div style={{ marginBottom: "1.5rem" }}> - <h1 style={{ margin: 0 }}>{deck.name}</h1> - {deck.description && ( - <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}> - {deck.description} - </p> - )} + {/* Main Content */} + <main className="max-w-4xl mx-auto px-4 py-8"> + {/* Loading State */} + {isLoading && ( + <div className="flex items-center justify-center py-12"> + <svg + className="animate-spin h-8 w-8 text-primary" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle + className="opacity-25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="4" + fill="none" + /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" + /> + </svg> </div> + )} + {/* Error State */} + {error && ( <div - style={{ - display: "flex", - gap: "0.5rem", - marginBottom: "1rem", - }} + role="alert" + className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between" > - <Link href={`/decks/${deckId}/study`}> + <span className="text-error">{error}</span> + <button + type="button" + onClick={fetchData} + className="text-error hover:text-error/80 font-medium text-sm" + > + Retry + </button> + </div> + )} + + {/* Deck Content */} + {!isLoading && !error && deck && ( + <div className="animate-fade-in"> + {/* Deck Header */} + <div className="mb-8"> + <h1 className="font-display text-3xl font-semibold text-ink mb-2"> + {deck.name} + </h1> + {deck.description && ( + <p className="text-muted">{deck.description}</p> + )} + </div> + + {/* Study Button */} + <div className="mb-8"> + <Link + href={`/decks/${deckId}/study`} + className="inline-flex items-center gap-2 bg-success hover:bg-success/90 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md" + > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" + /> + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" + /> + </svg> + Study Now + </Link> + </div> + + {/* Cards Section */} + <div className="flex items-center justify-between mb-6"> + <h2 className="font-display text-xl font-medium text-slate"> + Cards{" "} + <span className="text-muted font-normal">({cards.length})</span> + </h2> <button type="button" - style={{ - backgroundColor: "#28a745", - color: "white", - border: "none", - padding: "0.5rem 1rem", - borderRadius: "4px", - cursor: "pointer", - }} + onClick={() => setIsCreateModalOpen(true)} + className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]" > - Study Now + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 4v16m8-8H4" + /> + </svg> + Add Card </button> - </Link> - </div> - - <div - style={{ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "1rem", - }} - > - <h2 style={{ margin: 0 }}>Cards ({cards.length})</h2> - <button type="button" onClick={() => setIsCreateModalOpen(true)}> - Add Card - </button> - </div> - - {cards.length === 0 && ( - <div> - <p>This deck has no cards yet.</p> - <p>Add cards to start studying!</p> </div> - )} - - {cards.length > 0 && ( - <ul style={{ listStyle: "none", padding: 0 }}> - {cards.map((card) => ( - <li - key={card.id} - style={{ - border: "1px solid #ccc", - padding: "1rem", - marginBottom: "0.5rem", - borderRadius: "4px", - }} + + {/* Empty State */} + {cards.length === 0 && ( + <div className="text-center py-12 bg-white rounded-xl border border-border/50"> + <div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center"> + <svg + className="w-7 h-7 text-muted" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + /> + </svg> + </div> + <h3 className="font-display text-lg font-medium text-slate mb-2"> + No cards yet + </h3> + <p className="text-muted text-sm mb-4"> + Add cards to start studying + </p> + <button + type="button" + onClick={() => setIsCreateModalOpen(true)} + className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200" > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 4v16m8-8H4" + /> + </svg> + Add Your First Card + </button> + </div> + )} + + {/* Card List */} + {cards.length > 0 && ( + <div className="space-y-3"> + {cards.map((card, index) => ( <div - style={{ - display: "flex", - justifyContent: "space-between", - alignItems: "flex-start", - }} + key={card.id} + className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200" + style={{ animationDelay: `${index * 30}ms` }} > - <div style={{ flex: 1, minWidth: 0 }}> - <div - style={{ - display: "flex", - gap: "1rem", - marginBottom: "0.5rem", - }} - > - <div style={{ flex: 1, minWidth: 0 }}> - <strong>Front:</strong> - <p - style={{ - margin: "0.25rem 0 0 0", - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} - > - {card.front} - </p> + <div className="flex items-start justify-between gap-4"> + <div className="flex-1 min-w-0"> + {/* Front/Back Preview */} + <div className="grid grid-cols-2 gap-4 mb-3"> + <div> + <span className="text-xs font-medium text-muted uppercase tracking-wide"> + Front + </span> + <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words"> + {card.front} + </p> + </div> + <div> + <span className="text-xs font-medium text-muted uppercase tracking-wide"> + Back + </span> + <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words"> + {card.back} + </p> + </div> </div> - <div style={{ flex: 1, minWidth: 0 }}> - <strong>Back:</strong> - <p - style={{ - margin: "0.25rem 0 0 0", - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} + + {/* Card Stats */} + <div className="flex items-center gap-3 text-xs"> + <span + className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`} > - {card.back} - </p> + {CardStateLabels[card.state] || "Unknown"} + </span> + <span className="text-muted"> + {card.reps} reviews + </span> + {card.lapses > 0 && ( + <span className="text-muted"> + {card.lapses} lapses + </span> + )} </div> </div> - <div - style={{ - display: "flex", - gap: "1rem", - fontSize: "0.875rem", - color: "#666", - }} - > - <span> - State: {CardStateLabels[card.state] || "Unknown"} - </span> - <span>Reviews: {card.reps}</span> - <span>Lapses: {card.lapses}</span> + + {/* Actions */} + <div className="flex items-center gap-1 shrink-0"> + <button + type="button" + onClick={() => setEditingCard(card)} + className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors" + title="Edit card" + > + <svg + className="w-4 h-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" + /> + </svg> + </button> + <button + type="button" + onClick={() => setDeletingCard(card)} + className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" + title="Delete card" + > + <svg + className="w-4 h-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" + /> + </svg> + </button> </div> </div> - <div - style={{ - display: "flex", - gap: "0.5rem", - marginLeft: "1rem", - }} - > - <button - type="button" - onClick={() => setEditingCard(card)} - > - Edit - </button> - <button - type="button" - onClick={() => setDeletingCard(card)} - style={{ - backgroundColor: "#dc3545", - color: "white", - border: "none", - padding: "0.5rem 1rem", - borderRadius: "4px", - cursor: "pointer", - }} - > - Delete - </button> - </div> </div> - </li> - ))} - </ul> - )} - </main> - )} + ))} + </div> + )} + </div> + )} + </main> + {/* Modals */} {deckId && ( <CreateCardModal isOpen={isCreateModalOpen} diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx index 18c2e76..5b8489a 100644 --- a/src/client/pages/HomePage.test.tsx +++ b/src/client/pages/HomePage.test.tsx @@ -137,7 +137,8 @@ describe("HomePage", () => { renderWithProviders(); - expect(screen.getByText("Loading decks...")).toBeDefined(); + // Loading state shows spinner (svg with animate-spin class) + expect(document.querySelector(".animate-spin")).toBeDefined(); }); it("displays empty state when no decks exist", async () => { @@ -151,10 +152,10 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("You don't have any decks yet.")).toBeDefined(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); expect( - screen.getByText("Create your first deck to start learning!"), + screen.getByText("Create your first deck to start learning"), ).toBeDefined(); }); @@ -255,7 +256,7 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); await user.click(screen.getByRole("button", { name: "Logout" })); @@ -290,11 +291,11 @@ describe("HomePage", () => { ).toBeDefined(); }); - // The deck item should only contain the heading, no description paragraph - const deckItem = screen + // The deck card should only contain the heading, no description paragraph + const deckCard = screen .getByRole("heading", { name: "No Description Deck" }) - .closest("li"); - expect(deckItem?.querySelectorAll("p").length).toBe(0); + .closest("div[class*='bg-white']"); + expect(deckCard?.querySelectorAll("p").length).toBe(0); }); it("passes auth header when fetching decks", async () => { @@ -315,7 +316,7 @@ describe("HomePage", () => { }); describe("Create Deck", () => { - it("shows Create Deck button", async () => { + it("shows New Deck button", async () => { vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue( mockResponse({ ok: true, @@ -326,13 +327,13 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); - expect(screen.getByRole("button", { name: "Create Deck" })).toBeDefined(); + expect(screen.getByRole("button", { name: /New Deck/i })).toBeDefined(); }); - it("opens modal when Create Deck button is clicked", async () => { + it("opens modal when New Deck button is clicked", async () => { const user = userEvent.setup(); vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue( mockResponse({ @@ -344,10 +345,10 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); - await user.click(screen.getByRole("button", { name: "Create Deck" })); + await user.click(screen.getByRole("button", { name: /New Deck/i })); expect(screen.getByRole("dialog")).toBeDefined(); expect( @@ -367,10 +368,10 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); - await user.click(screen.getByRole("button", { name: "Create Deck" })); + await user.click(screen.getByRole("button", { name: /New Deck/i })); expect(screen.getByRole("dialog")).toBeDefined(); await user.click(screen.getByRole("button", { name: "Cancel" })); @@ -413,11 +414,11 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); // Open modal - await user.click(screen.getByRole("button", { name: "Create Deck" })); + await user.click(screen.getByRole("button", { name: /New Deck/i })); // Fill in form await user.type(screen.getByLabelText("Name"), "New Deck"); @@ -427,7 +428,7 @@ describe("HomePage", () => { ); // Submit - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); // Modal should close await waitFor(() => { @@ -462,7 +463,7 @@ describe("HomePage", () => { ).toBeDefined(); }); - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); expect(editButtons.length).toBe(2); }); @@ -483,7 +484,7 @@ describe("HomePage", () => { ).toBeDefined(); }); - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); await user.click(editButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -511,7 +512,7 @@ describe("HomePage", () => { ).toBeDefined(); }); - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); await user.click(editButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -556,7 +557,7 @@ describe("HomePage", () => { }); // Click Edit on first deck - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); await user.click(editButtons.at(0) as HTMLElement); // Update name @@ -565,7 +566,7 @@ describe("HomePage", () => { await user.type(nameInput, "Updated Japanese"); // Save - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); // Modal should close await waitFor(() => { @@ -601,7 +602,9 @@ describe("HomePage", () => { ).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); expect(deleteButtons.length).toBe(2); }); @@ -622,7 +625,9 @@ describe("HomePage", () => { ).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); await user.click(deleteButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -651,7 +656,9 @@ describe("HomePage", () => { ).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); await user.click(deleteButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -692,7 +699,9 @@ describe("HomePage", () => { }); // Click Delete on first deck - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); await user.click(deleteButtons.at(0) as HTMLElement); // Wait for modal to appear diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx index 783e623..fcae971 100644 --- a/src/client/pages/HomePage.tsx +++ b/src/client/pages/HomePage.tsx @@ -62,122 +62,226 @@ export function HomePage() { }, [fetchDecks]); return ( - <div> - <header - style={{ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "1rem", - }} - > - <h1>Kioku</h1> - <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> - <SyncStatusIndicator /> - <SyncButton /> - <button type="button" onClick={logout}> - Logout - </button> + <div className="min-h-screen bg-cream"> + {/* Header */} + <header className="bg-white border-b border-border/50 sticky top-0 z-10"> + <div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between"> + <h1 className="font-display text-2xl font-semibold text-ink"> + Kioku + </h1> + <div className="flex items-center gap-3"> + <SyncStatusIndicator /> + <SyncButton /> + <button + type="button" + onClick={logout} + className="text-sm text-muted hover:text-slate transition-colors px-3 py-1.5 rounded-lg hover:bg-ivory" + > + Logout + </button> + </div> </div> </header> - <main> - <div - style={{ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "1rem", - }} - > - <h2 style={{ margin: 0 }}>Your Decks</h2> - <button type="button" onClick={() => setIsCreateModalOpen(true)}> - Create Deck + {/* Main Content */} + <main className="max-w-4xl mx-auto px-4 py-8"> + {/* Section Header */} + <div className="flex items-center justify-between mb-6"> + <h2 className="font-display text-xl font-medium text-slate"> + Your Decks + </h2> + <button + type="button" + onClick={() => setIsCreateModalOpen(true)} + className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md" + > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 4v16m8-8H4" + /> + </svg> + New Deck </button> </div> - {isLoading && <p>Loading decks...</p>} + {/* Loading State */} + {isLoading && ( + <div className="flex items-center justify-center py-12"> + <svg + className="animate-spin h-8 w-8 text-primary" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle + className="opacity-25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="4" + fill="none" + /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" + /> + </svg> + </div> + )} + {/* Error State */} {error && ( - <div role="alert" style={{ color: "red" }}> - {error} + <div + role="alert" + className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between" + > + <span className="text-error">{error}</span> <button type="button" onClick={fetchDecks} - style={{ marginLeft: "0.5rem" }} + className="text-error hover:text-error/80 font-medium text-sm" > Retry </button> </div> )} + {/* Empty State */} {!isLoading && !error && decks.length === 0 && ( - <div> - <p>You don't have any decks yet.</p> - <p>Create your first deck to start learning!</p> + <div className="text-center py-16 animate-fade-in"> + <div className="w-16 h-16 mx-auto mb-4 bg-ivory rounded-2xl flex items-center justify-center"> + <svg + className="w-8 h-8 text-muted" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" + /> + </svg> + </div> + <h3 className="font-display text-lg font-medium text-slate mb-2"> + No decks yet + </h3> + <p className="text-muted text-sm mb-6"> + Create your first deck to start learning + </p> + <button + type="button" + onClick={() => setIsCreateModalOpen(true)} + className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 4v16m8-8H4" + /> + </svg> + Create Your First Deck + </button> </div> )} + {/* Deck List */} {!isLoading && !error && decks.length > 0 && ( - <ul style={{ listStyle: "none", padding: 0 }}> - {decks.map((deck) => ( - <li + <div className="space-y-3 animate-fade-in"> + {decks.map((deck, index) => ( + <div key={deck.id} - style={{ - border: "1px solid #ccc", - padding: "1rem", - marginBottom: "0.5rem", - borderRadius: "4px", - }} + className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200 group" + style={{ animationDelay: `${index * 50}ms` }} > - <div - style={{ - display: "flex", - justifyContent: "space-between", - alignItems: "flex-start", - }} - > - <div> - <h3 style={{ margin: 0 }}> - <Link - href={`/decks/${deck.id}`} - style={{ textDecoration: "none", color: "inherit" }} - > + <div className="flex items-start justify-between gap-4"> + <div className="flex-1 min-w-0"> + <Link + href={`/decks/${deck.id}`} + className="block group-hover:text-primary transition-colors" + > + <h3 className="font-display text-lg font-medium text-slate truncate"> {deck.name} - </Link> - </h3> + </h3> + </Link> {deck.description && ( - <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}> + <p className="text-muted text-sm mt-1 line-clamp-2"> {deck.description} </p> )} </div> - <div style={{ display: "flex", gap: "0.5rem" }}> - <button type="button" onClick={() => setEditingDeck(deck)}> - Edit + <div className="flex items-center gap-2 shrink-0"> + <button + type="button" + onClick={() => setEditingDeck(deck)} + className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors" + title="Edit deck" + > + <svg + className="w-4 h-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" + /> + </svg> </button> <button type="button" onClick={() => setDeletingDeck(deck)} - style={{ - backgroundColor: "#dc3545", - color: "white", - border: "none", - padding: "0.25rem 0.5rem", - borderRadius: "4px", - cursor: "pointer", - }} + className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" + title="Delete deck" > - Delete + <svg + className="w-4 h-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" + /> + </svg> </button> </div> </div> - </li> + </div> ))} - </ul> + </div> )} </main> + {/* Modals */} <CreateDeckModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} diff --git a/src/client/pages/LoginPage.test.tsx b/src/client/pages/LoginPage.test.tsx index 724f433..e4dac95 100644 --- a/src/client/pages/LoginPage.test.tsx +++ b/src/client/pages/LoginPage.test.tsx @@ -55,10 +55,11 @@ describe("LoginPage", () => { it("renders login form", async () => { renderWithProviders(); - expect(screen.getByRole("heading", { name: "Login" })).toBeDefined(); + expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined(); + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeDefined(); expect(screen.getByLabelText("Username")).toBeDefined(); expect(screen.getByLabelText("Password")).toBeDefined(); - expect(screen.getByRole("button", { name: "Login" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined(); }); it("submits form and logs in successfully", async () => { @@ -74,7 +75,7 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "password123"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(apiClient.login).toHaveBeenCalledWith("testuser", "password123"); @@ -92,7 +93,7 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "wrongpassword"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toBe("Invalid credentials"); @@ -107,7 +108,7 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "password123"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toBe( @@ -126,10 +127,10 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "password123"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { - const button = screen.getByRole("button", { name: "Logging in..." }); + const button = screen.getByRole("button", { name: /Signing in/ }); expect(button.hasAttribute("disabled")).toBe(true); }); expect( diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx index cc59105..89dd053 100644 --- a/src/client/pages/LoginPage.tsx +++ b/src/client/pages/LoginPage.tsx @@ -38,42 +38,113 @@ export function LoginPage() { }; return ( - <div> - <h1>Login</h1> - <form onSubmit={handleSubmit}> - {error && ( - <div role="alert" style={{ color: "red" }}> - {error} - </div> - )} - <div> - <label htmlFor="username">Username</label> - <input - id="username" - type="text" - value={username} - onChange={(e) => setUsername(e.target.value)} - required - autoComplete="username" - disabled={isSubmitting} - /> + <div className="min-h-screen flex items-center justify-center px-4 py-12 bg-cream"> + <div className="w-full max-w-sm animate-slide-up"> + {/* Logo/Brand */} + <div className="text-center mb-10"> + <h1 className="font-display text-4xl font-semibold text-ink tracking-tight"> + Kioku + </h1> + <p className="mt-2 text-muted text-sm">Your memory, amplified</p> </div> - <div> - <label htmlFor="password">Password</label> - <input - id="password" - type="password" - value={password} - onChange={(e) => setPassword(e.target.value)} - required - autoComplete="current-password" - disabled={isSubmitting} - /> + + {/* Login Card */} + <div className="bg-white rounded-2xl shadow-lg p-8 border border-border/50"> + <h2 className="font-display text-xl font-medium text-slate mb-6"> + Welcome back + </h2> + + <form onSubmit={handleSubmit} className="space-y-5"> + {error && ( + <div + role="alert" + className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20" + > + {error} + </div> + )} + + <div> + <label + htmlFor="username" + className="block text-sm font-medium text-slate mb-1.5" + > + Username + </label> + <input + id="username" + type="text" + value={username} + onChange={(e) => setUsername(e.target.value)} + required + autoComplete="username" + disabled={isSubmitting} + className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed" + placeholder="Enter your username" + /> + </div> + + <div> + <label + htmlFor="password" + className="block text-sm font-medium text-slate mb-1.5" + > + Password + </label> + <input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + required + autoComplete="current-password" + disabled={isSubmitting} + className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed" + placeholder="Enter your password" + /> + </div> + + <button + type="submit" + disabled={isSubmitting} + className="w-full bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-4 rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98] shadow-sm hover:shadow-md" + > + {isSubmitting ? ( + <span className="flex items-center justify-center gap-2"> + <svg + className="animate-spin h-4 w-4" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle + className="opacity-25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="4" + fill="none" + /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" + /> + </svg> + Signing in... + </span> + ) : ( + "Sign in" + )} + </button> + </form> </div> - <button type="submit" disabled={isSubmitting}> - {isSubmitting ? "Logging in..." : "Login"} - </button> - </form> + + {/* Footer note */} + <p className="text-center text-muted text-xs mt-6"> + Spaced repetition learning + </p> + </div> </div> ); } diff --git a/src/client/pages/NotFoundPage.tsx b/src/client/pages/NotFoundPage.tsx index 289dab5..72531c1 100644 --- a/src/client/pages/NotFoundPage.tsx +++ b/src/client/pages/NotFoundPage.tsx @@ -2,10 +2,52 @@ import { Link } from "wouter"; export function NotFoundPage() { return ( - <div> - <h1>404 - Not Found</h1> - <p>The page you're looking for doesn't exist.</p> - <Link href="/">Go to Home</Link> + <div className="min-h-screen bg-cream flex items-center justify-center px-4"> + <div className="text-center animate-fade-in"> + <div className="w-20 h-20 mx-auto mb-6 bg-ivory rounded-2xl flex items-center justify-center"> + <svg + className="w-10 h-10 text-muted" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" + /> + </svg> + </div> + <h1 className="font-display text-6xl font-bold text-ink mb-2">404</h1> + <h2 className="font-display text-xl font-medium text-slate mb-4"> + Page Not Found + </h2> + <p className="text-muted mb-8 max-w-sm mx-auto"> + The page you're looking for doesn't exist or has been moved. + </p> + <Link + href="/" + className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + <svg + className="w-5 h-5" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" + /> + </svg> + Go Home + </Link> + </div> </div> ); } diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx index bab9193..146322a 100644 --- a/src/client/pages/StudyPage.test.tsx +++ b/src/client/pages/StudyPage.test.tsx @@ -129,7 +129,8 @@ describe("StudyPage", () => { renderWithProviders(); - expect(screen.getByText("Loading study session...")).toBeDefined(); + // Loading state shows spinner (svg with animate-spin class) + expect(document.querySelector(".animate-spin")).toBeDefined(); }); it("renders deck name and back link", async () => { @@ -147,7 +148,7 @@ describe("StudyPage", () => { await waitFor(() => { expect( - screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + screen.getByRole("heading", { name: /Japanese Vocabulary/ }), ).toBeDefined(); }); @@ -229,7 +230,7 @@ describe("StudyPage", () => { await waitFor(() => { expect( - screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + screen.getByRole("heading", { name: /Japanese Vocabulary/ }), ).toBeDefined(); }); }); @@ -252,9 +253,9 @@ describe("StudyPage", () => { await waitFor(() => { expect(screen.getByTestId("no-cards")).toBeDefined(); }); - expect(screen.getByText("No cards to study")).toBeDefined(); + expect(screen.getByText("All caught up!")).toBeDefined(); expect( - screen.getByText("There are no due cards in this deck right now."), + screen.getByText("No cards due for review right now"), ).toBeDefined(); }); }); @@ -633,7 +634,7 @@ describe("StudyPage", () => { expect(screen.getByTestId("session-complete")).toBeDefined(); }); - expect(screen.getByText("Back to Deck")).toBeDefined(); + expect(screen.getAllByText("Back to Deck").length).toBeGreaterThan(0); expect(screen.getByText("All Decks")).toBeDefined(); }); }); diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 03cb537..16c1a1c 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -29,11 +29,11 @@ const RatingLabels: Record<Rating, string> = { 4: "Easy", }; -const RatingColors: Record<Rating, string> = { - 1: "#dc3545", - 2: "#fd7e14", - 3: "#28a745", - 4: "#007bff", +const RatingStyles: Record<Rating, string> = { + 1: "bg-again hover:bg-again/90 focus:ring-again/30", + 2: "bg-hard hover:bg-hard/90 focus:ring-hard/30", + 3: "bg-good hover:bg-good/90 focus:ring-good/30", + 4: "bg-easy hover:bg-easy/90 focus:ring-easy/30", }; export function StudyPage() { @@ -217,9 +217,16 @@ export function StudyPage() { if (!deckId) { return ( - <div> - <p>Invalid deck ID</p> - <Link href="/">Back to decks</Link> + <div className="min-h-screen bg-cream flex items-center justify-center"> + <div className="text-center"> + <p className="text-muted mb-4">Invalid deck ID</p> + <Link + href="/" + className="text-primary hover:text-primary-dark font-medium" + > + Back to decks + </Link> + </div> </div> ); } @@ -230,218 +237,259 @@ export function StudyPage() { const remainingCards = cards.length - currentIndex; return ( - <div style={{ maxWidth: "600px", margin: "0 auto", padding: "1rem" }}> - <header style={{ marginBottom: "1rem" }}> - <Link href={`/decks/${deckId}`} style={{ textDecoration: "none" }}> - ← Back to Deck - </Link> - </header> - - {isLoading && <p>Loading study session...</p>} - - {error && ( - <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> - {error} - <button - type="button" - onClick={fetchData} - style={{ marginLeft: "0.5rem" }} + <div className="min-h-screen bg-cream flex flex-col"> + {/* Header */} + <header className="bg-white border-b border-border/50 shrink-0"> + <div className="max-w-2xl mx-auto px-4 py-4"> + <Link + href={`/decks/${deckId}`} + className="inline-flex items-center gap-2 text-muted hover:text-slate transition-colors text-sm" > - Retry - </button> + <svg + className="w-4 h-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M15 19l-7-7 7-7" + /> + </svg> + Back to Deck + </Link> </div> - )} + </header> - {!isLoading && !error && deck && ( - <> - <div - style={{ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "1rem", - }} - > - <h1 style={{ margin: 0 }}>Study: {deck.name}</h1> - {!isSessionComplete && !hasNoCards && ( - <span - data-testid="remaining-count" - style={{ - backgroundColor: "#f0f0f0", - padding: "0.25rem 0.75rem", - borderRadius: "12px", - fontSize: "0.875rem", - }} - > - {remainingCards} remaining - </span> - )} + {/* Main Content */} + <main className="flex-1 flex flex-col max-w-2xl mx-auto w-full px-4 py-6"> + {/* Loading State */} + {isLoading && ( + <div className="flex-1 flex items-center justify-center"> + <svg + className="animate-spin h-8 w-8 text-primary" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle + className="opacity-25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="4" + fill="none" + /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" + /> + </svg> </div> + )} - {hasNoCards && ( - <div - data-testid="no-cards" - style={{ - textAlign: "center", - padding: "3rem 1rem", - backgroundColor: "#f8f9fa", - borderRadius: "8px", - }} + {/* Error State */} + {error && ( + <div + role="alert" + className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between mb-4" + > + <span className="text-error">{error}</span> + <button + type="button" + onClick={fetchData} + className="text-error hover:text-error/80 font-medium text-sm" > - <h2 style={{ marginTop: 0 }}>No cards to study</h2> - <p style={{ color: "#666" }}> - There are no due cards in this deck right now. - </p> - <Link href={`/decks/${deckId}`}> - <button type="button">Back to Deck</button> - </Link> + Retry + </button> + </div> + )} + + {/* Study Content */} + {!isLoading && !error && deck && ( + <div className="flex-1 flex flex-col animate-fade-in"> + {/* Study Header */} + <div className="flex items-center justify-between mb-6"> + <h1 className="font-display text-xl font-medium text-slate truncate"> + {deck.name} + </h1> + {!isSessionComplete && !hasNoCards && ( + <span + data-testid="remaining-count" + className="bg-ivory text-slate px-3 py-1 rounded-full text-sm font-medium" + > + {remainingCards} remaining + </span> + )} </div> - )} - - {isSessionComplete && ( - <div - data-testid="session-complete" - style={{ - textAlign: "center", - padding: "3rem 1rem", - backgroundColor: "#d4edda", - borderRadius: "8px", - }} - > - <h2 style={{ marginTop: 0, color: "#155724" }}> - Session Complete! - </h2> - <p style={{ fontSize: "1.25rem", marginBottom: "1.5rem" }}> - You reviewed{" "} - <strong data-testid="completed-count">{completedCount}</strong>{" "} - card{completedCount !== 1 ? "s" : ""}. - </p> + + {/* No Cards State */} + {hasNoCards && ( <div - style={{ - display: "flex", - gap: "1rem", - justifyContent: "center", - }} + data-testid="no-cards" + className="flex-1 flex items-center justify-center" > - <Link href={`/decks/${deckId}`}> - <button type="button">Back to Deck</button> - </Link> - <Link href="/"> - <button type="button">All Decks</button> - </Link> + <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-card max-w-sm w-full"> + <div className="w-16 h-16 mx-auto mb-4 bg-success/10 rounded-2xl flex items-center justify-center"> + <svg + className="w-8 h-8 text-success" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M5 13l4 4L19 7" + /> + </svg> + </div> + <h2 className="font-display text-xl font-medium text-slate mb-2"> + All caught up! + </h2> + <p className="text-muted text-sm mb-6"> + No cards due for review right now + </p> + <Link + href={`/decks/${deckId}`} + className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + Back to Deck + </Link> + </div> </div> - </div> - )} - - {currentCard && !isSessionComplete && ( - <div data-testid="study-card"> - <button - type="button" - data-testid="card-container" - onClick={!isFlipped ? handleFlip : undefined} - aria-label={ - isFlipped ? "Card showing answer" : "Click to reveal answer" - } - disabled={isFlipped} - style={{ - width: "100%", - border: "1px solid #ccc", - borderRadius: "8px", - padding: "2rem", - minHeight: "200px", - display: "flex", - flexDirection: "column", - justifyContent: "center", - alignItems: "center", - cursor: isFlipped ? "default" : "pointer", - backgroundColor: isFlipped ? "#f8f9fa" : "white", - transition: "background-color 0.2s", - font: "inherit", - }} + )} + + {/* Session Complete State */} + {isSessionComplete && ( + <div + data-testid="session-complete" + className="flex-1 flex items-center justify-center" > - {!isFlipped ? ( - <> - <p - data-testid="card-front" - style={{ - fontSize: "1.25rem", - textAlign: "center", - margin: 0, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} + <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-lg max-w-sm w-full animate-scale-in"> + <div className="w-20 h-20 mx-auto mb-6 bg-success/10 rounded-full flex items-center justify-center"> + <svg + className="w-10 h-10 text-success" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" > - {currentCard.front} - </p> + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + /> + </svg> + </div> + <h2 className="font-display text-2xl font-semibold text-ink mb-2"> + Session Complete! + </h2> + <p className="text-muted mb-1">You reviewed</p> + <p className="text-4xl font-display font-bold text-primary mb-1"> + <span data-testid="completed-count">{completedCount}</span> + </p> + <p className="text-muted mb-8"> + card{completedCount !== 1 ? "s" : ""} + </p> + <div className="flex flex-col sm:flex-row gap-3 justify-center"> + <Link + href={`/decks/${deckId}`} + className="inline-flex items-center justify-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + Back to Deck + </Link> + <Link + href="/" + className="inline-flex items-center justify-center gap-2 bg-ivory hover:bg-border text-slate font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + All Decks + </Link> + </div> + </div> + </div> + )} + + {/* Active Study Card */} + {currentCard && !isSessionComplete && ( + <div data-testid="study-card" className="flex-1 flex flex-col"> + {/* Card */} + <button + type="button" + data-testid="card-container" + onClick={!isFlipped ? handleFlip : undefined} + aria-label={ + isFlipped ? "Card showing answer" : "Click to reveal answer" + } + disabled={isFlipped} + className={`flex-1 min-h-[280px] bg-white rounded-2xl border border-border/50 shadow-card p-8 flex flex-col items-center justify-center text-center transition-all duration-300 ${ + !isFlipped + ? "cursor-pointer hover:shadow-lg hover:border-primary/30 active:scale-[0.99]" + : "bg-ivory/50" + }`} + > + {!isFlipped ? ( + <> + <p + data-testid="card-front" + className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed" + > + {currentCard.front} + </p> + <p className="mt-8 text-muted text-sm flex items-center gap-2"> + <kbd className="px-2 py-0.5 bg-ivory rounded text-xs font-mono"> + Space + </kbd> + <span>or tap to reveal</span> + </p> + </> + ) : ( <p - style={{ - marginTop: "1.5rem", - color: "#666", - fontSize: "0.875rem", - }} + data-testid="card-back" + className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed animate-fade-in" > - Click or press Space to reveal + {currentCard.back} </p> - </> - ) : ( - <p - data-testid="card-back" - style={{ - fontSize: "1.25rem", - textAlign: "center", - margin: 0, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - }} + )} + </button> + + {/* Rating Buttons */} + {isFlipped && ( + <div + data-testid="rating-buttons" + className="mt-6 grid grid-cols-4 gap-2 animate-slide-up" > - {currentCard.back} - </p> + {([1, 2, 3, 4] as Rating[]).map((rating) => ( + <button + key={rating} + type="button" + data-testid={`rating-${rating}`} + onClick={() => handleRating(rating)} + disabled={isSubmitting} + className={`py-4 px-2 rounded-xl text-white font-medium transition-all duration-200 focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.97] ${RatingStyles[rating]}`} + > + <span className="block text-base font-semibold"> + {RatingLabels[rating]} + </span> + <span className="block text-xs opacity-80 mt-0.5"> + {rating} + </span> + </button> + ))} + </div> )} - </button> - - {isFlipped && ( - <div - data-testid="rating-buttons" - style={{ - display: "flex", - gap: "0.5rem", - justifyContent: "center", - marginTop: "1rem", - }} - > - {([1, 2, 3, 4] as Rating[]).map((rating) => ( - <button - key={rating} - type="button" - data-testid={`rating-${rating}`} - onClick={() => handleRating(rating)} - disabled={isSubmitting} - style={{ - flex: 1, - padding: "0.75rem 1rem", - backgroundColor: RatingColors[rating], - color: "white", - border: "none", - borderRadius: "4px", - cursor: isSubmitting ? "not-allowed" : "pointer", - opacity: isSubmitting ? 0.6 : 1, - fontSize: "0.875rem", - }} - > - <span style={{ display: "block", fontWeight: "bold" }}> - {RatingLabels[rating]} - </span> - <span style={{ display: "block", fontSize: "0.75rem" }}> - {rating} - </span> - </button> - ))} - </div> - )} - </div> - )} - </> - )} + </div> + )} + </div> + )} + </main> </div> ); } |
