Phase 4: Frontend-Core MVP — Decks, Cards, Study mit FSRS-Loop
Stack:
- Tailwind 4 via @tailwindcss/vite (oklch-Theme + Dark-Mode-Auto)
- marked + DOMPurify für Markdown (sanitized, SSR-Safe)
- Svelte 5 runes durchgängig ($state, $derived, $effect)
- @sveltejs/adapter-node für Production-Build
Infrastruktur:
- $lib/auth/dev-stub.svelte.ts: User-ID via sessionStorage (Phase 2
ersetzt durch echtes JWT via @mana/shared-auth)
- $lib/api/{client,decks,cards,reviews}.ts: typed Fetch-Wrapper, ruft
cards-api auf 3081 mit X-User-Id-Header
- $lib/stores/toasts.svelte.ts: Toast-Store mit info/success/warning/error
- $lib/markdown.ts: marked → DOMPurify-Pipeline
- $lib/components/{Header,ToastStack}.svelte: Layout-Shell
Routes:
- / → Dev-Login-Form oder Redirect zu /decks (wenn eingeloggt)
- /decks → Liste mit Color-Dot, Hover-Delete-Button
- /decks/new → Create-Form (Name, Beschreibung, Color-Picker)
- /decks/[id] → Detail mit Cards-Liste + dueCount + "Lernen"-Button
- /cards/new?deck=... → Type-Picker (basic|basic-reverse) +
Side-by-Side Markdown-Editor mit Live-Preview
- /study → Übersicht aller Decks mit Due-Counts
- /study/[deckId] → Session-View mit Queue-Snapshot, Reveal/Grade,
Hotkeys (Space/Enter=Reveal & Good, 1-4=Again/Hard/Good/Easy),
INPUT-Skip im Keyboard-Handler
CORS auf cards-api für localhost-Origins + cardecky.mana.how.
Verifiziert:
- pnpm run type-check ✅ 4/4 packages, svelte-check 0 errors
- pnpm build (cards-web) ✅ adapter-node bundle 140 kB server,
alle Routen bundled
- Tailwind-CSS inlined in SSR-HTML, oklch-Theme korrekt
- CORS-Preflight funktioniert (OPTIONS 204 mit korrekten Allow-*-Headers)
- Live-Smoke-Test gegen localhost:3081 (cards-api) + localhost:3082
(cards-web): Beide laufen parallel, Web → API CORS-fetch grün
Outside scope (Phase 4):
- Card-Edit-Page (/cards/[id]/edit) — heute nur Create + Delete
- Settings/Account/Credits/DSGVO-Pages — Phase 9 (Polish)
- Anki-Import — Phase 8
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e3b3a2b478
commit
89a7a9250b
22 changed files with 1582 additions and 58 deletions
23
apps/web/src/lib/api/cards.ts
Normal file
23
apps/web/src/lib/api/cards.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Card, CardCreate, CardUpdate } from '@cards/domain';
|
||||
import { api } from './client.ts';
|
||||
|
||||
export function listCards(deckId?: string) {
|
||||
const qs = deckId ? `?deck_id=${encodeURIComponent(deckId)}` : '';
|
||||
return api<{ cards: Card[]; total: number }>(`/api/v1/cards${qs}`);
|
||||
}
|
||||
|
||||
export function getCard(id: string) {
|
||||
return api<Card>(`/api/v1/cards/${id}`);
|
||||
}
|
||||
|
||||
export function createCard(input: CardCreate) {
|
||||
return api<Card>('/api/v1/cards', { method: 'POST', body: input });
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: CardUpdate) {
|
||||
return api<Card>(`/api/v1/cards/${id}`, { method: 'PATCH', body: patch });
|
||||
}
|
||||
|
||||
export function deleteCard(id: string) {
|
||||
return api<{ deleted: string }>(`/api/v1/cards/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
55
apps/web/src/lib/api/client.ts
Normal file
55
apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Cards-API-Client. Dünner Fetch-Wrapper, der `X-User-Id`-Header aus
|
||||
* dem Dev-Auth-Stub setzt. Phase 2 ersetzt das durch ein Bearer-Token
|
||||
* aus @mana/shared-auth.
|
||||
*/
|
||||
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
|
||||
export const API_BASE = (() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return import.meta.env.PUBLIC_CARDS_API_URL ?? 'http://localhost:3081';
|
||||
}
|
||||
return process.env.CARDS_API_URL ?? 'http://localhost:3081';
|
||||
})();
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly body: unknown,
|
||||
message?: string
|
||||
) {
|
||||
super(message ?? `cards-api ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function api<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (devUser.id) {
|
||||
headers['X-User-Id'] = devUser.id;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = await res.text();
|
||||
}
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
22
apps/web/src/lib/api/decks.ts
Normal file
22
apps/web/src/lib/api/decks.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Deck, DeckCreate, DeckUpdate } from '@cards/domain';
|
||||
import { api } from './client.ts';
|
||||
|
||||
export function listDecks() {
|
||||
return api<{ decks: Deck[]; total: number }>('/api/v1/decks');
|
||||
}
|
||||
|
||||
export function getDeck(id: string) {
|
||||
return api<Deck>(`/api/v1/decks/${id}`);
|
||||
}
|
||||
|
||||
export function createDeck(input: DeckCreate) {
|
||||
return api<Deck>('/api/v1/decks', { method: 'POST', body: input });
|
||||
}
|
||||
|
||||
export function updateDeck(id: string, patch: DeckUpdate) {
|
||||
return api<Deck>(`/api/v1/decks/${id}`, { method: 'PATCH', body: patch });
|
||||
}
|
||||
|
||||
export function deleteDeck(id: string) {
|
||||
return api<{ deleted: string }>(`/api/v1/decks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
23
apps/web/src/lib/api/reviews.ts
Normal file
23
apps/web/src/lib/api/reviews.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Card, Rating, Review } from '@cards/domain';
|
||||
import { api } from './client.ts';
|
||||
|
||||
export type DueReview = Review & {
|
||||
card?: Pick<Card, 'id' | 'deck_id' | 'type' | 'fields'>;
|
||||
};
|
||||
|
||||
export function listDueReviews(opts: { deckId?: string; limit?: number } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.deckId) params.set('deck_id', opts.deckId);
|
||||
if (opts.limit) params.set('limit', String(opts.limit));
|
||||
const qs = params.toString();
|
||||
return api<{ reviews: DueReview[]; total: number }>(
|
||||
`/api/v1/reviews/due${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export function gradeReview(cardId: string, subIndex: number, rating: Rating) {
|
||||
return api<Review>(`/api/v1/reviews/${cardId}/${subIndex}/grade`, {
|
||||
method: 'POST',
|
||||
body: { rating },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue