aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 02:38:05 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 02:38:05 +0900
commitb51d4efaa1e5e0417d4306c02797f424938766cb (patch)
tree56ef6a15c7e62fa0f4855e3e4453dffc4bac17ba
parent7e04f7afbb3511e472135de5cb735314778604d3 (diff)
downloadkioku-b51d4efaa1e5e0417d4306c02797f424938766cb.tar.gz
kioku-b51d4efaa1e5e0417d4306c02797f424938766cb.tar.zst
kioku-b51d4efaa1e5e0417d4306c02797f424938766cb.zip
feat(client): add NoteTypesPage for note type management
Implement Phase 6 of the roadmap - NoteType list page with CRUD modals: - NoteTypesPage displays all user's note types with templates and reversible badge - CreateNoteTypeModal for creating new note types with templates - EditNoteTypeModal for updating existing note types - DeleteNoteTypeModal with constraint warning - Navigation link from HomePage header - Comprehensive tests for all components (65 new tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--docs/dev/roadmap.md4
-rw-r--r--src/client/App.tsx6
-rw-r--r--src/client/components/CreateNoteTypeModal.test.tsx318
-rw-r--r--src/client/components/CreateNoteTypeModal.tsx229
-rw-r--r--src/client/components/DeleteNoteTypeModal.test.tsx290
-rw-r--r--src/client/components/DeleteNoteTypeModal.tsx156
-rw-r--r--src/client/components/EditNoteTypeModal.test.tsx368
-rw-r--r--src/client/components/EditNoteTypeModal.tsx239
-rw-r--r--src/client/pages/HomePage.tsx13
-rw-r--r--src/client/pages/NoteTypesPage.test.tsx551
-rw-r--r--src/client/pages/NoteTypesPage.tsx272
-rw-r--r--src/client/pages/index.ts1
12 files changed, 2445 insertions, 2 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 9e771e6..916eee3 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -231,13 +231,13 @@ Create these as default note types for each user:
### Phase 6: Frontend - Note Type Management
**Tasks:**
-- [ ] Create NoteType list page (`/note-types`)
+- [x] Create NoteType list page (`/note-types`)
- [ ] Create NoteType editor component
- Edit name
- Manage fields (add/remove/reorder)
- Edit front/back templates (mustache syntax)
- Toggle `is_reversible` option
-- [ ] Add navigation to note type management
+- [x] Add navigation to note type management
**Files to create:**
- `src/client/pages/NoteTypesPage.tsx`
diff --git a/src/client/App.tsx b/src/client/App.tsx
index 3c20c54..e1b794c 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -4,6 +4,7 @@ import {
DeckDetailPage,
HomePage,
LoginPage,
+ NoteTypesPage,
NotFoundPage,
StudyPage,
} from "./pages";
@@ -28,6 +29,11 @@ export function App() {
<StudyPage />
</ProtectedRoute>
</Route>
+ <Route path="/note-types">
+ <ProtectedRoute>
+ <NoteTypesPage />
+ </ProtectedRoute>
+ </Route>
<Route path="/login" component={LoginPage} />
<Route component={NotFoundPage} />
</Switch>
diff --git a/src/client/components/CreateNoteTypeModal.test.tsx b/src/client/components/CreateNoteTypeModal.test.tsx
new file mode 100644
index 0000000..9536f53
--- /dev/null
+++ b/src/client/components/CreateNoteTypeModal.test.tsx
@@ -0,0 +1,318 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { apiClient } from "../api/client";
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ getAuthHeader: vi.fn(),
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+// Import after mock is set up
+import { CreateNoteTypeModal } from "./CreateNoteTypeModal";
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+describe("CreateNoteTypeModal", () => {
+ const defaultProps = {
+ isOpen: true,
+ onClose: vi.fn(),
+ onNoteTypeCreated: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("does not render when closed", () => {
+ render(<CreateNoteTypeModal {...defaultProps} isOpen={false} />);
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("renders modal when open", () => {
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Create Note Type" }),
+ ).toBeDefined();
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ expect(screen.getByLabelText("Front Template")).toBeDefined();
+ expect(screen.getByLabelText("Back Template")).toBeDefined();
+ expect(screen.getByLabelText("Create reversed cards")).toBeDefined();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
+ });
+
+ it("has default template values", () => {
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByLabelText("Front Template")).toHaveProperty(
+ "value",
+ "{{Front}}",
+ );
+ expect(screen.getByLabelText("Back Template")).toHaveProperty(
+ "value",
+ "{{Back}}",
+ );
+ });
+
+ it("disables create button when name is empty", () => {
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ const createButton = screen.getByRole("button", { name: "Create" });
+ expect(createButton).toHaveProperty("disabled", true);
+ });
+
+ it("enables create button when name has content", async () => {
+ const user = userEvent.setup();
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ const nameInput = screen.getByLabelText("Name");
+ await user.type(nameInput, "My Note Type");
+
+ const createButton = screen.getByRole("button", { name: "Create" });
+ expect(createButton).toHaveProperty("disabled", false);
+ });
+
+ it("calls onClose when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<CreateNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when clicking outside the modal", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<CreateNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ const dialog = screen.getByRole("dialog");
+ await user.click(dialog);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("creates note type with all fields", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ const onNoteTypeCreated = vi.fn();
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ noteType: {
+ id: "note-type-1",
+ name: "Test Note Type",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: true,
+ },
+ }),
+ });
+
+ render(
+ <CreateNoteTypeModal
+ isOpen={true}
+ onClose={onClose}
+ onNoteTypeCreated={onNoteTypeCreated}
+ />,
+ );
+
+ await user.type(screen.getByLabelText("Name"), "Test Note Type");
+ // Keep default templates and just toggle reversible
+ await user.click(screen.getByLabelText("Create reversed cards"));
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith("/api/note-types", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer access-token",
+ },
+ body: JSON.stringify({
+ name: "Test Note Type",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: true,
+ }),
+ });
+ });
+
+ expect(onNoteTypeCreated).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("trims whitespace from text fields", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: { id: "note-type-1" } }),
+ });
+
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Name"), " Test Note Type ");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ "/api/note-types",
+ expect.objectContaining({
+ body: expect.stringContaining('"name":"Test Note Type"'),
+ }),
+ );
+ });
+ });
+
+ it("shows loading state during submission", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Name"), "Test Note Type");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Creating..." })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ expect(screen.getByLabelText("Name")).toHaveProperty("disabled", true);
+ });
+
+ it("displays API error message", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "Note type name already exists" }),
+ });
+
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Name"), "Test Note Type");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Note type name already exists",
+ );
+ });
+ });
+
+ it("displays generic error on unexpected failure", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockRejectedValue(new Error("Network error"));
+
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Name"), "Test Note Type");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to create note type. Please try again.",
+ );
+ });
+ });
+
+ it("displays error when not authenticated", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+
+ render(<CreateNoteTypeModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Name"), "Test Note Type");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Not authenticated",
+ );
+ });
+ });
+
+ it("resets form when closed and reopened", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ const { rerender } = render(
+ <CreateNoteTypeModal
+ isOpen={true}
+ onClose={onClose}
+ onNoteTypeCreated={vi.fn()}
+ />,
+ );
+
+ // Type something in the form
+ await user.type(screen.getByLabelText("Name"), "Test Note Type");
+ await user.click(screen.getByLabelText("Create reversed cards"));
+
+ // Click cancel to close
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ // Reopen the modal
+ rerender(
+ <CreateNoteTypeModal
+ isOpen={true}
+ onClose={onClose}
+ onNoteTypeCreated={vi.fn()}
+ />,
+ );
+
+ // Form should be reset
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
+ expect(screen.getByLabelText("Front Template")).toHaveProperty(
+ "value",
+ "{{Front}}",
+ );
+ expect(screen.getByLabelText("Back Template")).toHaveProperty(
+ "value",
+ "{{Back}}",
+ );
+ expect(screen.getByLabelText("Create reversed cards")).toHaveProperty(
+ "checked",
+ false,
+ );
+ });
+});
diff --git a/src/client/components/CreateNoteTypeModal.tsx b/src/client/components/CreateNoteTypeModal.tsx
new file mode 100644
index 0000000..2b8dfb9
--- /dev/null
+++ b/src/client/components/CreateNoteTypeModal.tsx
@@ -0,0 +1,229 @@
+import { type FormEvent, useState } from "react";
+import { ApiClientError, apiClient } from "../api";
+
+interface CreateNoteTypeModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onNoteTypeCreated: () => void;
+}
+
+export function CreateNoteTypeModal({
+ isOpen,
+ onClose,
+ onNoteTypeCreated,
+}: CreateNoteTypeModalProps) {
+ const [name, setName] = useState("");
+ const [frontTemplate, setFrontTemplate] = useState("{{Front}}");
+ const [backTemplate, setBackTemplate] = useState("{{Back}}");
+ const [isReversible, setIsReversible] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const resetForm = () => {
+ setName("");
+ setFrontTemplate("{{Front}}");
+ setBackTemplate("{{Back}}");
+ setIsReversible(false);
+ setError(null);
+ };
+
+ const handleClose = () => {
+ resetForm();
+ onClose();
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setIsSubmitting(true);
+
+ try {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new ApiClientError("Not authenticated", 401);
+ }
+
+ const res = await fetch("/api/note-types", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...authHeader,
+ },
+ body: JSON.stringify({
+ name: name.trim(),
+ frontTemplate: frontTemplate.trim(),
+ backTemplate: backTemplate.trim(),
+ isReversible,
+ }),
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ resetForm();
+ onNoteTypeCreated();
+ onClose();
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to create note type. Please try again.");
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="create-note-type-title"
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
+ onClick={(e) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ handleClose();
+ }
+ }}
+ >
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="create-note-type-title"
+ className="font-display text-xl font-medium text-ink mb-6"
+ >
+ Create Note Type
+ </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="note-type-name"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Name
+ </label>
+ <input
+ id="note-type-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="Basic"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="front-template"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Front Template
+ </label>
+ <input
+ id="front-template"
+ type="text"
+ value={frontTemplate}
+ onChange={(e) => setFrontTemplate(e.target.value)}
+ required
+ maxLength={1000}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm 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="{{Front}}"
+ />
+ <p className="text-muted text-xs mt-1">
+ Use {"{{FieldName}}"} to insert field values
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="back-template"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Back Template
+ </label>
+ <input
+ id="back-template"
+ type="text"
+ value={backTemplate}
+ onChange={(e) => setBackTemplate(e.target.value)}
+ required
+ maxLength={1000}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm 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="{{Back}}"
+ />
+ </div>
+
+ <div className="flex items-center gap-3">
+ <input
+ id="is-reversible"
+ type="checkbox"
+ checked={isReversible}
+ onChange={(e) => setIsReversible(e.target.checked)}
+ disabled={isSubmitting}
+ className="w-4 h-4 text-primary bg-ivory border-border rounded focus:ring-primary/20 focus:ring-2 disabled:opacity-50"
+ />
+ <label
+ htmlFor="is-reversible"
+ className="text-sm font-medium text-slate"
+ >
+ Create reversed cards
+ </label>
+ </div>
+ <p className="text-muted text-xs -mt-2 ml-7">
+ When enabled, each note will generate both a normal and reversed
+ card
+ </p>
+
+ <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"}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/client/components/DeleteNoteTypeModal.test.tsx b/src/client/components/DeleteNoteTypeModal.test.tsx
new file mode 100644
index 0000000..d5d536a
--- /dev/null
+++ b/src/client/components/DeleteNoteTypeModal.test.tsx
@@ -0,0 +1,290 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { apiClient } from "../api/client";
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ getAuthHeader: vi.fn(),
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+// Import after mock is set up
+import { DeleteNoteTypeModal } from "./DeleteNoteTypeModal";
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+describe("DeleteNoteTypeModal", () => {
+ const mockNoteType = {
+ id: "note-type-123",
+ name: "Basic",
+ };
+
+ const defaultProps = {
+ isOpen: true,
+ noteType: mockNoteType,
+ onClose: vi.fn(),
+ onNoteTypeDeleted: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("does not render when closed", () => {
+ render(<DeleteNoteTypeModal {...defaultProps} isOpen={false} />);
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("does not render when noteType is null", () => {
+ render(<DeleteNoteTypeModal {...defaultProps} noteType={null} />);
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("renders modal when open with noteType", () => {
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Delete Note Type" }),
+ ).toBeDefined();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Delete" })).toBeDefined();
+ });
+
+ it("displays confirmation message with noteType name", () => {
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByText(/Are you sure you want to delete/)).toBeDefined();
+ expect(screen.getByText("Basic")).toBeDefined();
+ });
+
+ it("displays warning about deletion constraints", () => {
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ expect(
+ screen.getByText(/Note types with existing notes cannot be deleted/),
+ ).toBeDefined();
+ });
+
+ it("calls onClose when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when clicking outside the modal", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ const dialog = screen.getByRole("dialog");
+ await user.click(dialog);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not call onClose when clicking inside the modal content", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ await user.click(screen.getByText("Basic"));
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("deletes noteType when Delete is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ const onNoteTypeDeleted = vi.fn();
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ render(
+ <DeleteNoteTypeModal
+ isOpen={true}
+ noteType={mockNoteType}
+ onClose={onClose}
+ onNoteTypeDeleted={onNoteTypeDeleted}
+ />,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", {
+ method: "DELETE",
+ headers: {
+ Authorization: "Bearer access-token",
+ },
+ });
+ });
+
+ expect(onNoteTypeDeleted).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows loading state during deletion", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ expect(screen.getByRole("button", { name: "Deleting..." })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Deleting..." })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ });
+
+ it("displays API error message", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "Note type not found" }),
+ });
+
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Note type not found",
+ );
+ });
+ });
+
+ it("displays conflict error when notes exist", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 409,
+ json: async () => ({ error: "Cannot delete note type with existing notes" }),
+ });
+
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Cannot delete note type with existing notes",
+ );
+ });
+ });
+
+ it("displays generic error on unexpected failure", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockRejectedValue(new Error("Network error"));
+
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to delete note type. Please try again.",
+ );
+ });
+ });
+
+ it("displays error when not authenticated", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+
+ render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Not authenticated",
+ );
+ });
+ });
+
+ it("clears error when modal is closed", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "Some error" }),
+ });
+
+ const { rerender } = render(
+ <DeleteNoteTypeModal {...defaultProps} onClose={onClose} />,
+ );
+
+ // Trigger error
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeDefined();
+ });
+
+ // Close and reopen the modal
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ rerender(<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ // Error should be cleared
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("displays noteType name correctly when changed", () => {
+ const { rerender } = render(<DeleteNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByText("Basic")).toBeDefined();
+
+ const newNoteType = { id: "note-type-456", name: "Another Note Type" };
+ rerender(<DeleteNoteTypeModal {...defaultProps} noteType={newNoteType} />);
+
+ expect(screen.getByText("Another Note Type")).toBeDefined();
+ });
+});
diff --git a/src/client/components/DeleteNoteTypeModal.tsx b/src/client/components/DeleteNoteTypeModal.tsx
new file mode 100644
index 0000000..bd6b4a5
--- /dev/null
+++ b/src/client/components/DeleteNoteTypeModal.tsx
@@ -0,0 +1,156 @@
+import { useState } from "react";
+import { ApiClientError, apiClient } from "../api";
+
+interface NoteType {
+ id: string;
+ name: string;
+}
+
+interface DeleteNoteTypeModalProps {
+ isOpen: boolean;
+ noteType: NoteType | null;
+ onClose: () => void;
+ onNoteTypeDeleted: () => void;
+}
+
+export function DeleteNoteTypeModal({
+ isOpen,
+ noteType,
+ onClose,
+ onNoteTypeDeleted,
+}: DeleteNoteTypeModalProps) {
+ const [error, setError] = useState<string | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const handleClose = () => {
+ setError(null);
+ onClose();
+ };
+
+ const handleDelete = async () => {
+ if (!noteType) return;
+
+ setError(null);
+ setIsDeleting(true);
+
+ try {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new ApiClientError("Not authenticated", 401);
+ }
+
+ const res = await fetch(`/api/note-types/${noteType.id}`, {
+ method: "DELETE",
+ headers: authHeader,
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ onNoteTypeDeleted();
+ onClose();
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to delete note type. Please try again.");
+ }
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ if (!isOpen || !noteType) {
+ return null;
+ }
+
+ return (
+ <div
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="delete-note-type-title"
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
+ onClick={(e) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ handleClose();
+ }
+ }}
+ >
+ <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>
+
+ <h2
+ id="delete-note-type-title"
+ className="font-display text-xl font-medium text-ink text-center mb-2"
+ >
+ Delete Note Type
+ </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">{noteType.name}</span>?
+ </p>
+ <p className="text-muted text-sm text-center mb-6">
+ Note types with existing notes cannot be deleted. Remove all notes
+ using this type first.
+ </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/EditNoteTypeModal.test.tsx b/src/client/components/EditNoteTypeModal.test.tsx
new file mode 100644
index 0000000..c8064bd
--- /dev/null
+++ b/src/client/components/EditNoteTypeModal.test.tsx
@@ -0,0 +1,368 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { apiClient } from "../api/client";
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ getAuthHeader: vi.fn(),
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+// Import after mock is set up
+import { EditNoteTypeModal } from "./EditNoteTypeModal";
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+describe("EditNoteTypeModal", () => {
+ const mockNoteType = {
+ id: "note-type-123",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ };
+
+ const defaultProps = {
+ isOpen: true,
+ noteType: mockNoteType,
+ onClose: vi.fn(),
+ onNoteTypeUpdated: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("does not render when closed", () => {
+ render(<EditNoteTypeModal {...defaultProps} isOpen={false} />);
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("does not render when noteType is null", () => {
+ render(<EditNoteTypeModal {...defaultProps} noteType={null} />);
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("renders modal when open with noteType", () => {
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Edit Note Type" }),
+ ).toBeDefined();
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ expect(screen.getByLabelText("Front Template")).toBeDefined();
+ expect(screen.getByLabelText("Back Template")).toBeDefined();
+ expect(screen.getByLabelText("Create reversed cards")).toBeDefined();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined();
+ });
+
+ it("populates form with noteType data", () => {
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+ expect(screen.getByLabelText("Front Template")).toHaveProperty(
+ "value",
+ "{{Front}}",
+ );
+ expect(screen.getByLabelText("Back Template")).toHaveProperty(
+ "value",
+ "{{Back}}",
+ );
+ expect(screen.getByLabelText("Create reversed cards")).toHaveProperty(
+ "checked",
+ false,
+ );
+ });
+
+ it("populates form with reversible noteType", () => {
+ const reversibleNoteType = {
+ ...mockNoteType,
+ isReversible: true,
+ };
+
+ render(
+ <EditNoteTypeModal {...defaultProps} noteType={reversibleNoteType} />,
+ );
+
+ expect(screen.getByLabelText("Create reversed cards")).toHaveProperty(
+ "checked",
+ true,
+ );
+ });
+
+ it("disables save button when name is empty", async () => {
+ const user = userEvent.setup();
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ const nameInput = screen.getByLabelText("Name");
+ await user.clear(nameInput);
+
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
+ expect(saveButton).toHaveProperty("disabled", true);
+ });
+
+ it("calls onClose when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<EditNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when clicking outside the modal", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<EditNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ const dialog = screen.getByRole("dialog");
+ await user.click(dialog);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("updates noteType when Save Changes is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ const onNoteTypeUpdated = vi.fn();
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ noteType: {
+ ...mockNoteType,
+ name: "Updated Basic",
+ isReversible: true,
+ },
+ }),
+ });
+
+ render(
+ <EditNoteTypeModal
+ isOpen={true}
+ noteType={mockNoteType}
+ onClose={onClose}
+ onNoteTypeUpdated={onNoteTypeUpdated}
+ />,
+ );
+
+ // Update fields
+ const nameInput = screen.getByLabelText("Name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Updated Basic");
+ await user.click(screen.getByLabelText("Create reversed cards"));
+
+ // Save
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ "/api/note-types/note-type-123",
+ {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer access-token",
+ },
+ body: JSON.stringify({
+ name: "Updated Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: true,
+ }),
+ },
+ );
+ });
+
+ expect(onNoteTypeUpdated).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("trims whitespace from text fields", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteType }),
+ });
+
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ const nameInput = screen.getByLabelText("Name");
+ await user.clear(nameInput);
+ await user.type(nameInput, " Updated Basic ");
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ "/api/note-types/note-type-123",
+ expect.objectContaining({
+ body: expect.stringContaining('"name":"Updated Basic"'),
+ }),
+ );
+ });
+ });
+
+ it("shows loading state during submission", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ expect(screen.getByLabelText("Name")).toHaveProperty("disabled", true);
+ });
+
+ it("displays API error message", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "Note type not found" }),
+ });
+
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Note type not found",
+ );
+ });
+ });
+
+ it("displays generic error on unexpected failure", async () => {
+ const user = userEvent.setup();
+
+ mockFetch.mockRejectedValue(new Error("Network error"));
+
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to update note type. Please try again.",
+ );
+ });
+ });
+
+ it("displays error when not authenticated", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+
+ render(<EditNoteTypeModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Not authenticated",
+ );
+ });
+ });
+
+ it("updates form when noteType prop changes", () => {
+ const { rerender } = render(<EditNoteTypeModal {...defaultProps} />);
+
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+
+ const newNoteType = {
+ id: "note-type-456",
+ name: "Another Note Type",
+ frontTemplate: "Q: {{Front}}",
+ backTemplate: "A: {{Back}}",
+ isReversible: true,
+ };
+
+ rerender(<EditNoteTypeModal {...defaultProps} noteType={newNoteType} />);
+
+ expect(screen.getByLabelText("Name")).toHaveProperty(
+ "value",
+ "Another Note Type",
+ );
+ expect(screen.getByLabelText("Front Template")).toHaveProperty(
+ "value",
+ "Q: {{Front}}",
+ );
+ expect(screen.getByLabelText("Back Template")).toHaveProperty(
+ "value",
+ "A: {{Back}}",
+ );
+ expect(screen.getByLabelText("Create reversed cards")).toHaveProperty(
+ "checked",
+ true,
+ );
+ });
+
+ it("clears error when modal is closed", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "Some error" }),
+ });
+
+ const { rerender } = render(
+ <EditNoteTypeModal {...defaultProps} onClose={onClose} />,
+ );
+
+ // Trigger error
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeDefined();
+ });
+
+ // Close and reopen the modal
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ rerender(<EditNoteTypeModal {...defaultProps} onClose={onClose} />);
+
+ // Error should be cleared
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+});
diff --git a/src/client/components/EditNoteTypeModal.tsx b/src/client/components/EditNoteTypeModal.tsx
new file mode 100644
index 0000000..b5d453d
--- /dev/null
+++ b/src/client/components/EditNoteTypeModal.tsx
@@ -0,0 +1,239 @@
+import { type FormEvent, useEffect, useState } from "react";
+import { ApiClientError, apiClient } from "../api";
+
+interface NoteType {
+ id: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+}
+
+interface EditNoteTypeModalProps {
+ isOpen: boolean;
+ noteType: NoteType | null;
+ onClose: () => void;
+ onNoteTypeUpdated: () => void;
+}
+
+export function EditNoteTypeModal({
+ isOpen,
+ noteType,
+ onClose,
+ onNoteTypeUpdated,
+}: EditNoteTypeModalProps) {
+ const [name, setName] = useState("");
+ const [frontTemplate, setFrontTemplate] = useState("");
+ const [backTemplate, setBackTemplate] = useState("");
+ const [isReversible, setIsReversible] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Sync form state when noteType changes
+ useEffect(() => {
+ if (noteType) {
+ setName(noteType.name);
+ setFrontTemplate(noteType.frontTemplate);
+ setBackTemplate(noteType.backTemplate);
+ setIsReversible(noteType.isReversible);
+ setError(null);
+ }
+ }, [noteType]);
+
+ const handleClose = () => {
+ setError(null);
+ onClose();
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!noteType) return;
+
+ setError(null);
+ setIsSubmitting(true);
+
+ try {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new ApiClientError("Not authenticated", 401);
+ }
+
+ const res = await fetch(`/api/note-types/${noteType.id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ ...authHeader,
+ },
+ body: JSON.stringify({
+ name: name.trim(),
+ frontTemplate: frontTemplate.trim(),
+ backTemplate: backTemplate.trim(),
+ isReversible,
+ }),
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ onNoteTypeUpdated();
+ onClose();
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to update note type. Please try again.");
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isOpen || !noteType) {
+ return null;
+ }
+
+ return (
+ <div
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="edit-note-type-title"
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
+ onClick={(e) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ handleClose();
+ }
+ }}
+ >
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="edit-note-type-title"
+ className="font-display text-xl font-medium text-ink mb-6"
+ >
+ Edit Note Type
+ </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-note-type-name"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Name
+ </label>
+ <input
+ id="edit-note-type-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-front-template"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Front Template
+ </label>
+ <input
+ id="edit-front-template"
+ type="text"
+ value={frontTemplate}
+ onChange={(e) => setFrontTemplate(e.target.value)}
+ required
+ maxLength={1000}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+ <p className="text-muted text-xs mt-1">
+ Use {"{{FieldName}}"} to insert field values
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="edit-back-template"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Back Template
+ </label>
+ <input
+ id="edit-back-template"
+ type="text"
+ value={backTemplate}
+ onChange={(e) => setBackTemplate(e.target.value)}
+ required
+ maxLength={1000}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm 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 className="flex items-center gap-3">
+ <input
+ id="edit-is-reversible"
+ type="checkbox"
+ checked={isReversible}
+ onChange={(e) => setIsReversible(e.target.checked)}
+ disabled={isSubmitting}
+ className="w-4 h-4 text-primary bg-ivory border-border rounded focus:ring-primary/20 focus:ring-2 disabled:opacity-50"
+ />
+ <label
+ htmlFor="edit-is-reversible"
+ className="text-sm font-medium text-slate"
+ >
+ Create reversed cards
+ </label>
+ </div>
+ <p className="text-muted text-xs -mt-2 ml-7">
+ Only affects new notes; existing cards are not modified
+ </p>
+
+ <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/pages/HomePage.tsx b/src/client/pages/HomePage.tsx
index b7b2c29..9b9f2de 100644
--- a/src/client/pages/HomePage.tsx
+++ b/src/client/pages/HomePage.tsx
@@ -1,5 +1,6 @@
import {
faBoxOpen,
+ faLayerGroup,
faPen,
faPlus,
faSpinner,
@@ -80,6 +81,18 @@ export function HomePage() {
<div className="flex items-center gap-3">
<SyncStatusIndicator />
<SyncButton />
+ <Link
+ href="/note-types"
+ className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+ title="Manage Note Types"
+ >
+ <FontAwesomeIcon
+ icon={faLayerGroup}
+ className="w-4 h-4"
+ aria-hidden="true"
+ />
+ <span className="sr-only">Manage Note Types</span>
+ </Link>
<button
type="button"
onClick={logout}
diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx
new file mode 100644
index 0000000..0cf8615
--- /dev/null
+++ b/src/client/pages/NoteTypesPage.test.tsx
@@ -0,0 +1,551 @@
+/**
+ * @vitest-environment jsdom
+ */
+import "fake-indexeddb/auto";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { apiClient } from "../api/client";
+import { AuthProvider, SyncProvider } from "../stores";
+import { NoteTypesPage } from "./NoteTypesPage";
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAuthenticated: vi.fn(),
+ getTokens: vi.fn(),
+ getAuthHeader: vi.fn(),
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+const mockNoteTypes = [
+ {
+ id: "note-type-1",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:00Z",
+ },
+ {
+ id: "note-type-2",
+ name: "Basic (and reversed card)",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: true,
+ createdAt: "2024-01-02T00:00:00Z",
+ updatedAt: "2024-01-02T00:00:00Z",
+ },
+];
+
+function renderWithProviders(path = "/note-types") {
+ const { hook } = memoryLocation({ path });
+ return render(
+ <Router hook={hook}>
+ <AuthProvider>
+ <SyncProvider>
+ <NoteTypesPage />
+ </SyncProvider>
+ </AuthProvider>
+ </Router>,
+ );
+}
+
+describe("NoteTypesPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getTokens).mockReturnValue({
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ });
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("renders page title and back button", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: [] }),
+ });
+
+ renderWithProviders();
+
+ expect(
+ screen.getByRole("heading", { name: "Note Types" }),
+ ).toBeDefined();
+ expect(screen.getByRole("link", { name: "Back to Home" })).toBeDefined();
+ });
+
+ it("shows loading state while fetching note types", async () => {
+ mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+
+ renderWithProviders();
+
+ // Loading state shows spinner (svg with animate-spin class)
+ expect(document.querySelector(".animate-spin")).toBeDefined();
+ });
+
+ it("displays empty state when no note types exist", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: [] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("No note types yet")).toBeDefined();
+ });
+ expect(
+ screen.getByText(
+ "Create a note type to define how your cards are structured",
+ ),
+ ).toBeDefined();
+ });
+
+ it("displays list of note types", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Basic" }),
+ ).toBeDefined();
+ });
+ expect(
+ screen.getByRole("heading", { name: "Basic (and reversed card)" }),
+ ).toBeDefined();
+ });
+
+ it("displays reversible badge for reversible note types", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Basic (and reversed card)" }),
+ ).toBeDefined();
+ });
+
+ expect(screen.getByText("Reversible")).toBeDefined();
+ });
+
+ it("displays template info for each note type", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+
+ expect(screen.getAllByText("Front: {{Front}}").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Back: {{Back}}").length).toBeGreaterThan(0);
+ });
+
+ it("displays error on API failure", async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Internal server error" }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Internal server error",
+ );
+ });
+ });
+
+ it("displays generic error on unexpected failure", async () => {
+ mockFetch.mockRejectedValue(new Error("Network error"));
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to load note types. Please try again.",
+ );
+ });
+ });
+
+ it("allows retry after error", async () => {
+ const user = userEvent.setup();
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Server error" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeDefined();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Retry" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+ });
+
+ it("passes auth header when fetching note types", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: [] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith("/api/note-types", {
+ headers: { Authorization: "Bearer access-token" },
+ });
+ });
+ });
+
+ describe("Create Note Type", () => {
+ it("shows New Note Type button", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: [] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("No note types yet")).toBeDefined();
+ });
+
+ expect(
+ screen.getByRole("button", { name: /New Note Type/i }),
+ ).toBeDefined();
+ });
+
+ it("opens modal when New Note Type button is clicked", async () => {
+ const user = userEvent.setup();
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: [] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("No note types yet")).toBeDefined();
+ });
+
+ await user.click(
+ screen.getByRole("button", { name: /New Note Type/i }),
+ );
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Create Note Type" }),
+ ).toBeDefined();
+ });
+
+ it("creates note type and refreshes list", async () => {
+ const user = userEvent.setup();
+ const newNoteType = {
+ id: "note-type-new",
+ name: "New Note Type",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: "2024-01-03T00:00:00Z",
+ updatedAt: "2024-01-03T00:00:00Z",
+ };
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: [] }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: newNoteType }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: [newNoteType] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("No note types yet")).toBeDefined();
+ });
+
+ // Open modal
+ await user.click(
+ screen.getByRole("button", { name: /New Note Type/i }),
+ );
+
+ // Fill in form
+ await user.type(screen.getByLabelText("Name"), "New Note Type");
+
+ // Submit
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ // Modal should close
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ // Note type list should be refreshed with new note type
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "New Note Type" }),
+ ).toBeDefined();
+ });
+ });
+ });
+
+ describe("Edit Note Type", () => {
+ it("shows Edit button for each note type", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+
+ const editButtons = screen.getAllByRole("button", {
+ name: "Edit note type",
+ });
+ expect(editButtons.length).toBe(2);
+ });
+
+ it("opens edit modal when Edit button is clicked", async () => {
+ const user = userEvent.setup();
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+
+ const editButtons = screen.getAllByRole("button", {
+ name: "Edit note type",
+ });
+ await user.click(editButtons.at(0) as HTMLElement);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Edit Note Type" }),
+ ).toBeDefined();
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+ });
+
+ it("edits note type and refreshes list", async () => {
+ const user = userEvent.setup();
+ const updatedNoteType = {
+ ...mockNoteTypes[0],
+ name: "Updated Basic",
+ };
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: updatedNoteType }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: [updatedNoteType, mockNoteTypes[1]] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+
+ // Click Edit on first note type
+ const editButtons = screen.getAllByRole("button", {
+ name: "Edit note type",
+ });
+ await user.click(editButtons.at(0) as HTMLElement);
+
+ // Update name
+ const nameInput = screen.getByLabelText("Name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Updated Basic");
+
+ // Save
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ // Modal should close
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ // Note type list should be refreshed with updated name
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Updated Basic" }),
+ ).toBeDefined();
+ });
+ });
+ });
+
+ describe("Delete Note Type", () => {
+ it("shows Delete button for each note type", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note type",
+ });
+ expect(deleteButtons.length).toBe(2);
+ });
+
+ it("opens delete modal when Delete button is clicked", async () => {
+ const user = userEvent.setup();
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note type",
+ });
+ await user.click(deleteButtons.at(0) as HTMLElement);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Delete Note Type" }),
+ ).toBeDefined();
+ const dialog = screen.getByRole("dialog");
+ expect(dialog.textContent).toContain("Basic");
+ });
+
+ it("deletes note type and refreshes list", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: [mockNoteTypes[1]] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
+ });
+
+ // Click Delete on first note type
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note type",
+ });
+ await user.click(deleteButtons.at(0) as HTMLElement);
+
+ // Wait for modal to appear
+ await waitFor(() => {
+ expect(screen.getByRole("dialog")).toBeDefined();
+ });
+
+ // Confirm deletion
+ const dialog = screen.getByRole("dialog");
+ const dialogButtons = dialog.querySelectorAll("button");
+ const deleteButton = Array.from(dialogButtons).find(
+ (btn) => btn.textContent === "Delete",
+ );
+ await user.click(deleteButton as HTMLElement);
+
+ // Modal should close
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ // Note type list should be refreshed without deleted note type
+ await waitFor(() => {
+ expect(
+ screen.queryByRole("heading", { name: "Basic" }),
+ ).toBeNull();
+ });
+ expect(
+ screen.getByRole("heading", { name: "Basic (and reversed card)" }),
+ ).toBeDefined();
+ });
+ });
+});
diff --git a/src/client/pages/NoteTypesPage.tsx b/src/client/pages/NoteTypesPage.tsx
new file mode 100644
index 0000000..0a34f5b
--- /dev/null
+++ b/src/client/pages/NoteTypesPage.tsx
@@ -0,0 +1,272 @@
+import {
+ faArrowLeft,
+ faBoxOpen,
+ faLayerGroup,
+ faPen,
+ faPlus,
+ faSpinner,
+ faTrash,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useCallback, useEffect, useState } from "react";
+import { Link } from "wouter";
+import { ApiClientError, apiClient } from "../api";
+import { CreateNoteTypeModal } from "../components/CreateNoteTypeModal";
+import { DeleteNoteTypeModal } from "../components/DeleteNoteTypeModal";
+import { EditNoteTypeModal } from "../components/EditNoteTypeModal";
+
+interface NoteType {
+ id: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export function NoteTypesPage() {
+ const [noteTypes, setNoteTypes] = useState<NoteType[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [editingNoteType, setEditingNoteType] = useState<NoteType | null>(null);
+ const [deletingNoteType, setDeletingNoteType] = useState<NoteType | null>(
+ null,
+ );
+
+ const fetchNoteTypes = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new ApiClientError("Not authenticated", 401);
+ }
+
+ const res = await fetch("/api/note-types", {
+ headers: authHeader,
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ const data = await res.json();
+ setNoteTypes(data.noteTypes);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to load note types. Please try again.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchNoteTypes();
+ }, [fetchNoteTypes]);
+
+ return (
+ <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">
+ <div className="flex items-center gap-3">
+ <Link
+ href="/"
+ className="p-2 -ml-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+ >
+ <FontAwesomeIcon
+ icon={faArrowLeft}
+ className="w-4 h-4"
+ aria-hidden="true"
+ />
+ <span className="sr-only">Back to Home</span>
+ </Link>
+ <h1 className="font-display text-2xl font-semibold text-ink">
+ Note Types
+ </h1>
+ </div>
+ </div>
+ </header>
+
+ {/* Main Content */}
+ <main className="max-w-4xl mx-auto px-4 py-8">
+ {/* Section Header */}
+ <div className="flex items-center justify-between mb-6">
+ <p className="text-muted text-sm">
+ Note types define how your cards are structured
+ </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 active:scale-[0.98] shadow-sm hover:shadow-md"
+ >
+ <FontAwesomeIcon
+ icon={faPlus}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ New Note Type
+ </button>
+ </div>
+
+ {/* Loading State */}
+ {isLoading && (
+ <div className="flex items-center justify-center py-12">
+ <FontAwesomeIcon
+ icon={faSpinner}
+ className="h-8 w-8 text-primary animate-spin"
+ aria-hidden="true"
+ />
+ </div>
+ )}
+
+ {/* Error State */}
+ {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={fetchNoteTypes}
+ className="text-error hover:text-error/80 font-medium text-sm"
+ >
+ Retry
+ </button>
+ </div>
+ )}
+
+ {/* Empty State */}
+ {!isLoading && !error && noteTypes.length === 0 && (
+ <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">
+ <FontAwesomeIcon
+ icon={faBoxOpen}
+ className="w-8 h-8 text-muted"
+ aria-hidden="true"
+ />
+ </div>
+ <h3 className="font-display text-lg font-medium text-slate mb-2">
+ No note types yet
+ </h3>
+ <p className="text-muted text-sm mb-6">
+ Create a note type to define how your cards are structured
+ </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"
+ >
+ <FontAwesomeIcon
+ icon={faPlus}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ Create Your First Note Type
+ </button>
+ </div>
+ )}
+
+ {/* Note Type List */}
+ {!isLoading && !error && noteTypes.length > 0 && (
+ <div className="space-y-3 animate-fade-in">
+ {noteTypes.map((noteType, index) => (
+ <div
+ key={noteType.id}
+ 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 className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1">
+ <FontAwesomeIcon
+ icon={faLayerGroup}
+ className="w-4 h-4 text-muted"
+ aria-hidden="true"
+ />
+ <h3 className="font-display text-lg font-medium text-slate truncate">
+ {noteType.name}
+ </h3>
+ </div>
+ <div className="flex flex-wrap gap-2 mt-2">
+ <span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-ivory text-muted">
+ Front: {noteType.frontTemplate}
+ </span>
+ <span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-ivory text-muted">
+ Back: {noteType.backTemplate}
+ </span>
+ {noteType.isReversible && (
+ <span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary">
+ Reversible
+ </span>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2 shrink-0">
+ <button
+ type="button"
+ onClick={() => setEditingNoteType(noteType)}
+ className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+ title="Edit note type"
+ >
+ <FontAwesomeIcon
+ icon={faPen}
+ className="w-4 h-4"
+ aria-hidden="true"
+ />
+ </button>
+ <button
+ type="button"
+ onClick={() => setDeletingNoteType(noteType)}
+ className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
+ title="Delete note type"
+ >
+ <FontAwesomeIcon
+ icon={faTrash}
+ className="w-4 h-4"
+ aria-hidden="true"
+ />
+ </button>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </main>
+
+ {/* Modals */}
+ <CreateNoteTypeModal
+ isOpen={isCreateModalOpen}
+ onClose={() => setIsCreateModalOpen(false)}
+ onNoteTypeCreated={fetchNoteTypes}
+ />
+
+ <EditNoteTypeModal
+ isOpen={editingNoteType !== null}
+ noteType={editingNoteType}
+ onClose={() => setEditingNoteType(null)}
+ onNoteTypeUpdated={fetchNoteTypes}
+ />
+
+ <DeleteNoteTypeModal
+ isOpen={deletingNoteType !== null}
+ noteType={deletingNoteType}
+ onClose={() => setDeletingNoteType(null)}
+ onNoteTypeDeleted={fetchNoteTypes}
+ />
+ </div>
+ );
+}
diff --git a/src/client/pages/index.ts b/src/client/pages/index.ts
index 3fb507a..597ea39 100644
--- a/src/client/pages/index.ts
+++ b/src/client/pages/index.ts
@@ -1,5 +1,6 @@
export { DeckDetailPage } from "./DeckDetailPage";
export { HomePage } from "./HomePage";
export { LoginPage } from "./LoginPage";
+export { NoteTypesPage } from "./NoteTypesPage";
export { NotFoundPage } from "./NotFoundPage";
export { StudyPage } from "./StudyPage";