diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-01-04 17:43:59 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-01-04 19:09:58 +0900 |
| commit | f8e4be9b36a16969ac53bd9ce12ce8064be10196 (patch) | |
| tree | b2cf350d2e2e52803ff809311effb40da767d859 /src/client/pages/HomePage.tsx | |
| parent | e1c9e5e89bb91bca2586470c786510c3e1c03826 (diff) | |
| download | kioku-f8e4be9b36a16969ac53bd9ce12ce8064be10196.tar.gz kioku-f8e4be9b36a16969ac53bd9ce12ce8064be10196.tar.zst kioku-f8e4be9b36a16969ac53bd9ce12ce8064be10196.zip | |
refactor(client): migrate state management from React Context to Jotai
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages/HomePage.tsx')
| -rw-r--r-- | src/client/pages/HomePage.tsx | 265 |
1 files changed, 110 insertions, 155 deletions
diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx index ddf97e2..e0e9e9e 100644 --- a/src/client/pages/HomePage.tsx +++ b/src/client/pages/HomePage.tsx @@ -3,72 +3,121 @@ import { faLayerGroup, faPen, faPlus, - faSpinner, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { Suspense, useState, useTransition } from "react"; import { Link } from "wouter"; -import { ApiClientError, apiClient } from "../api"; +import { type Deck, decksAtom, logoutAtom } from "../atoms"; import { CreateDeckModal } from "../components/CreateDeckModal"; import { DeleteDeckModal } from "../components/DeleteDeckModal"; import { EditDeckModal } from "../components/EditDeckModal"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { LoadingSpinner } from "../components/LoadingSpinner"; import { SyncButton } from "../components/SyncButton"; import { SyncStatusIndicator } from "../components/SyncStatusIndicator"; -import { useAuth } from "../stores"; -interface Deck { - id: string; - name: string; - description: string | null; - newCardsPerDay: number; - createdAt: string; - updatedAt: string; +function DeckList({ + onEditDeck, + onDeleteDeck, +}: { + onEditDeck: (deck: Deck) => void; + onDeleteDeck: (deck: Deck) => void; +}) { + const decks = useAtomValue(decksAtom); + + if (decks.length === 0) { + return ( + <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 decks yet + </h3> + <p className="text-muted text-sm mb-6"> + Create your first deck to start learning + </p> + </div> + ); + } + + return ( + <div className="space-y-3 animate-fade-in"> + {decks.map((deck, index) => ( + <div + key={deck.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"> + <Link + href={`/decks/${deck.id}`} + className="block group-hover:text-primary transition-colors" + > + <h3 className="font-display text-lg font-medium text-slate truncate"> + {deck.name} + </h3> + </Link> + {deck.description && ( + <p className="text-muted text-sm mt-1 line-clamp-2"> + {deck.description} + </p> + )} + </div> + <div className="flex items-center gap-2 shrink-0"> + <button + type="button" + onClick={() => onEditDeck(deck)} + className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors" + title="Edit deck" + > + <FontAwesomeIcon + icon={faPen} + className="w-4 h-4" + aria-hidden="true" + /> + </button> + <button + type="button" + onClick={() => onDeleteDeck(deck)} + className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" + title="Delete deck" + > + <FontAwesomeIcon + icon={faTrash} + className="w-4 h-4" + aria-hidden="true" + /> + </button> + </div> + </div> + </div> + ))} + </div> + ); } export function HomePage() { - const { logout } = useAuth(); - const [decks, setDecks] = useState<Deck[]>([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState<string | null>(null); + const logout = useSetAtom(logoutAtom); + const reloadDecks = useSetAtom(decksAtom); + const [, startTransition] = useTransition(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [editingDeck, setEditingDeck] = useState<Deck | null>(null); const [deletingDeck, setDeletingDeck] = useState<Deck | null>(null); - const fetchDecks = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const res = await apiClient.rpc.api.decks.$get(undefined, { - headers: apiClient.getAuthHeader(), - }); - - 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(); - setDecks(data.decks); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to load decks. Please try again."); - } - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - fetchDecks(); - }, [fetchDecks]); + const handleDeckMutation = () => { + startTransition(() => { + reloadDecks(); + }); + }; return ( <div className="min-h-screen bg-cream"> @@ -95,7 +144,7 @@ export function HomePage() { </Link> <button type="button" - onClick={logout} + onClick={() => logout()} className="text-sm text-muted hover:text-slate transition-colors px-3 py-1.5 rounded-lg hover:bg-ivory" > Logout @@ -125,130 +174,36 @@ export function HomePage() { </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" + {/* Deck List with Suspense */} + <ErrorBoundary> + <Suspense fallback={<LoadingSpinner />}> + <DeckList + onEditDeck={setEditingDeck} + onDeleteDeck={setDeletingDeck} /> - </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={fetchDecks} - className="text-error hover:text-error/80 font-medium text-sm" - > - Retry - </button> - </div> - )} - - {/* Empty State */} - {!isLoading && !error && decks.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 decks yet - </h3> - <p className="text-muted text-sm mb-6"> - Create your first deck to start learning - </p> - </div> - )} - - {/* Deck List */} - {!isLoading && !error && decks.length > 0 && ( - <div className="space-y-3 animate-fade-in"> - {decks.map((deck, index) => ( - <div - key={deck.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"> - <Link - href={`/decks/${deck.id}`} - className="block group-hover:text-primary transition-colors" - > - <h3 className="font-display text-lg font-medium text-slate truncate"> - {deck.name} - </h3> - </Link> - {deck.description && ( - <p className="text-muted text-sm mt-1 line-clamp-2"> - {deck.description} - </p> - )} - </div> - <div className="flex items-center gap-2 shrink-0"> - <button - type="button" - onClick={() => setEditingDeck(deck)} - className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors" - title="Edit deck" - > - <FontAwesomeIcon - icon={faPen} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - <button - type="button" - onClick={() => setDeletingDeck(deck)} - className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" - title="Delete deck" - > - <FontAwesomeIcon - icon={faTrash} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - </div> - </div> - </div> - ))} - </div> - )} + </Suspense> + </ErrorBoundary> </main> {/* Modals */} <CreateDeckModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} - onDeckCreated={fetchDecks} + onDeckCreated={handleDeckMutation} /> <EditDeckModal isOpen={editingDeck !== null} deck={editingDeck} onClose={() => setEditingDeck(null)} - onDeckUpdated={fetchDecks} + onDeckUpdated={handleDeckMutation} /> <DeleteDeckModal isOpen={deletingDeck !== null} deck={deletingDeck} onClose={() => setDeletingDeck(null)} - onDeckDeleted={fetchDecks} + onDeckDeleted={handleDeckMutation} /> </div> ); |
