Paginierung, Share-Vorschauen, Weiterlesen: Die letzte Runde vor der Closed Alpha

Paginierung, Share-Vorschauen, Weiterlesen: Die letzte Runde vor der Closed Alpha

paginierunginfinite-scrollopen-graphskiasharpweiterlesenandroidalphaseo
Fiction

Paginierung, Share-Vorschauen, Weiterlesen: Die letzte Runde vor der Closed Alpha

Endlose Bibliotheksregale mit Büchern Foto: Unsplash

Zwischen den externen Alpha-Einladungen und dem Datenbankschema gibt es einen Gap von Features, die man erst braucht, wenn echte Leute auf die Seite schauen. Drei Tage, vier Brocken. Jeder für sich ein klassischer „das ist doch einfach"-Satz, den wir in „das hat einen doppelten Boden"-Projekte verwandelt haben.

Serverseitige Kategorie-Paginierung mit Infinite Scroll

Bisher lud die Kategorie-Seite den gesamten Katalog für die aktuelle Sprache in einem Rutsch und filterte clientseitig. Bei 750 Geschichten pro Sprache geht das noch, bei 5.000 wird es unangenehm, bei 50.000 ist es kaputt.

Die Neugestaltung:

  • Routen: /category, /category/{L0}, /category/{L0}/{L1}, /category/{L0}/{L1}/{L2}, plus je eine …/page/{n}-Variante. Acht Route-Templates auf einer Razor-Komponente, deep-linkbar auf jeder Ebene.
  • API: GET /api/stories/by-category/{slug}?page=1&pageSize=24&sort=new liefert ein PagedResult<StoryDto> mit Items, Page, PageSize, TotalCount.
  • Repository: Der Slug wird einmal zu einer flachen Liste von Kategorie-IDs aufgelöst (Selbst + Nachfahren), dann filtert EF über CategoryId IN (...) statt über den vierstufigen ParentCategory-Walk, den die alte Variante brauchte.
  • Composite-Indexe: Drei neue Indexe auf Stories — je einer für die Newest-, TopRated- und Trending-Sort-Varianten. Pagination auf Seite 40 ist damit O(log n) statt Table-Scan.
  • Stabile Sortierung: Jeder Sort endet mit .ThenByDescending(s => s.Id) als Tiebreak. Ohne den würden zwei Geschichten mit demselben PublishedDate zwischen aufeinanderfolgenden Seiten wandern oder verschwinden.

Die Sort-Chips (Default / Trending / Neu / Top bewertet / Vollständig) leben jetzt serverseitig. Ein Klick auf einen Chip setzt die Paginierung auf Seite 1 zurück — die Ordnung ändert sich, die alte Scroll-Position ist bedeutungslos.

Infinite Scroll, aber ehrlich

Für das Scrollen selbst gab es eine Grundsatzentscheidung: URL-pfadbasiert (/page/{n}) oder Query-String (?page=n). Google empfiehlt seit 2025 eher Query-Strings, weil Search Console Attribution damit sauberer ist. Aber: Du wolltest Pfade, und das hat noch einen Vorteil — jede Seite ist eine eigene SSR-Ressource, rel=next/rel=prev lässt sich sauber ausspielen, und Crawler folgen dem Link-Graphen ohne JavaScript-Ausführung.

Das Infinite-Scroll-Verhalten sitzt in einer neuen InfiniteScroll.razor-Komponente:

  • Am unteren Rand der Seite sitzt ein 1px-Sentinel, an dem ein IntersectionObserver hängt. Kommt der Sentinel ins Viewport, wird OnLoadMore ausgelöst und die nächste Seite hinzugeladen.
  • Unter dem Sentinel gibt es immer einen sichtbaren „Load more"-Button. Aus drei Gründen: Barrierefreiheit (Tastaturnutzer erreichen den Sentinel nie), SEO (Crawler folgen dem <a href="?page=2">), und Fallback (wenn der SignalR-Circuit hängt, klickt der Leser halt).
  • Beim Append wird die URL via history.replaceState auf die neu geladene Seite umgeschrieben. Das lässt Blazor's Router in Ruhe und aktualisiert trotzdem Browser-Adresse und Sharing-Link.

Deep-Link auf /page/3 lädt nur Seite 3, nicht 1-2-3. Darüber erscheint ein „Vorherige Seiten anzeigen"-Link, der zurück zu /page/1 führt. So wie Medium oder Dev.to es machen — kein vergeudetes Payload, jede URL ist eigenständig indexierbar.

Und ja, wir haben zwei hässliche Blazor-Bugs dabei eingesammelt und dokumentiert:

  • Bei OnAfterRenderAsync kann eine Race zwischen zwei aufeinanderfolgenden Renders entstehen: beide sehen _observerAttached=false, beide versuchen zu attachen. Die Lösung war, das Flag vor dem await zu setzen, nicht danach.
  • DotNetObjectReference darf nicht im detach-Pfad gedisposed werden, wenn der Ref zwischen Renders wiederverwendet wird — das unsichtbar ungültige Ref hat den IntersectionObserver-Callback stumm verpuffen lassen. Jetzt gibt es zwei getrennte Funktionen: detach (nur Observer) und teardown (Observer + Ref, nur im Dispose-Pfad).

Share-Vorschauen: 1200×630 pro Geschichte

Bislang hat jede Geschichts-Detailseite ein og:image bekommen, und zwar das Portrait-Cover (3:4). Das sieht auf Facebook und LinkedIn grausam aus — die platformen strecken oder croppen, kein Text sitzt am richtigen Platz, die Vorschau verliert den Kontext.

Die Lösung: eine eigene 1200×630-Landscape-Variante pro Geschichte, serverseitig komponiert.

Aufgeschlagenes Buch auf einem Tisch Foto: Unsplash

Der neue Pfad:

  • Eine OgImageGenerationMessage fliegt bei jeder Publish-Aktion in die Service-Bus-Queue ogimageprocessor. Als Geschwister der Cover-Queue, im selben Azure-Functions-Projekt (OutaStory.ImageProcessor).
  • Eine Funktion verwendet SkiaSharp, um das echte Cover (Hintergrund-Gradient verbrannt mit der Cover-Abbildung links) mit Titel (Inter Bold 52px, max. 3 Zeilen) und Autor rechts zu montieren. Rechts unten sitzt ein „OutaStory"-Wordmark.
  • Der Blob-Name ist content-addressed: story-{id}-{sha256[:16]}.jpg. Gleiche Eingänge → gleicher Name → sichere Cache-Control: public, max-age=604800, immutable-Kopfzeilen, weil die URL sich nie unter demselben Inhalt ändert.
  • Ändert sich Cover oder Titel, ändert sich der Hash. Neuer Blob, neue URL, der alte wird gelöscht. Kein Ghost.

ImageSharp hätten wir lieber gehabt, aber die Lizenz seit Mitte 2022 ist für kommerzielle Nutzung über 1 Mio. USD Umsatz kostenpflichtig. SkiaSharp ist frei, läuft in Linux-Containern mit dem NativeAssets.Linux.NoDependencies-Paket (statisch gelinkt, keine libfontconfig-Abhängigkeiten), und rendert Text dank HarfBuzz-Integration sauber. Die Inter-Fonts liegen unter SIL OFL — wir committen sie in den Repo, der Azure-Functions-Container bringt sie damit mit, kein apt-get in der Deployment-Pipeline.

Der Reader-Fallback: wenn die Cover-Datei für die OG-Generierung nicht erreichbar ist, fällt der Compositor auf ein reines Text-Layout (Gradient + Titel + Autor) zurück, statt die Pipeline hart scheitern zu lassen. Ein Service-Bus-Blip darf die OG-Seite nicht leer lassen.

Nebenher: og:image:alt und twitter:image:alt werden jetzt automatisch als "Cover art for '{Titel}' von {Autor}" gebildet, wenn die Seite es nicht explizit überschreibt. Twitter/X liest das an Screenreader aus, LinkedIn nutzt es als kleines Ranking-Signal. Kostet nichts, nützt.

„Weiterlesen" auf der Detailseite

Die Geschichtsdetailseite hatte bisher einen einzigen „Start Reading"-Button, der immer auf /read/1 landete. Egal ob der Leser die Geschichte schon halb durch hat — Seite 1 hallo und los.

Jetzt ist der Button kontextabhängig:

  • Unangetastet → „Start Reading" → /read/1
  • Laufend (45%) → „Continue reading • 45%" → /read/{LastReadPage} + eine Zeile darunter „Picks up on page 3."
  • Fertig → „Finished ✓ — Read again" → /read/1 (Neustart)

Für die genaue Seite brauchten wir ein neues Feld in der DB: StoryReadingProgress.LastReadPage. Im Gegensatz zu PercentComplete ist das nicht forward-only — wer von Seite 7 zurück auf Seite 3 blättert, schreibt die 3 rein. Die Leser-Intuition ist „nimm mich dahin, wo ich zuletzt war", nicht „dorthin, wo ich je am weitesten war". Prozent bleibt ein Forward-only-Fortschrittsmaß, Page ist der letzte Aufenthaltsort.

Das Feature funktioniert identisch für angemeldete und anonyme Leser. Der ReadingProgressService-Facade entscheidet hinter den Kulissen, ob er am API oder an localStorage landet — die Detailseite merkt davon nichts.

Android: Closed Alpha freigegeben

Und endlich, nach etlichen Pipeline-Iterationen: die Android-App ist von Google Play für Closed Testing auf der Alpha-Spur freigegeben. Die CI-Pipeline fing vorher an einem irreführenden API-Fehler an („Only releases with status draft may be created on draft app"), der in Wahrheit bedeutete: „Dein App-Listing ist noch im Draft-State, auf Alpha darfst du gar nicht hochladen, Internal ist der einzige Kanal, der funktioniert."

Die Lösung war dreifach:

  1. Der deprecated track:-Input unserer Upload-Action wurde stillschweigend von einem neuen tracks:-Default (production) überschrieben. Wir sind auf die plurale Form umgestiegen.
  2. Play Console → Store Listing + App Content komplett grün, Privacy Policy, Content Rating, Data Safety — alles ausgefüllt.
  3. Der Track ist jetzt über eine ANDROID_PLAY_TRACK-Repo-Variable konfigurierbar (Standard internal). Von internal nach alpha nach production ist nur noch ein Variablen-Flip, keine YAML-Änderung.

Die iOS-App läuft parallel durch den App Review für TestFlight Closed. Sobald die zwei zusammen sind, gehen die Alpha-Einladungen raus.

Was kommt als nächstes

Closed Alpha, das heißt: echte Leute, echtes Feedback, echte Zahlen. Alles bisher auf meinem Rechner hat zwar Tests, aber Tests können nicht simulieren, was passiert, wenn jemand die App zum ersten Mal öffnet und die nächsten zwei Minuten damit verbringt, den falschen Button zu suchen. Die nächsten Wochen gehen daran, aus dem Alpha-Feedback die nächsten Prioritäten herauszuhören — und dann die Roadmap neu zu sortieren.


Comments (0)

No comments yet.

Comments are closed for this post.

Something went wrong

An unhandled error has occurred.

Reload the page to pick up where you left off.

Reload

Connection lost

Reconnecting to the server…

Reconnect failed. Trying again in seconds.

We couldn't reconnect to the server. Retry, or reload the page.

The session was paused by the server.

The session couldn't be resumed. Please reload the page.