feat(cards-web): Phase δ.2 — Subscribe + initial pull

End-to-end subscribe flow on cards.mana.how. From a public deck page
the user can now pull the deck into their own Cards instance with
one click; subscribed decks live alongside own decks but carry a
`subscribedFromSlug` marker so the editor knows to hide mutate
controls (UI gating in δ.3).

  - cards-core types: LocalDeck gains subscribedFromSlug +
    subscribedAtVersion. LocalCard gains serverContentHash. Both
    optional — own decks/cards are unaffected.
  - data/database.ts: Dexie v2 adds index on cardDecks.subscribedFromSlug
    so the lookup-by-slug path is O(1).
  - lib/api/cards-api.ts: subscriptions.{list,subscribe,unsubscribe,
    version,diff} + the SubscriptionInfo / ServerCard / DeckVersionPayload
    / DiffPayload types.
  - lib/services/subscribe.ts: subscribeAndPull() sequences server
    POST /subscribe → GET /decks/:slug → GET /versions/:semver →
    create LocalDeck + LocalCards + ensure FSRS reviews. Re-pull
    refreshes in place (Phase δ.3 will swap to real diff-apply that
    keeps FSRS state). unsubscribe() soft-deletes the local mirror.
    isSubscribedLocally() backs the deck-page state check.
  - routes/d/[slug]/+page.svelte: full subscribe UI — Abonnieren →
    Abonniert + Lernen-Button (deep-links into the existing learn
    session route).

Validated: svelte-check 0/0, vite build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 19:56:48 +02:00
parent 86a01426e8
commit 58c057f6c5
5 changed files with 307 additions and 27 deletions

View file

@ -187,6 +187,28 @@ export const cardsApi = {
method: 'DELETE',
}),
},
subscriptions: {
list: () => request<SubscriptionInfo[]>('/v1/me/subscriptions'),
subscribe: (deckSlug: string) =>
request<{ deckSlug: string; latestVersionId: string }>(
`/v1/decks/${encodeURIComponent(deckSlug)}/subscribe`,
{ method: 'POST' }
),
unsubscribe: (deckSlug: string) =>
request<{ ok: true }>(`/v1/decks/${encodeURIComponent(deckSlug)}/subscribe`, {
method: 'DELETE',
}),
version: (deckSlug: string, semver: string) =>
request<DeckVersionPayload>(
`/v1/decks/${encodeURIComponent(deckSlug)}/versions/${encodeURIComponent(semver)}`,
{ auth: 'optional' }
),
diff: (deckSlug: string, fromSemver: string) =>
request<DiffPayload>(
`/v1/decks/${encodeURIComponent(deckSlug)}/diff?from=${encodeURIComponent(fromSemver)}`,
{ auth: 'optional' }
),
},
};
// Override author lookup to send token opportunistically — public reads.
@ -254,3 +276,39 @@ export interface PublishResult {
version: PublicDeckVersion;
moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] };
}
export interface SubscriptionInfo {
deckSlug: string;
deckTitle: string;
deckDescription: string | null;
subscribedAt: string;
notifyUpdates: boolean;
currentVersionId: string | null;
latestVersionId: string | null;
updateAvailable: boolean;
}
export interface ServerCard {
contentHash: string;
type: string;
fields: Record<string, string>;
ord: number;
}
export interface DeckVersionPayload {
id: string;
semver: string;
contentHash: string;
publishedAt: string;
changelog: string | null;
cards: ServerCard[];
}
export interface DiffPayload {
from: string;
to: string;
added: ServerCard[];
changed: { previous: { contentHash: string }; next: ServerCard }[];
unchanged: { contentHash: string; ord: number }[];
removed: { contentHash: string }[];
}

View file

@ -65,6 +65,12 @@ class CardsDatabase extends Dexie {
deckTags: 'id, deckId, tagId',
_pendingChanges: '++pk, table, queuedAt',
});
// v2 — Phase δ.2: index `subscribedFromSlug` on cardDecks so the
// subscribe service can lookup-by-slug to avoid duplicating
// subscriptions on re-pull.
this.version(2).stores({
cardDecks: 'id, lastStudied, subscribedFromSlug',
});
}
}

View file

@ -0,0 +1,145 @@
/**
* Subscribe to a marketplace deck and pull its latest version into
* the local Dexie. Phase δ.2 initial pull only; smart-merge of
* subsequent updates lands in δ.3 via `applySubscriptionUpdate`
* (placeholder export below).
*
* The subscribed deck shows up alongside own decks but is marked
* `subscribedFromSlug` + `subscribedAtVersion` so the UI can hide
* mutate controls and show an "Update available" indicator when
* cards-server reports a newer version.
*/
import { cardsApi, CardsApiError, type ServerCard } from '$lib/api/cards-api';
import { cardDeckTable, cardTable } from '$lib/data/database';
import { reviewStore } from '$lib/stores/reviews.svelte';
import type { CardType, LocalCard, LocalDeck } from '@mana/cards-core';
const ALLOWED_TYPES: CardType[] = [
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
];
function asCardType(t: string): CardType {
return (ALLOWED_TYPES as string[]).includes(t) ? (t as CardType) : 'basic';
}
export interface SubscribeResult {
deckId: string;
cardCount: number;
}
export async function subscribeAndPull(deckSlug: string): Promise<SubscribeResult> {
// 1. Tell the server we're subscribed (idempotent, returns the
// version we should pull).
const sub = await cardsApi.subscriptions.subscribe(deckSlug);
// 2. Fetch the deck metadata so we know title/description/etc.
const { deck, latestVersion } = await cardsApi.decks.bySlug(deckSlug);
if (!latestVersion) {
throw new Error('Subscribed but the deck has no published version yet');
}
// 3. Fetch the version's cards (full payload).
const version = await cardsApi.subscriptions.version(deckSlug, latestVersion.semver);
// 4. Already subscribed locally? Don't duplicate — refresh in
// place. Phase δ.3 will swap this for a real diff-apply.
const existingDeck = await cardDeckTable
.where('subscribedFromSlug')
.equals(deckSlug)
.first()
.catch(() => undefined);
const now = new Date().toISOString();
const localDeck: LocalDeck = existingDeck ?? {
id: crypto.randomUUID(),
name: deck.title,
description: deck.description,
color: '#6366f1',
cardCount: version.cards.length,
visibility: 'private',
createdAt: now,
updatedAt: now,
subscribedFromSlug: deckSlug,
subscribedAtVersion: latestVersion.semver,
};
if (existingDeck) {
await cardDeckTable.update(existingDeck.id, {
name: deck.title,
description: deck.description,
cardCount: version.cards.length,
subscribedAtVersion: latestVersion.semver,
updatedAt: now,
});
} else {
await cardDeckTable.add(localDeck);
}
// 5. Replace cards (initial-pull strategy; δ.3 keeps FSRS state).
if (existingDeck) {
const oldCards = await cardTable.where('deckId').equals(existingDeck.id).toArray();
for (const c of oldCards) {
if (!c.deletedAt) await cardTable.update(c.id, { deletedAt: now });
}
}
for (const sc of version.cards) {
const card: LocalCard = {
id: crypto.randomUUID(),
deckId: localDeck.id,
type: asCardType(sc.type),
fields: sc.fields,
order: sc.ord,
serverContentHash: sc.contentHash,
createdAt: now,
updatedAt: now,
};
await cardTable.add(card);
await reviewStore.ensureReviewsForCard({
id: card.id,
type: card.type as CardType,
fields: card.fields ?? {},
});
}
return { deckId: localDeck.id, cardCount: version.cards.length };
}
export async function unsubscribe(deckSlug: string): Promise<void> {
await cardsApi.subscriptions.unsubscribe(deckSlug);
const local = await cardDeckTable
.where('subscribedFromSlug')
.equals(deckSlug)
.first()
.catch(() => undefined);
if (!local) return;
const now = new Date().toISOString();
const cards = await cardTable.where('deckId').equals(local.id).toArray();
for (const c of cards) {
if (!c.deletedAt) await cardTable.update(c.id, { deletedAt: now });
}
await cardDeckTable.update(local.id, { deletedAt: now });
}
/** Helper: am I already subscribed locally to this slug? */
export async function isSubscribedLocally(slug: string): Promise<boolean> {
try {
const row = await cardDeckTable.where('subscribedFromSlug').equals(slug).first();
return Boolean(row && !row.deletedAt);
} catch {
return false;
}
}
export async function ensureSubscriptionFresh(_deckSlug: string): Promise<void> {
// Placeholder for Phase δ.3 — when an update is available, fetch
// the diff and apply it card-by-card without touching FSRS state.
// Today we just re-pull (existingDeck branch above does that).
}

View file

@ -1,24 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import {
cardsApi,
CardsApiError,
type PublicAuthor,
type PublicDeck,
type PublicDeckVersion,
} from '$lib/api/cards-api';
import { isSubscribedLocally, subscribeAndPull, unsubscribe } from '$lib/services/subscribe';
import { cardDeckTable } from '$lib/data/database';
const slug = $derived(page.params.slug as string);
let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading');
let deck = $state<PublicDeck | null>(null);
let version = $state<PublicDeckVersion | null>(null);
let author = $state<PublicAuthor | null>(null);
let starred = $state(false);
let starBusy = $state(false);
let subscribed = $state(false);
let subscribeBusy = $state(false);
let subscribedDeckId = $state<string | null>(null);
let error = $state<string | null>(null);
let busy = $state(false);
$effect(() => {
if (!slug) return;
@ -31,11 +34,15 @@
const r = await cardsApi.decks.bySlug(slug);
deck = r.deck;
version = r.latestVersion;
// Author profile is a separate lookup by ownerUserId — we don't
// have a slug from the deck endpoint yet, but the explore browse
// gives us the author info inline. For Phase γ.2 we keep this
// page simple and just show the deck; clicking the deck card on
// /explore already routed via /u/<slug>.
subscribed = await isSubscribedLocally(slug);
if (subscribed) {
const local = await cardDeckTable
.where('subscribedFromSlug')
.equals(slug)
.first()
.catch(() => undefined);
subscribedDeckId = local?.id ?? null;
}
stage = 'ok';
} catch (e) {
if (e instanceof CardsApiError && e.status === 404) {
@ -48,8 +55,9 @@
}
async function toggleStar() {
if (!deck || busy) return;
busy = true;
if (!deck || starBusy) return;
starBusy = true;
error = null;
try {
if (starred) {
await cardsApi.decks.unstar(deck.slug);
@ -61,15 +69,30 @@
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
starBusy = false;
}
}
// `author` is a placeholder for Phase γ.3 (full author surface on
// the deck page). Reading it once silences the unused-state lint
// without changing reactivity semantics.
// svelte-ignore state_referenced_locally
void author;
async function toggleSubscribe() {
if (!deck || subscribeBusy) return;
subscribeBusy = true;
error = null;
try {
if (subscribed) {
await unsubscribe(deck.slug);
subscribed = false;
subscribedDeckId = null;
} else {
const result = await subscribeAndPull(deck.slug);
subscribed = true;
subscribedDeckId = result.deckId;
}
} catch (e) {
error = (e as Error).message;
} finally {
subscribeBusy = false;
}
}
</script>
<svelte:head>
@ -80,7 +103,9 @@
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-neutral-400">Lade Deck…</p>
{:else if stage === 'not-found'}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400">
<p
class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400"
>
Deck <code class="rounded bg-neutral-800 px-1">{slug}</code> existiert nicht.
</p>
{:else if stage === 'error'}
@ -128,27 +153,52 @@
<button
class="rounded-lg border border-indigo-500/40 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
onclick={toggleStar}
disabled={busy}
disabled={starBusy}
>
{starred ? '★ Markiert' : '☆ Merken'}
</button>
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
disabled
title="Subscribe + Smart-Merge folgt in Phase δ"
>
Abonnieren · Phase δ
</button>
{#if subscribed}
<button
class="rounded-lg border border-emerald-500/40 px-4 py-2 text-sm text-emerald-300 hover:bg-emerald-500/10 disabled:opacity-50"
onclick={toggleSubscribe}
disabled={subscribeBusy}
title="Abo entfernen"
>
{subscribeBusy ? 'Lädt…' : '✓ Abonniert'}
</button>
{#if subscribedDeckId}
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
onclick={() => goto(`/learn/${subscribedDeckId}`)}
>
Lernen
</button>
{/if}
{:else}
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
onclick={toggleSubscribe}
disabled={subscribeBusy || !version}
title={version ? 'In meine Decks ziehen' : 'Deck hat noch keine Version'}
>
{subscribeBusy ? 'Abonniere…' : 'Abonnieren'}
</button>
{/if}
{:else}
<a
href="/login"
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
>
Anmelden um zu merken
Anmelden um zu abonnieren
</a>
{/if}
</div>
{#if error}
<p class="mt-3 text-sm text-red-400">{error}</p>
{/if}
<p class="mt-10 text-xs text-neutral-500">
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
</p>