aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/HomePage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/pages/HomePage.tsx')
-rw-r--r--src/client/pages/HomePage.tsx265
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>
);