diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 5b7ec43..853e39f 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -2,8 +2,11 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import { router } from './router.js'; +import { useSettings } from './stores/settingsStore.js'; import './styles.css'; +useSettings.getState().hydrate(); + const root = createRoot(document.getElementById('root')!); root.render( diff --git a/packages/frontend/src/stores/lessonsStore.ts b/packages/frontend/src/stores/lessonsStore.ts new file mode 100644 index 0000000..722e7ea --- /dev/null +++ b/packages/frontend/src/stores/lessonsStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; +import type { LessonTreeNode } from '@flashcard/shared'; +import { lessonsApi } from '../api/lessons.js'; + +interface LessonsState { + tree: LessonTreeNode[]; + loading: boolean; + refresh: () => Promise; +} + +export const useLessons = create((set) => ({ + tree: [], + loading: false, + refresh: async () => { + set({ loading: true }); + try { set({ tree: await lessonsApi.tree() }); } + finally { set({ loading: false }); } + }, +})); diff --git a/packages/frontend/src/stores/sessionStore.ts b/packages/frontend/src/stores/sessionStore.ts new file mode 100644 index 0000000..56bd5e9 --- /dev/null +++ b/packages/frontend/src/stores/sessionStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; +import type { SessionRow, QueueItem } from '@flashcard/shared'; +import { sessionsApi } from '../api/sessions.js'; + +interface SessionState { + session: SessionRow | null; + current: QueueItem | null; + done: boolean; + showAnswer: boolean; + shownAt: number | null; + start: (input: { lessonId: number; maxCards: number | null; shuffle: boolean; direction: 'forward' | 'backward' | 'both' }) => Promise; + reveal: () => void; + answer: (result: 'correct' | 'incorrect') => Promise; + end: () => Promise; + abandon: () => Promise; + reset: () => void; +} + +export const useSession = create((set, get) => ({ + session: null, current: null, done: false, showAnswer: false, shownAt: null, + + start: async (input) => { + const { session } = await sessionsApi.start(input); + const nx = await sessionsApi.next(session.id); + set({ + session, + done: nx.done, + current: nx.done ? null : nx.item, + showAnswer: false, + shownAt: Date.now(), + }); + }, + + reveal: () => set({ showAnswer: true }), + + answer: async (result) => { + const s = get(); + if (!s.session || !s.current) return; + const ttm = s.shownAt ? Date.now() - s.shownAt : null; + await sessionsApi.attempt(s.session.id, { + cardId: s.current.cardId, direction: s.current.direction, result, timeToAnswerMs: ttm, + }); + const nx = await sessionsApi.next(s.session.id); + if (nx.done) { + set({ done: true, current: null, showAnswer: false }); + } else { + set({ current: nx.item, showAnswer: false, shownAt: Date.now() }); + } + }, + + end: async () => { + const s = get(); + if (!s.session) return; + const finished = await sessionsApi.end(s.session.id); + set({ session: finished }); + }, + + abandon: async () => { + const s = get(); + if (!s.session) return; + await sessionsApi.abandon(s.session.id); + set({ session: null, current: null, done: false, showAnswer: false }); + }, + + reset: () => set({ session: null, current: null, done: false, showAnswer: false, shownAt: null }), +})); diff --git a/packages/frontend/src/stores/settingsStore.ts b/packages/frontend/src/stores/settingsStore.ts new file mode 100644 index 0000000..7436fea --- /dev/null +++ b/packages/frontend/src/stores/settingsStore.ts @@ -0,0 +1,35 @@ +import { create } from 'zustand'; + +interface SettingsState { + theme: 'light' | 'dark'; + defaultMaxCards: number; + toggleTheme: () => void; + setDefaultMaxCards: (n: number) => void; + hydrate: () => void; +} + +const KEY = 'flashcard:settings'; + +export const useSettings = create((set, get) => ({ + theme: 'light', + defaultMaxCards: 20, + toggleTheme: () => { + const theme = get().theme === 'light' ? 'dark' : 'light'; + set({ theme }); + document.documentElement.classList.toggle('dark', theme === 'dark'); + localStorage.setItem(KEY, JSON.stringify({ theme, defaultMaxCards: get().defaultMaxCards })); + }, + setDefaultMaxCards: (n) => { + set({ defaultMaxCards: n }); + localStorage.setItem(KEY, JSON.stringify({ theme: get().theme, defaultMaxCards: n })); + }, + hydrate: () => { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as { theme: 'light' | 'dark'; defaultMaxCards: number }; + set(parsed); + document.documentElement.classList.toggle('dark', parsed.theme === 'dark'); + } catch { /* ignore */ } + }, +}));