feat(frontend): API client modules + backend GET /api/cards/:id

This commit is contained in:
2026-05-20 21:12:38 +02:00
parent 480ee15df9
commit 1c977c4743
6 changed files with 114 additions and 1 deletions

View File

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

View File

@@ -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<Card[]>(`/lessons/${lessonId}/cards`),
get: (id: number) => api.get<Card>(`/cards/${id}`),
create: (lessonId: number, input: CardCreateInput) => api.post<Card>(`/lessons/${lessonId}/cards`, input),
update: (id: number, input: CardUpdateInput) => api.patch<Card>(`/cards/${id}`, input),
remove: (id: number) => api.delete<void>(`/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<ImportResult>(`/lessons/${lessonId}/cards/import`, fd);
},
exportUrl: (lessonId: number, includeDescendants: boolean) =>
`/api/lessons/${lessonId}/cards/export?include_descendants=${includeDescendants}`,
};

View File

@@ -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<T>(method: string, path: string, body?: unknown, opts?: { isFormData?: boolean }): Promise<T> {
const headers: Record<string, string> = {};
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: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
postForm: <T>(path: string, form: FormData) => request<T>('POST', path, form, { isFormData: true }),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
getBlob: async (path: string): Promise<Blob> => {
const res = await fetch(`/api${path}`);
if (!res.ok) throw new ApiClientError(res.status, 'UNKNOWN', 'Request failed');
return res.blob();
},
};

View File

@@ -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<LessonTreeNode[]>('/lessons/tree'),
create: (input: LessonCreateInput) => api.post<Lesson>('/lessons', input),
update: (id: number, input: LessonUpdateInput) => api.patch<Lesson>(`/lessons/${id}`, input),
remove: (id: number) => api.delete<void>(`/lessons/${id}`),
move: (id: number, input: LessonMoveInput) => api.post<Lesson>(`/lessons/${id}/move`, input),
};

View File

@@ -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<StartedSession>('/sessions', input),
active: () => api.get<SessionRow | null>('/sessions/active'),
state: (id: number) => api.get<SessionState>(`/sessions/${id}`),
next: (id: number) => api.get<{ done: true } | { done: false; item: QueueItem }>(`/sessions/${id}/next`),
attempt: (id: number, input: AttemptCreateInput) => api.post<void>(`/sessions/${id}/attempts`, input),
end: (id: number) => api.post<SessionRow>(`/sessions/${id}/end`),
abandon: (id: number) => api.post<SessionRow>(`/sessions/${id}/abandon`),
};

View File

@@ -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<Overview>('/stats/overview'),
lesson: (id: number) => api.get<LessonStats>(`/stats/lessons/${id}`),
card: (id: number) => api.get<CardStats>(`/stats/cards/${id}`),
heatmap: (weeks = 12) => api.get<{ day: string; sessions: number; attempts: number }[]>(`/stats/heatmap?weeks=${weeks}`),
};