aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-14 23:05:43 +0900
committernsfisis <nsfisis@gmail.com>2026-02-14 23:05:43 +0900
commitc1cb543875edee1aa9cc160a78b610e06ea139e7 (patch)
tree9cb5254c8f874e985b1bfd1a5cb5ab6fb89efb93 /src/client/pages
parent8883b0beb78b794d74fd5a1dad641b687b308dbd (diff)
downloadkioku-c1cb543875edee1aa9cc160a78b610e06ea139e7.tar.gz
kioku-c1cb543875edee1aa9cc160a78b610e06ea139e7.tar.zst
kioku-c1cb543875edee1aa9cc160a78b610e06ea139e7.zip
feat(study): add edit button to study session cards
Allow editing note content directly from the study screen via a pen icon button or the E key shortcut. Keyboard shortcuts are disabled while the edit modal is open to prevent accidental card flips/ratings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages')
-rw-r--r--src/client/pages/StudyPage.test.tsx221
-rw-r--r--src/client/pages/StudyPage.tsx59
2 files changed, 278 insertions, 2 deletions
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx
index 9c48083..860777a 100644
--- a/src/client/pages/StudyPage.test.tsx
+++ b/src/client/pages/StudyPage.test.tsx
@@ -27,6 +27,49 @@ vi.mock("../utils/shuffle", () => ({
shuffle: <T,>(array: T[]): T[] => [...array],
}));
+const mockEditNoteModalOnClose = vi.fn();
+const mockEditNoteModalOnNoteUpdated = vi.fn();
+
+vi.mock("../components/EditNoteModal", () => ({
+ EditNoteModal: ({
+ isOpen,
+ deckId,
+ noteId,
+ onClose,
+ onNoteUpdated,
+ }: {
+ isOpen: boolean;
+ deckId: string;
+ noteId: string | null;
+ onClose: () => void;
+ onNoteUpdated: () => void;
+ }) => {
+ // Store callbacks so tests can call them
+ mockEditNoteModalOnClose.mockImplementation(onClose);
+ mockEditNoteModalOnNoteUpdated.mockImplementation(onNoteUpdated);
+
+ if (!isOpen) return null;
+ return (
+ <div
+ data-testid="edit-note-modal"
+ data-deck-id={deckId}
+ data-note-id={noteId}
+ >
+ <button type="button" data-testid="edit-modal-close" onClick={onClose}>
+ Cancel
+ </button>
+ <button
+ type="button"
+ data-testid="edit-modal-save"
+ onClick={onNoteUpdated}
+ >
+ Save Changes
+ </button>
+ </div>
+ );
+ },
+}));
+
vi.mock("../api/client", () => ({
apiClient: {
login: vi.fn(),
@@ -935,4 +978,182 @@ describe("StudyPage", () => {
);
});
});
+
+ describe("Edit Card", () => {
+ it("shows edit button on card", () => {
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ expect(screen.getByTestId("edit-card-button")).toBeDefined();
+ });
+
+ it("opens edit modal when edit button is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ expect(screen.queryByTestId("edit-note-modal")).toBeNull();
+
+ await user.click(screen.getByTestId("edit-card-button"));
+
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+ expect(
+ screen.getByTestId("edit-note-modal").getAttribute("data-note-id"),
+ ).toBe("note-1");
+ expect(
+ screen.getByTestId("edit-note-modal").getAttribute("data-deck-id"),
+ ).toBe("deck-1");
+ });
+
+ it("does not flip card when edit button is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ await user.click(screen.getByTestId("edit-card-button"));
+
+ // Card should still show front, not back
+ expect(screen.getByTestId("card-front")).toBeDefined();
+ expect(screen.queryByTestId("card-back")).toBeNull();
+ });
+
+ it("closes edit modal when close button is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ await user.click(screen.getByTestId("edit-card-button"));
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+
+ await user.click(screen.getByTestId("edit-modal-close"));
+
+ expect(screen.queryByTestId("edit-note-modal")).toBeNull();
+ });
+
+ it("closes edit modal when save button is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ await user.click(screen.getByTestId("edit-card-button"));
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+
+ await user.click(screen.getByTestId("edit-modal-save"));
+
+ expect(screen.queryByTestId("edit-note-modal")).toBeNull();
+ });
+
+ it("opens edit modal with E key", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ await user.keyboard("e");
+
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+ expect(
+ screen.getByTestId("edit-note-modal").getAttribute("data-note-id"),
+ ).toBe("note-1");
+ });
+
+ it("opens edit modal with E key when card is flipped", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ // Flip card first
+ await user.keyboard(" ");
+ expect(screen.getByTestId("card-back")).toBeDefined();
+
+ await user.keyboard("e");
+
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+ });
+
+ it("disables keyboard shortcuts while edit modal is open", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ // Open edit modal
+ await user.keyboard("e");
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+
+ // Space should not flip the card
+ await user.keyboard(" ");
+ expect(screen.getByTestId("card-front")).toBeDefined();
+ expect(screen.queryByTestId("card-back")).toBeNull();
+ });
+
+ it("disables rating shortcuts while edit modal is open", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ // Flip card, then open edit modal via E key
+ await user.keyboard(" ");
+ expect(screen.getByTestId("card-back")).toBeDefined();
+
+ await user.keyboard("e");
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+
+ // Number keys should not rate the card
+ await user.keyboard("3");
+
+ // Card should still be showing (not moved to next)
+ expect(screen.getByTestId("card-back")).toBeDefined();
+ expect(screen.getByTestId("remaining-count").textContent).toBe(
+ "2 remaining",
+ );
+ });
+
+ it("re-enables keyboard shortcuts after edit modal is closed", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ // Open and close edit modal
+ await user.keyboard("e");
+ expect(screen.getByTestId("edit-note-modal")).toBeDefined();
+
+ await user.click(screen.getByTestId("edit-modal-close"));
+ expect(screen.queryByTestId("edit-note-modal")).toBeNull();
+
+ // Space should now flip the card
+ await user.keyboard(" ");
+ expect(screen.getByTestId("card-back")).toBeDefined();
+ });
+
+ it("shows edit button when card is flipped", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialStudyData: { deck: mockDeck, cards: mockDueCards },
+ });
+
+ await user.click(screen.getByTestId("card-container"));
+
+ expect(screen.getByTestId("card-back")).toBeDefined();
+ expect(screen.getByTestId("edit-card-button")).toBeDefined();
+ });
+ });
});
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx
index 33fd290..c8eb603 100644
--- a/src/client/pages/StudyPage.tsx
+++ b/src/client/pages/StudyPage.tsx
@@ -2,6 +2,7 @@ import {
faCheck,
faChevronLeft,
faCircleCheck,
+ faPen,
faRotateLeft,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -17,6 +18,7 @@ import {
import { Link, useLocation, useParams } from "wouter";
import { ApiClientError, apiClient } from "../api";
import { studyDataAtomFamily } from "../atoms";
+import { EditNoteModal } from "../components/EditNoteModal";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { queryClient } from "../queryClient";
import { renderCard } from "../utils/templateRenderer";
@@ -60,6 +62,7 @@ function StudySession({
null,
);
const pendingReviewRef = useRef<PendingReview | null>(null);
+ const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
// Keep ref in sync with state for cleanup effect
useEffect(() => {
@@ -174,6 +177,14 @@ function StudySession({
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isSubmitting) return;
+ if (editingNoteId) return;
+
+ // Edit: E key to open edit modal
+ if (e.key === "e" && cards[currentIndex]) {
+ e.preventDefault();
+ setEditingNoteId(cards[currentIndex].noteId);
+ return;
+ }
// Undo: Ctrl+Z / Cmd+Z anytime, or z when card is not flipped
if (
@@ -206,7 +217,16 @@ function StudySession({
}
}
},
- [isFlipped, isSubmitting, handleFlip, handleRating, handleUndo],
+ [
+ isFlipped,
+ isSubmitting,
+ editingNoteId,
+ cards,
+ currentIndex,
+ handleFlip,
+ handleRating,
+ handleUndo,
+ ],
);
useEffect(() => {
@@ -395,11 +415,37 @@ function StudySession({
: "bg-ivory/50"
}`}
>
+ {/* Edit button */}
+ {/* biome-ignore lint/a11y/useSemanticElements: Cannot nest <button> inside parent <button>, using span with role="button" instead */}
+ <span
+ role="button"
+ tabIndex={0}
+ data-testid="edit-card-button"
+ onClick={(e) => {
+ e.stopPropagation();
+ setEditingNoteId(currentCard.noteId);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.stopPropagation();
+ e.preventDefault();
+ setEditingNoteId(currentCard.noteId);
+ }
+ }}
+ className="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-muted hover:text-slate transition-colors rounded-lg hover:bg-ivory"
+ aria-label="Edit card"
+ >
+ <FontAwesomeIcon
+ icon={faPen}
+ className="w-3.5 h-3.5"
+ aria-hidden="true"
+ />
+ </span>
{/* New card badge */}
{currentCard.state === 0 && (
<span
data-testid="new-card-badge"
- className="absolute top-3 right-3 bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 rounded-full"
+ className="absolute top-3 right-10 bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 rounded-full"
>
New
</span>
@@ -456,6 +502,15 @@ function StudySession({
)}
</div>
)}
+
+ {/* Edit Note Modal */}
+ <EditNoteModal
+ isOpen={!!editingNoteId}
+ deckId={deckId}
+ noteId={editingNoteId}
+ onClose={() => setEditingNoteId(null)}
+ onNoteUpdated={() => setEditingNoteId(null)}
+ />
</div>
);
}