feat(frontend): zustand stores for settings, lessons, session

This commit is contained in:
2026-05-20 21:14:20 +02:00
parent 1c977c4743
commit 1d501ee50a
4 changed files with 123 additions and 0 deletions

View File

@@ -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>

View 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 }); }
},
}));

View 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 }),
}));

View 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 */ }
},
}));