aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/pages')
-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
4 files changed, 837 insertions, 0 deletions
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";