From bebd182540fa85488726fec0139f21f70d7e65d2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 20 May 2026 21:40:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(wordeck):=20L-1f=20Phase=20C=20=E2=80=94?= =?UTF-8?q?=20public-feed-Endpoint=20+=20DSE=20f=C3=BCr=20lokale=20Speiche?= =?UTF-8?q?rung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/api: - neue Route GET /api/v1/public/feed?cursor=&limit=20 - Plattform-Konvention aus LOCAL_FIRST_LOGIN_OPTIONAL.md §3 D - Shape: { items: [{ id, type:'deck', slug, title, excerpt, author, publishedAt, stats }], nextCursor: | null } - Quelle: marketplace.public_decks (is_takedown=false), sortiert nach createdAt desc — Aggregatoren wie hub.mana.how/timeline konsumieren genau diese Shape apps/web/datenschutz: - neue Sektion 7a „Lokale Daten + anonyme Nutzung" mit Hinweisen auf IndexedDB-DB-Name manasync_wordeck, anonyme Geräte-ID anon:, Sign-In-Lift mit AES-GCM-256-Vault-Verschlüsselung, Browser-Reset- Risiko bei anonymer Nutzung - alter sql-wasm-Hinweis raus (Wordeck nutzt jetzt event-sync, kein sql-wasm mehr) Verbleibend für Live-Smoke (Till manuell): - wordeck.com lädt → /decks ohne Login (anonymous-Mode) - Deck anlegen → IndexedDB manasync_wordeck zeigt Event - Login → Re-Tagging-Sweep + Push zu sync2.mana.how - /api/v1/public/feed Smoke (oder über hub.mana.how) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/index.ts | 5 + apps/api/src/routes/public-feed.ts | 118 +++++++++++++++++++ apps/web/src/routes/datenschutz/+page.svelte | 29 ++++- 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/routes/public-feed.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3e7e753..c8376cb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,6 +16,7 @@ import { decksGenerateRouter } from './routes/decks-generate.ts'; import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts'; import { marketplaceDecksRouter } from './routes/marketplace/decks.ts'; import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts'; +import { publicFeedRouter } from './routes/public-feed.ts'; import { engagementRouter as marketplaceEngagementRouter } from './routes/marketplace/engagement.ts'; import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/marketplace/subscriptions.ts'; import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts'; @@ -90,6 +91,10 @@ app.route('/api/v1/marketplace/me', marketplaceMeRouter()); app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter()); app.route('/api/v1/marketplace/decks', marketplaceDecksRouter()); +// Plattform-Public-Feed-Konvention (LOCAL_FIRST_LOGIN_OPTIONAL.md §3 D). +// hub.mana.how/timeline aggregiert über /api/v1/public/feed aller Apps. +app.route('/api/v1/public', publicFeedRouter()); + app.get('/', (c) => c.json({ app: 'wordeck', diff --git a/apps/api/src/routes/public-feed.ts b/apps/api/src/routes/public-feed.ts new file mode 100644 index 0000000..a20101d --- /dev/null +++ b/apps/api/src/routes/public-feed.ts @@ -0,0 +1,118 @@ +/** + * Public-Feed-Endpoint nach Plattform-Konvention + * (siehe `mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md` Konvention D). + * + * Shape: + * GET /api/v1/public/feed?cursor=&limit=20 + * → { items: [{ id, type, slug, title, excerpt, author, publishedAt, ... }], + * nextCursor: | null } + * + * Wordecks Public-Items sind die published Marketplace-Decks. Wir wrappen + * den Marketplace-Browse mit dem Konventions-Output. Aggregator-Apps wie + * `hub.mana.how/timeline` konsumieren genau diese Shape. + * + * Keine Auth — pure public read. + */ + +import { Hono } from 'hono'; + +import { authors, publicDecks } from '../db/schema/index.ts'; +import { count, eq, and, desc, sql } from 'drizzle-orm'; +import { getDb, type CardsDb } from '../db/connection.ts'; + +interface FeedItem { + id: string; + type: 'deck'; + slug: string; + title: string; + excerpt: string | null; + author: { + slug: string; + display_name: string; + }; + publishedAt: string; + stats: { + card_count: number; + star_count: number; + }; +} + +function encodeCursor(offset: number): string { + return Buffer.from(JSON.stringify({ offset })).toString('base64url'); +} + +function decodeCursor(cursor: string): number { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')); + return typeof parsed.offset === 'number' ? parsed.offset : 0; + } catch { + return 0; + } +} + +async function loadPage( + db: CardsDb, + offset: number, + limit: number, +): Promise<{ items: FeedItem[]; total: number }> { + const starCount = sql`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`; + const cardCountExpr = sql`COALESCE((SELECT v.card_count FROM marketplace.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`; + + const rows = await db + .select({ + id: publicDecks.id, + slug: publicDecks.slug, + title: publicDecks.title, + description: publicDecks.description, + createdAt: publicDecks.createdAt, + cardCount: cardCountExpr, + starCount, + ownerSlug: authors.slug, + ownerDisplayName: authors.displayName, + }) + .from(publicDecks) + .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId)) + .where(and(eq(publicDecks.isTakedown, false))) + .orderBy(desc(publicDecks.createdAt)) + .limit(limit) + .offset(offset); + + const totalRow = await db + .select({ value: count() }) + .from(publicDecks) + .where(and(eq(publicDecks.isTakedown, false))); + + return { + items: rows.map( + (r): FeedItem => ({ + id: r.id, + type: 'deck', + slug: r.slug, + title: r.title, + excerpt: r.description, + author: { slug: r.ownerSlug, display_name: r.ownerDisplayName }, + publishedAt: r.createdAt.toISOString(), + stats: { card_count: Number(r.cardCount), star_count: Number(r.starCount) }, + }), + ), + total: totalRow[0]?.value ?? 0, + }; +} + +export function publicFeedRouter() { + const r = new Hono(); + r.get('/feed', async (c) => { + const url = new URL(c.req.url); + const limitRaw = url.searchParams.get('limit'); + const cursor = url.searchParams.get('cursor'); + const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '20', 10) || 20), 100); + const offset = cursor ? decodeCursor(cursor) : 0; + + const { items, total } = await loadPage(getDb(), offset, limit); + const nextOffset = offset + items.length; + const nextCursor = nextOffset < total ? encodeCursor(nextOffset) : null; + + return c.json({ items, nextCursor }); + }); + return r; +} diff --git a/apps/web/src/routes/datenschutz/+page.svelte b/apps/web/src/routes/datenschutz/+page.svelte index d766eba..b3b5f5d 100644 --- a/apps/web/src/routes/datenschutz/+page.svelte +++ b/apps/web/src/routes/datenschutz/+page.svelte @@ -214,8 +214,33 @@

Lokal speichert die App im Browser-localStorage technisch notwendige - Daten (Auth-Token, Spracheinstellung, sql-wasm-Cache für Offline- - Study). Wir nutzen keine Tracking-Cookies. + Daten (Auth-Token, Spracheinstellung). Wir nutzen keine + Tracking-Cookies. +

+

7a. Lokale Daten + anonyme Nutzung

+

+ Seit Mai 2026 läuft Wordeck local-first: Decks, + Karten und Lern-Reviews werden zuerst in Ihrer Browser-IndexedDB + gespeichert (Datenbank-Name manasync_wordeck), nicht + direkt auf unseren Servern. Sie können die App komplett ohne + Konto nutzen — eine anonyme Geräte-ID + (anon:<ulid>) wird lokal erzeugt und verlässt + Ihr Gerät nicht. +

+

+ Sobald Sie sich anmelden, werden alle bisher anonym gesammelten + Daten dem Konto zugeordnet und an unseren Sync-Server + sync2.mana.how übertragen — verschlüsselt mit einem + User-eigenen Master-Key (AES-GCM-256), der über + auth.mana.how in Ihrem Vault liegt. Inhalte + (Karteninhalte, Notizen) sind serverseitig nicht im Klartext + lesbar. +

+

+ Sie können jederzeit ohne Anmeldung weiter lokal arbeiten — + diese Daten bleiben dann auf Ihrem Gerät. Bei Browser-Reset oder + manueller Löschung der IndexedDB sind sie verloren, sofern Sie + vorher kein Konto angelegt haben.

8. Ihre Rechte