diff --git a/packages/backend/src/routes/cards.ts b/packages/backend/src/routes/cards.ts index 425eb67..c759910 100644 --- a/packages/backend/src/routes/cards.ts +++ b/packages/backend/src/routes/cards.ts @@ -3,7 +3,7 @@ import multer from 'multer'; import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared'; import type { Db } from '../db/client.js'; import { ApiError } from '../lib/errors.js'; -import { createCard, deleteCard, listCardsByLesson, updateCard } from '../services/cards.js'; +import { createCard, deleteCard, getCard, listCardsByLesson, updateCard } from '../services/cards.js'; import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.js'; export function cardsRouter(db: Db): Router { @@ -21,6 +21,10 @@ export function cardsRouter(db: Db): Router { } catch (e) { next(e); } }); + r.get('/cards/:id', async (req, res, next) => { + try { res.json(await getCard(db, Number(req.params.id))); } catch (e) { next(e); } + }); + r.patch('/cards/:id', async (req, res, next) => { try { const input = cardUpdateSchema.parse(req.body); diff --git a/packages/frontend/src/api/cards.ts b/packages/frontend/src/api/cards.ts new file mode 100644 index 0000000..a9e4cb4 --- /dev/null +++ b/packages/frontend/src/api/cards.ts @@ -0,0 +1,23 @@ +import type { Card, CardCreateInput, CardUpdateInput } from '@flashcard/shared'; +import { api } from './client.js'; + +export interface ImportResult { + inserted: number; updated: number; skipped: number; errors: { row: number; message: string }[]; +} + +export const cardsApi = { + list: (lessonId: number) => api.get(`/lessons/${lessonId}/cards`), + get: (id: number) => api.get(`/cards/${id}`), + create: (lessonId: number, input: CardCreateInput) => api.post(`/lessons/${lessonId}/cards`, input), + update: (id: number, input: CardUpdateInput) => api.patch(`/cards/${id}`, input), + remove: (id: number) => api.delete(`/cards/${id}`), + importXlsx: (lessonId: number, file: File, opts: { updateExisting: boolean; createMissingLessons: boolean }) => { + const fd = new FormData(); + fd.append('file', file); + fd.append('updateExisting', String(opts.updateExisting)); + fd.append('createMissingLessons', String(opts.createMissingLessons)); + return api.postForm(`/lessons/${lessonId}/cards/import`, fd); + }, + exportUrl: (lessonId: number, includeDescendants: boolean) => + `/api/lessons/${lessonId}/cards/export?include_descendants=${includeDescendants}`, +}; diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts new file mode 100644 index 0000000..4d044c0 --- /dev/null +++ b/packages/frontend/src/api/client.ts @@ -0,0 +1,38 @@ +export class ApiClientError extends Error { + constructor(public status: number, public code: string, message: string, public details?: unknown) { + super(message); + } +} + +async function request(method: string, path: string, body?: unknown, opts?: { isFormData?: boolean }): Promise { + const headers: Record = {}; + let payload: BodyInit | undefined; + if (opts?.isFormData) { + payload = body as FormData; + } else if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + payload = JSON.stringify(body); + } + const res = await fetch(`/api${path}`, { method, headers, body: payload }); + if (res.status === 204) return undefined as T; + const isJson = res.headers.get('content-type')?.includes('application/json'); + const data = isJson ? await res.json() : await res.blob(); + if (!res.ok) { + const e = (data as { error?: { code: string; message: string; details?: unknown } }).error; + throw new ApiClientError(res.status, e?.code ?? 'UNKNOWN', e?.message ?? 'Request failed', e?.details); + } + return data as T; +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + postForm: (path: string, form: FormData) => request('POST', path, form, { isFormData: true }), + patch: (path: string, body: unknown) => request('PATCH', path, body), + delete: (path: string) => request('DELETE', path), + getBlob: async (path: string): Promise => { + const res = await fetch(`/api${path}`); + if (!res.ok) throw new ApiClientError(res.status, 'UNKNOWN', 'Request failed'); + return res.blob(); + }, +}; diff --git a/packages/frontend/src/api/lessons.ts b/packages/frontend/src/api/lessons.ts new file mode 100644 index 0000000..f531dca --- /dev/null +++ b/packages/frontend/src/api/lessons.ts @@ -0,0 +1,10 @@ +import type { Lesson, LessonCreateInput, LessonMoveInput, LessonTreeNode, LessonUpdateInput } from '@flashcard/shared'; +import { api } from './client.js'; + +export const lessonsApi = { + tree: () => api.get('/lessons/tree'), + create: (input: LessonCreateInput) => api.post('/lessons', input), + update: (id: number, input: LessonUpdateInput) => api.patch(`/lessons/${id}`, input), + remove: (id: number) => api.delete(`/lessons/${id}`), + move: (id: number, input: LessonMoveInput) => api.post(`/lessons/${id}/move`, input), +}; diff --git a/packages/frontend/src/api/sessions.ts b/packages/frontend/src/api/sessions.ts new file mode 100644 index 0000000..85f4b0c --- /dev/null +++ b/packages/frontend/src/api/sessions.ts @@ -0,0 +1,15 @@ +import type { QueueItem, SessionRow, SessionStartInput, AttemptCreateInput } from '@flashcard/shared'; +import { api } from './client.js'; + +export interface StartedSession { session: SessionRow; queue: QueueItem[]; } +export interface SessionState { session: SessionRow; queue: QueueItem[]; index: number; } + +export const sessionsApi = { + start: (input: SessionStartInput) => api.post('/sessions', input), + active: () => api.get('/sessions/active'), + state: (id: number) => api.get(`/sessions/${id}`), + next: (id: number) => api.get<{ done: true } | { done: false; item: QueueItem }>(`/sessions/${id}/next`), + attempt: (id: number, input: AttemptCreateInput) => api.post(`/sessions/${id}/attempts`, input), + end: (id: number) => api.post(`/sessions/${id}/end`), + abandon: (id: number) => api.post(`/sessions/${id}/abandon`), +}; diff --git a/packages/frontend/src/api/stats.ts b/packages/frontend/src/api/stats.ts new file mode 100644 index 0000000..a396083 --- /dev/null +++ b/packages/frontend/src/api/stats.ts @@ -0,0 +1,23 @@ +import { api } from './client.js'; + +export interface Overview { + totalSessions: number; totalDurationSeconds: number; totalAttempts: number; streakDays: number; + recentSessions: { id: number; lessonId: number; startedAt: number; durationSeconds: number | null; cardsShown: number; cardsCorrect: number }[]; +} +export interface LessonStats { + lessonId: number; totalCards: number; mastered: number; score: number; + sessions: number; totalDurationSeconds: number; attempts: number; correct: number; incorrect: number; +} +export interface CardStats { + cardId: number; attempts: number; correct: number; incorrect: number; + box: { forward: number; backward: number | null }; + lastShownAt: number | null; nextDueAt: number; + history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[]; +} + +export const statsApi = { + overview: () => api.get('/stats/overview'), + lesson: (id: number) => api.get(`/stats/lessons/${id}`), + card: (id: number) => api.get(`/stats/cards/${id}`), + heatmap: (weeks = 12) => api.get<{ day: string; sessions: number; attempts: number }[]>(`/stats/heatmap?weeks=${weeks}`), +};