feat(frontend): zustand stores for settings, lessons, session
This commit is contained in:
@@ -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(
|
||||
<React.StrictMode>
|
||||
|
||||
19
packages/frontend/src/stores/lessonsStore.ts
Normal file
19
packages/frontend/src/stores/lessonsStore.ts
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
export const useLessons = create<LessonsState>((set) => ({
|
||||
tree: [],
|
||||
loading: false,
|
||||
refresh: async () => {
|
||||
set({ loading: true });
|
||||
try { set({ tree: await lessonsApi.tree() }); }
|
||||
finally { set({ loading: false }); }
|
||||
},
|
||||
}));
|
||||
66
packages/frontend/src/stores/sessionStore.ts
Normal file
66
packages/frontend/src/stores/sessionStore.ts
Normal file
@@ -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<void>;
|
||||
reveal: () => void;
|
||||
answer: (result: 'correct' | 'incorrect') => Promise<void>;
|
||||
end: () => Promise<void>;
|
||||
abandon: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSession = create<SessionState>((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 }),
|
||||
}));
|
||||
35
packages/frontend/src/stores/settingsStore.ts
Normal file
35
packages/frontend/src/stores/settingsStore.ts
Normal file
@@ -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<SettingsState>((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 */ }
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user