diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts index e871e5021..3bcb9d28e 100644 --- a/apps/cards/apps/web/src/lib/api/cards-api.ts +++ b/apps/cards/apps/web/src/lib/api/cards-api.ts @@ -209,6 +209,49 @@ export const cardsApi = { { auth: 'optional' } ), }, + pullRequests: { + create: ( + deckSlug: string, + input: { + title: string; + body?: string; + diff: PullRequestDiffInput; + } + ) => + request(`/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests`, { + method: 'POST', + body: input, + }), + list: (deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') => { + const qs = status ? `?status=${status}` : ''; + return request( + `/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests${qs}`, + { auth: 'optional' } + ); + }, + get: (id: string) => request(`/v1/pull-requests/${id}`, { auth: 'optional' }), + merge: (id: string, opts: { newSemver?: string; mergeNote?: string } = {}) => + request<{ pullRequest: PullRequest; version: PublicDeckVersion }>( + `/v1/pull-requests/${id}/merge`, + { method: 'POST', body: opts } + ), + close: (id: string) => + request<{ ok: true }>(`/v1/pull-requests/${id}/close`, { method: 'POST' }), + reject: (id: string) => + request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }), + }, + discussions: { + listForCard: (contentHash: string) => + request(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, { + auth: 'optional', + }), + post: (contentHash: string, input: { deckSlug: string; body: string; parentId?: string }) => + request(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, { + method: 'POST', + body: input, + }), + hide: (id: string) => request<{ ok: true }>(`/v1/discussions/${id}/hide`, { method: 'POST' }), + }, }; // Override author lookup to send token opportunistically — public reads. @@ -312,3 +355,39 @@ export interface DiffPayload { unchanged: { contentHash: string; ord: number }[]; removed: { contentHash: string }[]; } + +export interface PullRequestDiffInput { + add: { type: string; fields: Record }[]; + modify: { previousContentHash: string; type: string; fields: Record }[]; + remove: { contentHash: string }[]; +} + +export type PullRequestStatus = 'open' | 'merged' | 'closed' | 'rejected'; + +export interface PullRequest { + id: string; + deckId: string; + authorUserId: string; + status: PullRequestStatus; + title: string; + body: string | null; + diff: { + add: { type: string; fields: Record }[]; + modify: { contentHash: string; fields: Record }[]; + remove: { contentHash: string }[]; + }; + mergedIntoVersionId: string | null; + createdAt: string; + resolvedAt: string | null; +} + +export interface CardDiscussion { + id: string; + cardContentHash: string; + deckId: string; + authorUserId: string; + parentId: string | null; + body: string; + hidden: boolean; + createdAt: string; +} diff --git a/apps/cards/apps/web/src/lib/components/PullRequestsSection.svelte b/apps/cards/apps/web/src/lib/components/PullRequestsSection.svelte new file mode 100644 index 000000000..9da721aad --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/PullRequestsSection.svelte @@ -0,0 +1,233 @@ + + +
+
+

+ Pull Requests {prs.length > 0 ? `(${prs.length})` : ''} +

+ +
+ + {#if error} +

+ {error} +

+ {/if} + + {#if loading && prs.length === 0} +

+ Lädt… +

+ {:else if prs.length === 0} +

+ Noch keine Pull Requests. Abonnenten können Verbesserungen vorschlagen. +

+ {:else} +
    + {#each prs as pr (pr.id)} +
  • +
    +
    +
    + + {pr.status} + +

    {pr.title}

    +
    +

    + {diffSummary(pr)} · {new Date(pr.createdAt).toLocaleDateString('de-DE')} +

    +
    + +
    + + {#if expanded[pr.id]} + {#if pr.body} +

    {pr.body}

    + {/if} + + {#if pr.diff.modify.length > 0} +
    +
    Geändert
    +
      + {#each pr.diff.modify as m (m.contentHash)} +
    • +
      + ← {m.contentHash.slice(0, 12)}… +
      + {#each Object.entries(m.fields) as [k, v]} +
      + {k}: + {v} +
      + {/each} +
    • + {/each} +
    +
    + {/if} + + {#if pr.diff.add.length > 0} +
    +
    + Neu (+{pr.diff.add.length}) +
    +
      + {#each pr.diff.add as a, i (i)} +
    • +
      {a.type}
      + {#each Object.entries(a.fields) as [k, v]} +
      + {k}: + {v} +
      + {/each} +
    • + {/each} +
    +
    + {/if} + + {#if pr.diff.remove.length > 0} +
    +
    + Entfernt (−{pr.diff.remove.length}) +
    +
      + {#each pr.diff.remove as r (r.contentHash)} +
    • · {r.contentHash.slice(0, 12)}…
    • + {/each} +
    +
    + {/if} + + {#if pr.status === 'open' && viewerIsOwner} +
    + + + +
    + {/if} + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte b/apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte new file mode 100644 index 000000000..1b994a1c5 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte @@ -0,0 +1,190 @@ + + +{#if open} + +{/if} diff --git a/apps/cards/apps/web/src/lib/queries.ts b/apps/cards/apps/web/src/lib/queries.ts index acf2f4462..869f7ae45 100644 --- a/apps/cards/apps/web/src/lib/queries.ts +++ b/apps/cards/apps/web/src/lib/queries.ts @@ -39,6 +39,8 @@ export function toDeck(local: LocalDeck): Deck { cardCount: local.cardCount, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(), + subscribedFromSlug: local.subscribedFromSlug, + subscribedAtVersion: local.subscribedAtVersion, }; } @@ -70,6 +72,7 @@ export function toCard(local: LocalCard): Card { order: local.order, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(), + serverContentHash: local.serverContentHash, }; } diff --git a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte index 2de50f830..995d7a6a9 100644 --- a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte +++ b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte @@ -10,6 +10,7 @@ } from '$lib/api/cards-api'; import { isSubscribedLocally, subscribeAndPull, unsubscribe } from '$lib/services/subscribe'; import { cardDeckTable } from '$lib/data/database'; + import PullRequestsSection from '$lib/components/PullRequestsSection.svelte'; const slug = $derived(page.params.slug as string); @@ -202,6 +203,8 @@

Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}

+ + {/if} diff --git a/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte index 3d730a667..505b7af75 100644 --- a/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte +++ b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte @@ -6,6 +6,7 @@ import { reviewStore } from '$lib/stores/reviews.svelte'; import { studyBlockStore } from '$lib/stores/study-blocks.svelte'; import CardFace from '$lib/components/CardFace.svelte'; + import SuggestEditModal from '$lib/components/SuggestEditModal.svelte'; import type { Card, CardReview, ReviewGrade } from '@mana/cards-core'; const deckId = $derived(page.params.deckId as string); @@ -22,6 +23,9 @@ const current = $derived(queue[currentIndex]); const deckTitle = $derived($deckQuery?.title ?? 'Deck'); + const subscribedSlug = $derived($deckQuery?.subscribedFromSlug); + const canSuggest = $derived(!!subscribedSlug && !!current?.card.serverContentHash); + let suggestOpen = $state(false); $effect(() => { const snap = $dueQuery; @@ -135,6 +139,18 @@ onTypedAnswer={(v) => (typedAnswer = v)} /> + {#if canSuggest} +
+ +
+ {/if} + {#if !showBack}