Paginierung, Share-Vorschauen, Weiterlesen: Die letzte Runde vor der Closed Alpha
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=newliefert einPagedResult<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 vierstufigenParentCategory-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 demselbenPublishedDatezwischen 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
IntersectionObserverhängt. Kommt der Sentinel ins Viewport, wirdOnLoadMoreausgelö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.replaceStateauf 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
OnAfterRenderAsynckann eine Race zwischen zwei aufeinanderfolgenden Renders entstehen: beide sehen_observerAttached=false, beide versuchen zu attachen. Die Lösung war, das Flag vor demawaitzu setzen, nicht danach. DotNetObjectReferencedarf nicht imdetach-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) undteardown(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.
Foto: Unsplash
Der neue Pfad:
- Eine
OgImageGenerationMessagefliegt bei jeder Publish-Aktion in die Service-Bus-Queueogimageprocessor. 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 → sichereCache-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:
- Der deprecated
track:-Input unserer Upload-Action wurde stillschweigend von einem neuentracks:-Default (production) überschrieben. Wir sind auf die plurale Form umgestiegen. - Play Console → Store Listing + App Content komplett grün, Privacy Policy, Content Rating, Data Safety — alles ausgefüllt.
- Der Track ist jetzt über eine
ANDROID_PLAY_TRACK-Repo-Variable konfigurierbar (Standardinternal). Voninternalnachalphanachproductionist 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.
