From f8e4be9b36a16969ac53bd9ce12ce8064be10196 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 4 Jan 2026 17:43:59 +0900 Subject: refactor(client): migrate state management from React Context to Jotai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace AuthProvider and SyncProvider with Jotai atoms for more granular state management and better performance. This migration: - Creates atoms for auth, sync, decks, cards, noteTypes, and study state - Uses atomFamily for parameterized state (e.g., cards by deckId) - Introduces StoreInitializer component for subscription initialization - Updates all components and pages to use useAtomValue/useSetAtom - Updates all tests to use Jotai Provider with createStore pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/pages/DeckDetailPage.tsx | 494 +++++++++++++++++------------------- 1 file changed, 228 insertions(+), 266 deletions(-) (limited to 'src/client/pages/DeckDetailPage.tsx') diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index f9b50f2..1376fab 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -6,44 +6,25 @@ import { faLayerGroup, faPen, faPlus, - faSpinner, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { Suspense, useMemo, useState, useTransition } from "react"; import { Link, useParams } from "wouter"; -import { ApiClientError, apiClient } from "../api"; +import { type Card, cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms"; import { CreateNoteModal } from "../components/CreateNoteModal"; import { DeleteCardModal } from "../components/DeleteCardModal"; import { DeleteNoteModal } from "../components/DeleteNoteModal"; import { EditCardModal } from "../components/EditCardModal"; import { EditNoteModal } from "../components/EditNoteModal"; +import { ErrorBoundary } from "../components/ErrorBoundary"; import { ImportNotesModal } from "../components/ImportNotesModal"; - -interface Card { - id: string; - deckId: string; - noteId: string; - isReversed: boolean; - front: string; - back: string; - state: number; - due: string; - reps: number; - lapses: number; - createdAt: string; - updatedAt: string; -} +import { LoadingSpinner } from "../components/LoadingSpinner"; /** Combined type for display: note group */ type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] }; -interface Deck { - id: string; - name: string; - description: string | null; -} - const CardStateLabels: Record = { 0: "New", 1: "Learning", @@ -178,18 +159,31 @@ function NoteGroupCard({ ); } -export function DeckDetailPage() { - const { deckId } = useParams<{ deckId: string }>(); - const [deck, setDeck] = useState(null); - const [cards, setCards] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [isImportModalOpen, setIsImportModalOpen] = useState(false); - const [editingCard, setEditingCard] = useState(null); - const [editingNoteId, setEditingNoteId] = useState(null); - const [deletingCard, setDeletingCard] = useState(null); - const [deletingNoteId, setDeletingNoteId] = useState(null); +function DeckHeader({ deckId }: { deckId: string }) { + const deck = useAtomValue(deckByIdAtomFamily(deckId)); + + return ( +
+

+ {deck.name} +

+ {deck.description &&

{deck.description}

} +
+ ); +} + +function CardList({ + deckId, + onEditNote, + onDeleteNote, + onCreateNote, +}: { + deckId: string; + onEditNote: (noteId: string) => void; + onDeleteNote: (noteId: string) => void; + onCreateNote: () => void; +}) { + const cards = useAtomValue(cardsByDeckAtomFamily(deckId)); // Group cards by note for display const displayItems = useMemo((): CardDisplayItem[] => { @@ -230,46 +224,153 @@ export function DeckDetailPage() { return items; }, [cards]); - const fetchDeck = useCallback(async () => { - if (!deckId) return; + if (cards.length === 0) { + return ( +
+
+
+

+ No cards yet +

+

Add notes to start studying

+ +
+ ); + } - const res = await apiClient.rpc.api.decks[":id"].$get({ - param: { id: deckId }, - }); - const data = await apiClient.handleResponse<{ deck: Deck }>(res); - setDeck(data.deck); - }, [deckId]); + return ( +
+ {displayItems.map((item, index) => ( + onEditNote(item.noteId)} + onDeleteNote={() => onDeleteNote(item.noteId)} + /> + ))} +
+ ); +} - const fetchCards = useCallback(async () => { - if (!deckId) return; +function DeckContent({ + deckId, + onCreateNote, + onImportNotes, + onEditNote, + onDeleteNote, +}: { + deckId: string; + onCreateNote: () => void; + onImportNotes: () => void; + onEditNote: (noteId: string) => void; + onDeleteNote: (noteId: string) => void; +}) { + const cards = useAtomValue(cardsByDeckAtomFamily(deckId)); - const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({ - param: { deckId }, - }); - const data = await apiClient.handleResponse<{ cards: Card[] }>(res); - setCards(data.cards); - }, [deckId]); - - const fetchData = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - await Promise.all([fetchDeck(), fetchCards()]); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to load data. Please try again."); - } - } finally { - setIsLoading(false); - } - }, [fetchDeck, fetchCards]); + return ( +
+ {/* Deck Header */} + + }> + + + + + {/* Study Button */} +
+ +
- useEffect(() => { - fetchData(); - }, [fetchData]); + {/* Cards Section */} +
+

+ Cards ({cards.length}) +

+
+ + +
+
+ + {/* Card List */} + +
+ ); +} + +export function DeckDetailPage() { + const { deckId } = useParams<{ deckId: string }>(); + const [, startTransition] = useTransition(); + + const reloadCards = useSetAtom(cardsByDeckAtomFamily(deckId || "")); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + const [editingCard, setEditingCard] = useState(null); + const [editingNoteId, setEditingNoteId] = useState(null); + const [deletingCard, setDeletingCard] = useState(null); + const [deletingNoteId, setDeletingNoteId] = useState(null); + + const handleCardMutation = () => { + startTransition(() => { + reloadCards(); + }); + }; if (!deckId) { return ( @@ -308,204 +409,65 @@ export function DeckDetailPage() { {/* Main Content */}
- {/* Loading State */} - {isLoading && ( -
-
- )} - - {/* Error State */} - {error && ( -
- {error} - -
- )} - - {/* Deck Content */} - {!isLoading && !error && deck && ( -
- {/* Deck Header */} -
-

- {deck.name} -

- {deck.description && ( -

{deck.description}

- )} -
- - {/* Study Button */} -
- -
- - {/* Cards Section */} -
-

- Cards{" "} - ({cards.length}) -

-
- - -
-
- - {/* Empty State */} - {cards.length === 0 && ( -
-
-
-

- No cards yet -

-

- Add notes to start studying -

- -
- )} - - {/* Card List - Grouped by Note */} - {cards.length > 0 && ( -
- {displayItems.map((item, index) => ( - setEditingNoteId(item.noteId)} - onDeleteNote={() => setDeletingNoteId(item.noteId)} - /> - ))} -
- )} -
- )} + +
{/* Modals */} - {deckId && ( - setIsCreateModalOpen(false)} - onNoteCreated={fetchCards} - /> - )} - - {deckId && ( - setIsImportModalOpen(false)} - onImportComplete={fetchCards} - /> - )} - - {deckId && ( - setEditingCard(null)} - onCardUpdated={fetchCards} - /> - )} - - {deckId && ( - setEditingNoteId(null)} - onNoteUpdated={fetchCards} - /> - )} - - {deckId && ( - setDeletingCard(null)} - onCardDeleted={fetchCards} - /> - )} - - {deckId && ( - setDeletingNoteId(null)} - onNoteDeleted={fetchCards} - /> - )} + setIsCreateModalOpen(false)} + onNoteCreated={handleCardMutation} + /> + + setIsImportModalOpen(false)} + onImportComplete={handleCardMutation} + /> + + setEditingCard(null)} + onCardUpdated={handleCardMutation} + /> + + setEditingNoteId(null)} + onNoteUpdated={handleCardMutation} + /> + + setDeletingCard(null)} + onCardDeleted={handleCardMutation} + /> + + setDeletingNoteId(null)} + onNoteDeleted={handleCardMutation} + /> ); } -- cgit v1.2.3-70-g09d2