diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-08 00:18:03 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-08 00:18:03 +0900 |
| commit | 65c0adfd769b9ef11b897c96a3634c61120055b8 (patch) | |
| tree | 74668feef8f134c1b132beaab125e42fa9d77b2e /src/client/components | |
| parent | 7cf55a3b7e37971ea0835118a26f032d895ff71f (diff) | |
| download | kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.gz kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.zst kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.zip | |
feat(client): redesign frontend with TailwindCSS v4
Replace inline styles with TailwindCSS, implementing a cohesive Japanese-inspired
design system with custom colors (cream, teal primary), typography (Fraunces,
DM Sans), and animations. Update all pages and components with consistent styling,
improve accessibility by adding aria-hidden to decorative SVGs, and configure
Biome for Tailwind CSS syntax support.
🤖 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')
| -rw-r--r-- | src/client/components/CreateCardModal.test.tsx | 20 | ||||
| -rw-r--r-- | src/client/components/CreateCardModal.tsx | 163 | ||||
| -rw-r--r-- | src/client/components/CreateDeckModal.test.tsx | 20 | ||||
| -rw-r--r-- | src/client/components/CreateDeckModal.tsx | 160 | ||||
| -rw-r--r-- | src/client/components/DeleteCardModal.tsx | 122 | ||||
| -rw-r--r-- | src/client/components/DeleteDeckModal.tsx | 125 | ||||
| -rw-r--r-- | src/client/components/EditCardModal.test.tsx | 24 | ||||
| -rw-r--r-- | src/client/components/EditCardModal.tsx | 169 | ||||
| -rw-r--r-- | src/client/components/EditDeckModal.test.tsx | 24 | ||||
| -rw-r--r-- | src/client/components/EditDeckModal.tsx | 164 | ||||
| -rw-r--r-- | src/client/components/OfflineBanner.test.tsx | 3 | ||||
| -rw-r--r-- | src/client/components/OfflineBanner.tsx | 31 | ||||
| -rw-r--r-- | src/client/components/SyncButton.tsx | 65 | ||||
| -rw-r--r-- | src/client/components/SyncStatusIndicator.tsx | 116 |
14 files changed, 617 insertions, 589 deletions
diff --git a/src/client/components/CreateCardModal.test.tsx b/src/client/components/CreateCardModal.test.tsx index 6b429c8..7244824 100644 --- a/src/client/components/CreateCardModal.test.tsx +++ b/src/client/components/CreateCardModal.test.tsx @@ -84,7 +84,7 @@ describe("CreateCardModal", () => { expect(screen.getByLabelText("Front")).toBeDefined(); expect(screen.getByLabelText("Back")).toBeDefined(); expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Create" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Create Card" })).toBeDefined(); }); it("disables create button when front is empty", async () => { @@ -93,7 +93,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Back"), "Answer"); - const createButton = screen.getByRole("button", { name: "Create" }); + const createButton = screen.getByRole("button", { name: "Create Card" }); expect(createButton).toHaveProperty("disabled", true); }); @@ -103,7 +103,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Front"), "Question"); - const createButton = screen.getByRole("button", { name: "Create" }); + const createButton = screen.getByRole("button", { name: "Create Card" }); expect(createButton).toHaveProperty("disabled", true); }); @@ -114,7 +114,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Front"), "Question"); await user.type(screen.getByLabelText("Back"), "Answer"); - const createButton = screen.getByRole("button", { name: "Create" }); + const createButton = screen.getByRole("button", { name: "Create Card" }); expect(createButton).toHaveProperty("disabled", false); }); @@ -181,7 +181,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Front"), "What is 2+2?"); await user.type(screen.getByLabelText("Back"), "4"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Card" })); await waitFor(() => { expect( @@ -213,7 +213,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Front"), " Question "); await user.type(screen.getByLabelText("Back"), " Answer "); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Card" })); await waitFor(() => { expect( @@ -241,7 +241,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Front"), "Question"); await user.type(screen.getByLabelText("Back"), "Answer"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Card" })); expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined(); expect(screen.getByRole("button", { name: "Creating..." })).toHaveProperty( @@ -271,7 +271,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Front"), "Question"); await user.type(screen.getByLabelText("Back"), "Answer"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Card" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -291,7 +291,7 @@ describe("CreateCardModal", () => { await user.type(screen.getByLabelText("Front"), "Question"); await user.type(screen.getByLabelText("Back"), "Answer"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Card" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -358,7 +358,7 @@ describe("CreateCardModal", () => { // Create a card await user.type(screen.getByLabelText("Front"), "Question"); await user.type(screen.getByLabelText("Back"), "Answer"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Card" })); await waitFor(() => { expect(onClose).toHaveBeenCalled(); diff --git a/src/client/components/CreateCardModal.tsx b/src/client/components/CreateCardModal.tsx index c28cf0f..3913e82 100644 --- a/src/client/components/CreateCardModal.tsx +++ b/src/client/components/CreateCardModal.tsx @@ -83,18 +83,7 @@ export function CreateCardModal({ 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, - }} + 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) { handleClose(); @@ -106,88 +95,82 @@ export function CreateCardModal({ } }} > - <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> + <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in"> + <div className="p-6"> + <h2 + id="create-card-title" + className="font-display text-xl font-medium text-ink mb-6" + > + Create New Card + </h2> - <form onSubmit={handleSubmit}> - {error && ( - <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> - {error} - </div> - )} + <form onSubmit={handleSubmit} className="space-y-4"> + {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 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> + <label + htmlFor="card-front" + className="block text-sm font-medium text-slate mb-1.5" + > + Front + </label> + <textarea + id="card-front" + value={front} + onChange={(e) => setFront(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Question or prompt" + 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 resize-none" + /> + </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> + <label + htmlFor="card-back" + className="block text-sm font-medium text-slate mb-1.5" + > + Back + </label> + <textarea + id="card-back" + value={back} + onChange={(e) => setBack(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Answer or explanation" + 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 resize-none" + /> + </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 className="flex gap-3 justify-end pt-2"> + <button + type="button" + onClick={handleClose} + disabled={isSubmitting} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50" + > + Cancel + </button> + <button + type="submit" + disabled={isSubmitting || !isFormValid} + 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" + > + {isSubmitting ? "Creating..." : "Create Card"} + </button> + </div> + </form> + </div> </div> </div> ); diff --git a/src/client/components/CreateDeckModal.test.tsx b/src/client/components/CreateDeckModal.test.tsx index 984f6d0..cdc5f97 100644 --- a/src/client/components/CreateDeckModal.test.tsx +++ b/src/client/components/CreateDeckModal.test.tsx @@ -79,13 +79,13 @@ describe("CreateDeckModal", () => { expect(screen.getByLabelText("Name")).toBeDefined(); expect(screen.getByLabelText("Description (optional)")).toBeDefined(); expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Create" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Create Deck" })).toBeDefined(); }); it("disables create button when name is empty", () => { render(<CreateDeckModal {...defaultProps} />); - const createButton = screen.getByRole("button", { name: "Create" }); + const createButton = screen.getByRole("button", { name: "Create Deck" }); expect(createButton).toHaveProperty("disabled", true); }); @@ -96,7 +96,7 @@ describe("CreateDeckModal", () => { const nameInput = screen.getByLabelText("Name"); await user.type(nameInput, "My Deck"); - const createButton = screen.getByRole("button", { name: "Create" }); + const createButton = screen.getByRole("button", { name: "Create Deck" }); expect(createButton).toHaveProperty("disabled", false); }); @@ -161,7 +161,7 @@ describe("CreateDeckModal", () => { ); await user.type(screen.getByLabelText("Name"), "Test Deck"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith( @@ -206,7 +206,7 @@ describe("CreateDeckModal", () => { screen.getByLabelText("Description (optional)"), "A test description", ); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith( @@ -236,7 +236,7 @@ describe("CreateDeckModal", () => { screen.getByLabelText("Description (optional)"), " Description ", ); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith( @@ -256,7 +256,7 @@ describe("CreateDeckModal", () => { render(<CreateDeckModal {...defaultProps} />); await user.type(screen.getByLabelText("Name"), "Test Deck"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined(); expect(screen.getByRole("button", { name: "Creating..." })).toHaveProperty( @@ -288,7 +288,7 @@ describe("CreateDeckModal", () => { render(<CreateDeckModal {...defaultProps} />); await user.type(screen.getByLabelText("Name"), "Test Deck"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -307,7 +307,7 @@ describe("CreateDeckModal", () => { render(<CreateDeckModal {...defaultProps} />); await user.type(screen.getByLabelText("Name"), "Test Deck"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -376,7 +376,7 @@ describe("CreateDeckModal", () => { // Create a deck await user.type(screen.getByLabelText("Name"), "Test Deck"); - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { expect(onClose).toHaveBeenCalled(); diff --git a/src/client/components/CreateDeckModal.tsx b/src/client/components/CreateDeckModal.tsx index 85afb0c..4541a68 100644 --- a/src/client/components/CreateDeckModal.tsx +++ b/src/client/components/CreateDeckModal.tsx @@ -78,18 +78,7 @@ export function CreateDeckModal({ role="dialog" aria-modal="true" aria-labelledby="create-deck-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, - }} + 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) { handleClose(); @@ -101,83 +90,84 @@ export function CreateDeckModal({ } }} > - <div - style={{ - backgroundColor: "white", - padding: "1.5rem", - borderRadius: "8px", - width: "100%", - maxWidth: "400px", - margin: "1rem", - }} - > - <h2 id="create-deck-title" style={{ marginTop: 0 }}> - Create New Deck - </h2> + <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in"> + <div className="p-6"> + <h2 + id="create-deck-title" + className="font-display text-xl font-medium text-ink mb-6" + > + Create New Deck + </h2> - <form onSubmit={handleSubmit}> - {error && ( - <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> - {error} - </div> - )} + <form onSubmit={handleSubmit} className="space-y-4"> + {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 style={{ marginBottom: "1rem" }}> - <label - htmlFor="deck-name" - style={{ display: "block", marginBottom: "0.25rem" }} - > - Name - </label> - <input - id="deck-name" - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - required - maxLength={255} - disabled={isSubmitting} - style={{ width: "100%", boxSizing: "border-box" }} - /> - </div> + <div> + <label + htmlFor="deck-name" + className="block text-sm font-medium text-slate mb-1.5" + > + Name + </label> + <input + id="deck-name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + required + maxLength={255} + 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="My New Deck" + /> + </div> - <div style={{ marginBottom: "1rem" }}> - <label - htmlFor="deck-description" - style={{ display: "block", marginBottom: "0.25rem" }} - > - Description (optional) - </label> - <textarea - id="deck-description" - value={description} - onChange={(e) => setDescription(e.target.value)} - maxLength={1000} - disabled={isSubmitting} - rows={3} - style={{ - width: "100%", - boxSizing: "border-box", - resize: "vertical", - }} - /> - </div> + <div> + <label + htmlFor="deck-description" + className="block text-sm font-medium text-slate mb-1.5" + > + Description{" "} + <span className="text-muted font-normal">(optional)</span> + </label> + <textarea + id="deck-description" + value={description} + onChange={(e) => setDescription(e.target.value)} + maxLength={1000} + disabled={isSubmitting} + rows={3} + 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 resize-none" + placeholder="What will you learn?" + /> + </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 || !name.trim()}> - {isSubmitting ? "Creating..." : "Create"} - </button> - </div> - </form> + <div className="flex gap-3 justify-end pt-2"> + <button + type="button" + onClick={handleClose} + disabled={isSubmitting} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50" + > + Cancel + </button> + <button + type="submit" + disabled={isSubmitting || !name.trim()} + 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" + > + {isSubmitting ? "Creating..." : "Create Deck"} + </button> + </div> + </form> + </div> </div> </div> ); diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx index 99abbd0..44a745d 100644 --- a/src/client/components/DeleteCardModal.tsx +++ b/src/client/components/DeleteCardModal.tsx @@ -81,18 +81,7 @@ export function DeleteCardModal({ role="dialog" aria-modal="true" aria-labelledby="delete-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, - }} + 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) { handleClose(); @@ -104,56 +93,69 @@ export function DeleteCardModal({ } }} > - <div - style={{ - backgroundColor: "white", - padding: "1.5rem", - borderRadius: "8px", - width: "100%", - maxWidth: "400px", - margin: "1rem", - }} - > - <h2 id="delete-card-title" style={{ marginTop: 0 }}> - Delete Card - </h2> - - {error && ( - <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> - {error} + <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in"> + <div className="p-6"> + <div className="w-12 h-12 mx-auto mb-4 bg-error/10 rounded-full flex items-center justify-center"> + <svg + className="w-6 h-6 text-error" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" + /> + </svg> </div> - )} - - <p>Are you sure you want to delete this card?</p> - <p style={{ color: "#666", fontStyle: "italic" }}>"{displayFront}"</p> - <p style={{ color: "#666" }}>This action cannot be undone.</p> - - <div - style={{ - display: "flex", - gap: "0.5rem", - justifyContent: "flex-end", - marginTop: "1.5rem", - }} - > - <button type="button" onClick={handleClose} disabled={isDeleting}> - Cancel - </button> - <button - type="button" - onClick={handleDelete} - disabled={isDeleting} - style={{ - backgroundColor: "#dc3545", - color: "white", - border: "none", - padding: "0.5rem 1rem", - borderRadius: "4px", - cursor: isDeleting ? "not-allowed" : "pointer", - }} + + <h2 + id="delete-card-title" + className="font-display text-xl font-medium text-ink text-center mb-2" > - {isDeleting ? "Deleting..." : "Delete"} - </button> + Delete Card + </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> + )} + + <p className="text-slate text-center mb-2"> + Are you sure you want to delete this card? + </p> + <p className="text-muted text-sm text-center italic mb-2"> + "{displayFront}" + </p> + <p className="text-muted text-sm text-center mb-6"> + This action cannot be undone. + </p> + + <div className="flex gap-3 justify-center"> + <button + type="button" + onClick={handleClose} + disabled={isDeleting} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50 min-w-[100px]" + > + Cancel + </button> + <button + type="button" + onClick={handleDelete} + disabled={isDeleting} + className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" + > + {isDeleting ? "Deleting..." : "Delete"} + </button> + </div> </div> </div> </div> diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx index 307451c..5a252e6 100644 --- a/src/client/components/DeleteDeckModal.tsx +++ b/src/client/components/DeleteDeckModal.tsx @@ -75,18 +75,7 @@ export function DeleteDeckModal({ role="dialog" aria-modal="true" aria-labelledby="delete-deck-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, - }} + 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) { handleClose(); @@ -98,60 +87,68 @@ export function DeleteDeckModal({ } }} > - <div - style={{ - backgroundColor: "white", - padding: "1.5rem", - borderRadius: "8px", - width: "100%", - maxWidth: "400px", - margin: "1rem", - }} - > - <h2 id="delete-deck-title" style={{ marginTop: 0 }}> - Delete Deck - </h2> - - {error && ( - <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> - {error} + <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in"> + <div className="p-6"> + <div className="w-12 h-12 mx-auto mb-4 bg-error/10 rounded-full flex items-center justify-center"> + <svg + className="w-6 h-6 text-error" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" + /> + </svg> </div> - )} - - <p> - Are you sure you want to delete <strong>{deck.name}</strong>? - </p> - <p style={{ color: "#666" }}> - This action cannot be undone. All cards in this deck will also be - deleted. - </p> - - <div - style={{ - display: "flex", - gap: "0.5rem", - justifyContent: "flex-end", - marginTop: "1.5rem", - }} - > - <button type="button" onClick={handleClose} disabled={isDeleting}> - Cancel - </button> - <button - type="button" - onClick={handleDelete} - disabled={isDeleting} - style={{ - backgroundColor: "#dc3545", - color: "white", - border: "none", - padding: "0.5rem 1rem", - borderRadius: "4px", - cursor: isDeleting ? "not-allowed" : "pointer", - }} + + <h2 + id="delete-deck-title" + className="font-display text-xl font-medium text-ink text-center mb-2" > - {isDeleting ? "Deleting..." : "Delete"} - </button> + Delete Deck + </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> + )} + + <p className="text-slate text-center mb-2"> + Are you sure you want to delete{" "} + <span className="font-semibold">{deck.name}</span>? + </p> + <p className="text-muted text-sm text-center mb-6"> + This action cannot be undone. All cards in this deck will also be + deleted. + </p> + + <div className="flex gap-3 justify-center"> + <button + type="button" + onClick={handleClose} + disabled={isDeleting} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50 min-w-[100px]" + > + Cancel + </button> + <button + type="button" + onClick={handleDelete} + disabled={isDeleting} + className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" + > + {isDeleting ? "Deleting..." : "Delete"} + </button> + </div> </div> </div> </div> diff --git a/src/client/components/EditCardModal.test.tsx b/src/client/components/EditCardModal.test.tsx index f37698f..b07dd4b 100644 --- a/src/client/components/EditCardModal.test.tsx +++ b/src/client/components/EditCardModal.test.tsx @@ -76,7 +76,7 @@ describe("EditCardModal", () => { expect(screen.getByLabelText("Front")).toBeDefined(); expect(screen.getByLabelText("Back")).toBeDefined(); expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Save" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined(); }); it("populates form with card values", () => { @@ -96,7 +96,7 @@ describe("EditCardModal", () => { const frontInput = screen.getByLabelText("Front"); await user.clear(frontInput); - const saveButton = screen.getByRole("button", { name: "Save" }); + const saveButton = screen.getByRole("button", { name: "Save Changes" }); expect(saveButton).toHaveProperty("disabled", true); }); @@ -107,14 +107,14 @@ describe("EditCardModal", () => { const backInput = screen.getByLabelText("Back"); await user.clear(backInput); - const saveButton = screen.getByRole("button", { name: "Save" }); + const saveButton = screen.getByRole("button", { name: "Save Changes" }); expect(saveButton).toHaveProperty("disabled", true); }); it("enables save button when both front and back have content", () => { render(<EditCardModal {...defaultProps} />); - const saveButton = screen.getByRole("button", { name: "Save" }); + const saveButton = screen.getByRole("button", { name: "Save Changes" }); expect(saveButton).toHaveProperty("disabled", false); }); @@ -180,7 +180,7 @@ describe("EditCardModal", () => { const frontInput = screen.getByLabelText("Front"); await user.clear(frontInput); await user.type(frontInput, "Updated front"); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith( @@ -232,7 +232,7 @@ describe("EditCardModal", () => { const backInput = screen.getByLabelText("Back"); await user.clear(backInput); await user.type(backInput, "Updated back"); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith( @@ -270,7 +270,7 @@ describe("EditCardModal", () => { }; render(<EditCardModal {...defaultProps} card={cardWithWhitespace} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith( @@ -297,7 +297,7 @@ describe("EditCardModal", () => { render(<EditCardModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined(); expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty( @@ -323,7 +323,7 @@ describe("EditCardModal", () => { render(<EditCardModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain("Card not found"); @@ -337,7 +337,7 @@ describe("EditCardModal", () => { render(<EditCardModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -353,7 +353,7 @@ describe("EditCardModal", () => { render(<EditCardModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -396,7 +396,7 @@ describe("EditCardModal", () => { ); // Trigger error - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert")).toBeDefined(); }); diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx index 2d04581..e38a2b1 100644 --- a/src/client/components/EditCardModal.tsx +++ b/src/client/components/EditCardModal.tsx @@ -99,18 +99,7 @@ export function EditCardModal({ role="dialog" aria-modal="true" aria-labelledby="edit-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, - }} + 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) { handleClose(); @@ -122,88 +111,82 @@ export function EditCardModal({ } }} > - <div - style={{ - backgroundColor: "white", - padding: "1.5rem", - borderRadius: "8px", - width: "100%", - maxWidth: "500px", - margin: "1rem", - }} - > - <h2 id="edit-card-title" style={{ marginTop: 0 }}> - Edit Card - </h2> - - <form onSubmit={handleSubmit}> - {error && ( - <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> - {error} - </div> - )} - - <div style={{ marginBottom: "1rem" }}> - <label - htmlFor="edit-card-front" - style={{ display: "block", marginBottom: "0.25rem" }} - > - Front - </label> - <textarea - id="edit-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="edit-card-back" - style={{ display: "block", marginBottom: "0.25rem" }} - > - Back - </label> - <textarea - id="edit-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", - }} + <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in"> + <div className="p-6"> + <h2 + id="edit-card-title" + className="font-display text-xl font-medium text-ink mb-6" > - <button type="button" onClick={handleClose} disabled={isSubmitting}> - Cancel - </button> - <button type="submit" disabled={isSubmitting || !isFormValid}> - {isSubmitting ? "Saving..." : "Save"} - </button> - </div> - </form> + Edit Card + </h2> + + <form onSubmit={handleSubmit} className="space-y-4"> + {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="edit-card-front" + className="block text-sm font-medium text-slate mb-1.5" + > + Front + </label> + <textarea + id="edit-card-front" + value={front} + onChange={(e) => setFront(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Question or prompt" + 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 resize-none" + /> + </div> + + <div> + <label + htmlFor="edit-card-back" + className="block text-sm font-medium text-slate mb-1.5" + > + Back + </label> + <textarea + id="edit-card-back" + value={back} + onChange={(e) => setBack(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Answer or explanation" + 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 resize-none" + /> + </div> + + <div className="flex gap-3 justify-end pt-2"> + <button + type="button" + onClick={handleClose} + disabled={isSubmitting} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50" + > + Cancel + </button> + <button + type="submit" + disabled={isSubmitting || !isFormValid} + 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" + > + {isSubmitting ? "Saving..." : "Save Changes"} + </button> + </div> + </form> + </div> </div> </div> ); diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx index e4c997e..c627dd5 100644 --- a/src/client/components/EditDeckModal.test.tsx +++ b/src/client/components/EditDeckModal.test.tsx @@ -76,7 +76,7 @@ describe("EditDeckModal", () => { expect(screen.getByLabelText("Name")).toBeDefined(); expect(screen.getByLabelText("Description (optional)")).toBeDefined(); expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Save" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined(); }); it("populates form with deck values", () => { @@ -106,14 +106,14 @@ describe("EditDeckModal", () => { const nameInput = screen.getByLabelText("Name"); await user.clear(nameInput); - const saveButton = screen.getByRole("button", { name: "Save" }); + const saveButton = screen.getByRole("button", { name: "Save Changes" }); expect(saveButton).toHaveProperty("disabled", true); }); it("enables save button when name has content", () => { render(<EditDeckModal {...defaultProps} />); - const saveButton = screen.getByRole("button", { name: "Save" }); + const saveButton = screen.getByRole("button", { name: "Save Changes" }); expect(saveButton).toHaveProperty("disabled", false); }); @@ -179,7 +179,7 @@ describe("EditDeckModal", () => { const nameInput = screen.getByLabelText("Name"); await user.clear(nameInput); await user.type(nameInput, "Updated Deck"); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { @@ -228,7 +228,7 @@ describe("EditDeckModal", () => { const descInput = screen.getByLabelText("Description (optional)"); await user.clear(descInput); await user.type(descInput, "New description"); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { @@ -267,7 +267,7 @@ describe("EditDeckModal", () => { const descInput = screen.getByLabelText("Description (optional)"); await user.clear(descInput); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { @@ -299,7 +299,7 @@ describe("EditDeckModal", () => { }; render(<EditDeckModal {...defaultProps} deck={deckWithWhitespace} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { @@ -323,7 +323,7 @@ describe("EditDeckModal", () => { render(<EditDeckModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined(); expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty( @@ -352,7 +352,7 @@ describe("EditDeckModal", () => { render(<EditDeckModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -368,7 +368,7 @@ describe("EditDeckModal", () => { render(<EditDeckModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -384,7 +384,7 @@ describe("EditDeckModal", () => { render(<EditDeckModal {...defaultProps} />); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( @@ -430,7 +430,7 @@ describe("EditDeckModal", () => { ); // Trigger error - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert")).toBeDefined(); }); diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx index 46f1d4b..e589900 100644 --- a/src/client/components/EditDeckModal.tsx +++ b/src/client/components/EditDeckModal.tsx @@ -96,18 +96,7 @@ export function EditDeckModal({ role="dialog" aria-modal="true" aria-labelledby="edit-deck-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, - }} + 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) { handleClose(); @@ -119,83 +108,82 @@ export function EditDeckModal({ } }} > - <div - style={{ - backgroundColor: "white", - padding: "1.5rem", - borderRadius: "8px", - width: "100%", - maxWidth: "400px", - margin: "1rem", - }} - > - <h2 id="edit-deck-title" style={{ marginTop: 0 }}> - Edit Deck - </h2> - - <form onSubmit={handleSubmit}> - {error && ( - <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> - {error} - </div> - )} - - <div style={{ marginBottom: "1rem" }}> - <label - htmlFor="edit-deck-name" - style={{ display: "block", marginBottom: "0.25rem" }} - > - Name - </label> - <input - id="edit-deck-name" - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - required - maxLength={255} - disabled={isSubmitting} - style={{ width: "100%", boxSizing: "border-box" }} - /> - </div> - - <div style={{ marginBottom: "1rem" }}> - <label - htmlFor="edit-deck-description" - style={{ display: "block", marginBottom: "0.25rem" }} - > - Description (optional) - </label> - <textarea - id="edit-deck-description" - value={description} - onChange={(e) => setDescription(e.target.value)} - maxLength={1000} - disabled={isSubmitting} - rows={3} - style={{ - width: "100%", - boxSizing: "border-box", - resize: "vertical", - }} - /> - </div> - - <div - style={{ - display: "flex", - gap: "0.5rem", - justifyContent: "flex-end", - }} + <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in"> + <div className="p-6"> + <h2 + id="edit-deck-title" + className="font-display text-xl font-medium text-ink mb-6" > - <button type="button" onClick={handleClose} disabled={isSubmitting}> - Cancel - </button> - <button type="submit" disabled={isSubmitting || !name.trim()}> - {isSubmitting ? "Saving..." : "Save"} - </button> - </div> - </form> + Edit Deck + </h2> + + <form onSubmit={handleSubmit} className="space-y-4"> + {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="edit-deck-name" + className="block text-sm font-medium text-slate mb-1.5" + > + Name + </label> + <input + id="edit-deck-name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + required + maxLength={255} + 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" + /> + </div> + + <div> + <label + htmlFor="edit-deck-description" + className="block text-sm font-medium text-slate mb-1.5" + > + Description{" "} + <span className="text-muted font-normal">(optional)</span> + </label> + <textarea + id="edit-deck-description" + value={description} + onChange={(e) => setDescription(e.target.value)} + maxLength={1000} + disabled={isSubmitting} + rows={3} + 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 resize-none" + /> + </div> + + <div className="flex gap-3 justify-end pt-2"> + <button + type="button" + onClick={handleClose} + disabled={isSubmitting} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50" + > + Cancel + </button> + <button + type="submit" + disabled={isSubmitting || !name.trim()} + 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" + > + {isSubmitting ? "Saving..." : "Save Changes"} + </button> + </div> + </form> + </div> </div> </div> ); diff --git a/src/client/components/OfflineBanner.test.tsx b/src/client/components/OfflineBanner.test.tsx index 41679d9..53ba815 100644 --- a/src/client/components/OfflineBanner.test.tsx +++ b/src/client/components/OfflineBanner.test.tsx @@ -79,7 +79,8 @@ describe("OfflineBanner", () => { render(<OfflineBanner />); const banner = screen.getByTestId("offline-banner"); - expect(banner.getAttribute("role")).toBe("status"); + // <output> element has implicit role="status", so we check it's an output element + expect(banner.tagName.toLowerCase()).toBe("output"); expect(banner.getAttribute("aria-live")).toBe("polite"); }); }); diff --git a/src/client/components/OfflineBanner.tsx b/src/client/components/OfflineBanner.tsx index faca3e7..bf94908 100644 --- a/src/client/components/OfflineBanner.tsx +++ b/src/client/components/OfflineBanner.tsx @@ -10,26 +10,27 @@ export function OfflineBanner() { return ( <output data-testid="offline-banner" - role="status" aria-live="polite" - style={{ - backgroundColor: "#6c757d", - color: "white", - padding: "0.5rem 1rem", - textAlign: "center", - fontSize: "0.875rem", - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: "0.5rem", - }} + className="bg-slate text-white py-2 px-4 text-sm flex items-center justify-center gap-2" > - <span aria-hidden="true">âš¡</span> + <svg + className="w-4 h-4 text-warning" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + /> + </svg> <span> You're offline. Changes will sync when you reconnect. {pendingCount > 0 && ( - <span data-testid="offline-pending-count"> - {" "} + <span data-testid="offline-pending-count" className="ml-1 opacity-80"> ({pendingCount} pending) </span> )} diff --git a/src/client/components/SyncButton.tsx b/src/client/components/SyncButton.tsx index 1ebfa2e..82a6c68 100644 --- a/src/client/components/SyncButton.tsx +++ b/src/client/components/SyncButton.tsx @@ -9,13 +9,6 @@ export function SyncButton() { const isDisabled = !isOnline || isSyncing; - const getButtonText = (): string => { - if (isSyncing) { - return "Syncing..."; - } - return "Sync"; - }; - return ( <button type="button" @@ -23,17 +16,55 @@ export function SyncButton() { onClick={handleSync} disabled={isDisabled} title={!isOnline ? "Cannot sync while offline" : undefined} - style={{ - padding: "0.25rem 0.5rem", - borderRadius: "4px", - border: "1px solid #dee2e6", - backgroundColor: isDisabled ? "#e9ecef" : "#007bff", - color: isDisabled ? "#6c757d" : "white", - cursor: isDisabled ? "not-allowed" : "pointer", - fontSize: "0.875rem", - }} + className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${ + isDisabled + ? "bg-ivory text-muted cursor-not-allowed" + : "bg-primary text-white hover:bg-primary-dark active:scale-[0.98]" + }`} > - {getButtonText()} + {isSyncing ? ( + <> + <svg + className="w-4 h-4 animate-spin" + fill="none" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle + className="opacity-25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="4" + /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" + /> + </svg> + <span>Syncing...</span> + </> + ) : ( + <> + <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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" + /> + </svg> + <span>Sync</span> + </> + )} </button> ); } diff --git a/src/client/components/SyncStatusIndicator.tsx b/src/client/components/SyncStatusIndicator.tsx index 23e3ec6..0f555ca 100644 --- a/src/client/components/SyncStatusIndicator.tsx +++ b/src/client/components/SyncStatusIndicator.tsx @@ -20,63 +20,115 @@ export function SyncStatusIndicator() { return "Synced"; }; - const getStatusColor = (): string => { + const getStatusStyles = (): string => { if (!isOnline) { - return "#6c757d"; // gray + return "bg-muted/10 text-muted"; } if (isSyncing) { - return "#007bff"; // blue + return "bg-info/10 text-info"; } if (status === SyncStatus.Error) { - return "#dc3545"; // red + return "bg-error/10 text-error"; } if (pendingCount > 0) { - return "#ffc107"; // yellow + return "bg-warning/10 text-warning"; } - return "#28a745"; // green + return "bg-success/10 text-success"; }; - const getStatusIcon = (): string => { + const getStatusIcon = () => { if (!isOnline) { - return "\u25CB"; // hollow circle + return ( + <svg + className="w-3.5 h-3.5" + fill="currentColor" + viewBox="0 0 20 20" + aria-hidden="true" + > + <circle cx="10" cy="10" r="4" /> + </svg> + ); } if (isSyncing) { - return "\u21BB"; // rotating arrows + return ( + <svg + className="w-3.5 h-3.5 animate-spin" + fill="none" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle + className="opacity-25" + cx="12" + cy="12" + r="10" + stroke="currentColor" + strokeWidth="4" + /> + <path + className="opacity-75" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" + /> + </svg> + ); } if (status === SyncStatus.Error) { - return "\u2717"; // cross mark + return ( + <svg + className="w-3.5 h-3.5" + fill="currentColor" + viewBox="0 0 20 20" + aria-hidden="true" + > + <path + fillRule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" + clipRule="evenodd" + /> + </svg> + ); } if (pendingCount > 0) { - return "\u25D4"; // partial circle + return ( + <svg + className="w-3.5 h-3.5" + fill="currentColor" + viewBox="0 0 20 20" + aria-hidden="true" + > + <path + fillRule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" + clipRule="evenodd" + /> + </svg> + ); } - return "\u2713"; // check mark + return ( + <svg + className="w-3.5 h-3.5" + fill="currentColor" + viewBox="0 0 20 20" + aria-hidden="true" + > + <path + fillRule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" + clipRule="evenodd" + /> + </svg> + ); }; return ( <div data-testid="sync-status-indicator" - style={{ - display: "inline-flex", - alignItems: "center", - gap: "0.25rem", - padding: "0.25rem 0.5rem", - borderRadius: "4px", - backgroundColor: "#f8f9fa", - border: "1px solid #dee2e6", - fontSize: "0.875rem", - }} + className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${getStatusStyles()}`} title={lastError || undefined} > - <span - style={{ - color: getStatusColor(), - fontWeight: "bold", - }} - aria-hidden="true" - > - {getStatusIcon()} - </span> - <span style={{ color: getStatusColor() }}>{getStatusText()}</span> + {getStatusIcon()} + <span>{getStatusText()}</span> </div> ); } |
