feat(frontend): API client modules + backend GET /api/cards/:id
This commit is contained in:
@@ -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);
|
||||
|
||||
23
packages/frontend/src/api/cards.ts
Normal file
23
packages/frontend/src/api/cards.ts
Normal 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}`,
|
||||
};
|
||||
38
packages/frontend/src/api/client.ts
Normal file
38
packages/frontend/src/api/client.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
10
packages/frontend/src/api/lessons.ts
Normal file
10
packages/frontend/src/api/lessons.ts
Normal 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),
|
||||
};
|
||||
15
packages/frontend/src/api/sessions.ts
Normal file
15
packages/frontend/src/api/sessions.ts
Normal 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`),
|
||||
};
|
||||
23
packages/frontend/src/api/stats.ts
Normal file
23
packages/frontend/src/api/stats.ts
Normal 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}`),
|
||||
};
|
||||
Reference in New Issue
Block a user