Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f8003945 | ||
|
|
9ee8664233 | ||
|
|
cd9953c47c | ||
|
|
18c87e6b2e | ||
|
|
afa3d74248 | ||
|
|
b3eb946cfa | ||
|
|
29ef710b9b | ||
|
|
3db6241e9a | ||
|
|
d8239441aa | ||
|
|
96a42594a9 | ||
|
|
a54e625ca8 | ||
|
|
ff9d380d42 | ||
|
|
8d6f1d044a | ||
|
|
cf38bd6854 | ||
|
|
0adcccc01a | ||
|
|
f02e3597b9 | ||
|
|
a9314acbb4 | ||
|
|
bb63827bf7 | ||
|
|
b25f0473fd | ||
|
|
969353ab32 | ||
|
|
f49ac17922 | ||
|
|
7ac598ea81 | ||
|
|
075b42584a | ||
|
|
8c872b240a | ||
|
|
0e51df7bad | ||
|
|
b53ee828ba | ||
|
|
ed6011384a | ||
|
|
5cf4fc48a6 | ||
|
|
5ee6946fd5 | ||
|
|
90497ddc3b | ||
|
|
190d7b3796 |
@@ -4,6 +4,7 @@ node_modules
|
||||
.env
|
||||
.env.local
|
||||
data
|
||||
backups
|
||||
docs/screenshots
|
||||
*.log
|
||||
test-results
|
||||
|
||||
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
|
||||
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Port, auf dem Flimmer auf dem Host lauscht.
|
||||
APP_PORT=3000
|
||||
|
||||
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
||||
TZ=Europe/Berlin
|
||||
|
||||
@@ -45,6 +48,9 @@ TMDB_ACCESS_TOKEN=
|
||||
# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit
|
||||
TMDB_LANGUAGE=de-DE
|
||||
TMDB_REGION=DE
|
||||
# Nur nötig, wenn TMDB über einen eigenen Proxy erreicht werden soll.
|
||||
# TMDB_BASE_URL=https://api.themoviedb.org/3
|
||||
# TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p
|
||||
|
||||
# --- Hintergrundläufe --------------------------------------------------------
|
||||
# Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Prüft jeden Push: Typen, Tests, Produktionsbuild.
|
||||
#
|
||||
# Die Tests brauchen eine echte PostgreSQL-Instanz — sie prüfen Abfragen und
|
||||
# Fortschrittsberechnungen gegen die Datenbank, nicht gegen Attrappen. Einen
|
||||
# TMDB-Schlüssel brauchen sie nicht: die TMDB-Antworten sind nachgebildet.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
pruefen:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: flimmer
|
||||
POSTGRES_PASSWORD: flimmer-ci
|
||||
POSTGRES_DB: flimmer
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U flimmer -d flimmer"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://flimmer:flimmer-ci@localhost:5432/flimmer?schema=public
|
||||
AUTH_SECRET: ci-geheimnis-0123456789abcdef0123456789abcdef
|
||||
APP_URL: http://localhost:3000
|
||||
TZ: Europe/Berlin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Abhängigkeiten installieren
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Prisma-Client erzeugen
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Schema einspielen
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: Typen prüfen
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
- name: Produktionsbuild
|
||||
run: npx next build
|
||||
@@ -0,0 +1,56 @@
|
||||
# Legt beim Pushen eines Versions-Tags automatisch das Release an.
|
||||
#
|
||||
# Die Notizen kommen aus releases/vX.Y.Z.md — derselben Datei, die auch von
|
||||
# Hand für ein Release taugt. So gibt es genau eine Quelle für den Text, und
|
||||
# der Ablauf funktioniert mit und ohne CI.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
veroeffentlichen:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Release-Notizen einsammeln
|
||||
id: notes
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
FILE="releases/${GITHUB_REF_NAME}.md"
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "Es gibt keine $FILE — bitte anlegen, bevor der Tag gepusht wird." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Die Überschrift der Datei ist der Titel; alles darunter der Text.
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "body<<FLIMMER_EOF"
|
||||
tail -n +2 "$FILE"
|
||||
echo "FLIMMER_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Release anlegen
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BODY: ${{ steps.notes.outputs.body }}
|
||||
VERSION: ${{ steps.notes.outputs.version }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "$GITHUB_REF_NAME" \
|
||||
--arg name "$VERSION" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
|
||||
curl -sSf -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
||||
@@ -11,6 +11,7 @@ next-env.d.ts
|
||||
|
||||
# Produktion / Laufzeit
|
||||
data/
|
||||
backups/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Umgebung — niemals committen!
|
||||
@@ -42,3 +43,4 @@ Thumbs.db
|
||||
/blob-report/
|
||||
.playwright/
|
||||
src/generated/
|
||||
.env.composetest
|
||||
|
||||
@@ -7,6 +7,221 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.7.1] — 2026-09-01
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
|
||||
## [0.7.0] — 2026-09-01
|
||||
|
||||
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
||||
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
|
||||
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
|
||||
zeigt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
|
||||
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
|
||||
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
|
||||
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
|
||||
nirgends mehr laufen, will man ja wiederfinden.
|
||||
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
|
||||
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
|
||||
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
|
||||
- Eigener Filter in der Bibliothek samt Zähler
|
||||
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
|
||||
Lesen, `owned` und `ownedSource` beim Ändern)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
|
||||
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
|
||||
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
|
||||
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
|
||||
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
|
||||
getracten Abhängigkeitsbaum der Anwendung nicht stört.
|
||||
|
||||
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
|
||||
Outfit beim Bauen von Google herunter — das macht den Build von einer
|
||||
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
|
||||
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
|
||||
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
|
||||
das Richtige.
|
||||
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
|
||||
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
|
||||
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
|
||||
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
|
||||
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
|
||||
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
|
||||
immer verhindern.
|
||||
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
|
||||
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
|
||||
Pushen eines Versions-Tags das Release mit den Notizen aus
|
||||
`releases/vX.Y.Z.md` an.
|
||||
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
|
||||
öffentliche Profil in die Anleitung aufgenommen.
|
||||
|
||||
### Entfernt
|
||||
|
||||
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
|
||||
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
|
||||
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
|
||||
|
||||
## [0.6.0] — 2026-09-01
|
||||
|
||||
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
|
||||
— und eine bebilderte Anleitung.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
|
||||
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
|
||||
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
|
||||
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
|
||||
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
|
||||
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
|
||||
Logo und Farbwelt von Flimmer.
|
||||
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
|
||||
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
|
||||
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
|
||||
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
|
||||
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
|
||||
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
|
||||
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
|
||||
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
|
||||
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
|
||||
Universen.
|
||||
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
|
||||
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
|
||||
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
|
||||
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
|
||||
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
|
||||
API-Schlüssel sowie Import und Export.
|
||||
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
|
||||
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
|
||||
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
|
||||
Administration lässt sich nicht entmachten.
|
||||
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
|
||||
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
|
||||
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
|
||||
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
|
||||
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
|
||||
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
|
||||
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
|
||||
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
|
||||
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
|
||||
laufenden Instanz, aufgenommen von `npm run screenshots`.
|
||||
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
|
||||
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
|
||||
|
||||
## [0.5.0] — 2026-09-01
|
||||
|
||||
Übersicht, Kalender und Statistik.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
|
||||
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
|
||||
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
|
||||
verfügbar geworden sind.
|
||||
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
|
||||
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
|
||||
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
|
||||
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
|
||||
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
|
||||
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
|
||||
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
|
||||
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
|
||||
zählen mit.
|
||||
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
|
||||
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
|
||||
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
|
||||
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
|
||||
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
|
||||
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
|
||||
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
|
||||
Standardton.
|
||||
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
|
||||
„Weiterschauen" und den Kalenderzeitraum.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
|
||||
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
|
||||
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
|
||||
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
|
||||
(„2026-04") aus der Datenbank.
|
||||
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
|
||||
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
|
||||
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
|
||||
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
|
||||
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
|
||||
hängt jetzt als `aria-label` am `<svg>`.
|
||||
|
||||
## [0.4.0] — 2026-09-01
|
||||
|
||||
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
|
||||
Wizarding World oder was auch immer zusammengehört.
|
||||
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
|
||||
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
|
||||
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
|
||||
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
|
||||
Tastatur nicht bedienbar und auf dem Handy fummelig.
|
||||
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
|
||||
vorkommen, lassen sich mit einem Klick übernehmen.
|
||||
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
|
||||
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
|
||||
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
|
||||
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
|
||||
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
|
||||
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
|
||||
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
|
||||
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
|
||||
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
|
||||
Serien hinweg, Freigaben und die Rechteprüfung.
|
||||
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
|
||||
Serien und einer abweichenden Story-Chronologie.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
|
||||
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
|
||||
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
|
||||
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
|
||||
serverseitig.
|
||||
|
||||
## [0.3.0] — 2026-09-01
|
||||
|
||||
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
|
||||
@@ -132,7 +347,12 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.3.0...HEAD
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.1...HEAD
|
||||
[0.7.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.1
|
||||
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
|
||||
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
|
||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
||||
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
|
||||
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
|
||||
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
|
||||
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
|
||||
|
||||
@@ -100,8 +100,25 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
||||
|
||||
## Fortschritt
|
||||
|
||||
- ✅ **Etappe 1** (v0.1.0, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
|
||||
- ⏳ **Etappe 2** — TMDB-Anbindung
|
||||
- ✅ **Etappe 1** (v0.1.0) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
|
||||
- ✅ **Etappe 2** (v0.2.0) — TMDB: Suche, Import, Episoden, Bild-Cache. 14 min.
|
||||
- ✅ **Etappe 3** (v0.3.0) — Bibliothek, Status, Episodenfortschritt, Papierkorb. 24 min.
|
||||
- ✅ **Etappe 4** (v0.4.0) — Universen mit Reihenfolgen, Freigaben. 14 min.
|
||||
- ✅ **Etappe 5** (v0.5.0) — Übersicht, Kalender, Statistik. 15 min.
|
||||
- ✅ **Etappe 6** (v0.6.0) — Benachrichtigungen, Abgleich, Import/Export, REST-API, PWA, Anleitung. 31 min.
|
||||
|
||||
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 min.**
|
||||
|
||||
### Nützliches für spätere Arbeiten
|
||||
- Suche ohne TMDB-Schlüssel ausprobieren:
|
||||
`npm run tmdb:stub` und dann
|
||||
`TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 PORT=3011 npm run dev`
|
||||
- Screenshots neu aufnehmen: `npm run screenshots` (nimmt auf und verkleinert)
|
||||
- `npm test` läuft seriell gegen die Dev-Datenbank, braucht keinen TMDB-Schlüssel
|
||||
- Deutsche Anführungszeichen (`„…"`) niemals in JS-**String-Literalen** —
|
||||
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
|
||||
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
|
||||
Write-Werkzeug benutzen.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
|
||||
@@ -49,6 +49,60 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 4 — Universen mit Reihenfolgen und Fortschritt (v0.4.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 4.1 | 19:25 | 19:30 | 5 min | Domänenlogik: Universen, berechnete Erscheinungsreihenfolge, frei sortierbare Reihenfolgen, Fortschritt aus Sicht des Betrachters, Freigaben |
|
||||
| 4.2 | 19:30 | 19:33 | 3 min | 12 Tests dazu; eine falsche Testannahme zur Reihenfolge korrigiert (nicht den Code) |
|
||||
| 4.3 | 19:33 | 19:36 | 3 min | Server-Aktionen, sortierbare Liste mit Ziehen und Pfeilknöpfen, Reihenfolge-Umschalter, Aufnehmen, Teilen |
|
||||
| 4.4 | 19:36 | 19:39 | 3 min | Seiten für Übersicht, Anlegen und Detailansicht; Build-Fehler durch `server-only` im Browser-Bundle gefunden und behoben; Umsortieren im Browser geprüft |
|
||||
|
||||
**Etappe 4 gesamt: 14 min** (2026-09-01, 19:25 – 19:39)
|
||||
|
||||
---
|
||||
|
||||
## Etappe 5 — Dashboard, Kalender, Statistiken (v0.5.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 5.1 | 19:39 | 19:43 | 4 min | Auswertungsschicht: Weiterschauen, kommende Episoden, neu verfügbar, Gesamtzahlen, Monatsverlauf, Verteilungen, Jahresrückblick |
|
||||
| 5.2 | 19:43 | 19:46 | 3 min | Diagrammfarben gegen die Prüfungen auf Helligkeit, Sättigung, Farbfehlsichtigkeit und Kontrast gerechnet; SVG-Diagramme und Verteilungsbalken gebaut |
|
||||
| 5.3 | 19:46 | 19:50 | 4 min | Seiten für Übersicht, Kalender und Statistik |
|
||||
| 5.4 | 19:50 | 19:54 | 4 min | Drei Fehler gefunden und behoben: leeres Monatsdiagramm durch Zeitzonenversatz, Hydrationsbruch durch SVG-Titel, Kalender ohne abgeschlossene Serien; 7 Tests nachgezogen |
|
||||
|
||||
**Etappe 5 gesamt: 15 min** (2026-09-01, 19:39 – 19:54)
|
||||
|
||||
---
|
||||
|
||||
## Etappe 6 — Benachrichtigungen, Sync, Import/Export, REST-API, PWA (v0.6.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 6.1 | 19:54 | 19:58 | 4 min | Benachrichtigungen über vier Wege samt E-Mail-Vorlage; Hintergrundlauf mit Zeitplan und Cron-Endpunkt |
|
||||
| 6.2 | 19:58 | 20:02 | 4 min | Import (Trakt, CSV) und Export (CSV, JSON) |
|
||||
| 6.3 | 20:02 | 20:07 | 5 min | REST-API `/api/v1` mit API-Schlüsseln, OpenAPI-Beschreibung |
|
||||
| 6.4 | 20:07 | 20:12 | 5 min | Einstellungsseiten, Verwaltung, öffentliches Profil; Service Worker und Offline-Seite |
|
||||
| 6.5 | 20:12 | 20:15 | 3 min | 9 Tests für Import, Export, Papierkorb und Doppelversandschutz |
|
||||
| 6.6 | 20:15 | 20:20 | 5 min | TMDB-Ersatz für Screenshots, 18 Aufnahmen, Verkleinerung auf WebP (13 MB → 0,6 MB) |
|
||||
| 6.7 | 20:20 | 20:25 | 5 min | `docs/ANLEITUNG.md` und `docs/API.md`, README erweitert, API von Hand durchgetestet |
|
||||
|
||||
**Etappe 6 gesamt: 31 min** (2026-09-01, 19:54 – 20:25)
|
||||
|
||||
---
|
||||
|
||||
## Nach der Abnahme (v0.7.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 7.1 | 20:25 | 20:50 | 25 min | Docker-Image zum ersten Mal wirklich gebaut und gestartet; zwei Fehler behoben (fehlende Prisma-Abhängigkeiten im Laufzeitimage, Schriftabruf beim Bauen), Gitea-Workflows, Erscheinungsmeldung nachgezogen |
|
||||
| 7.2 | 20:50 | 21:05 | 15 min | Compose-Stack von null verifiziert: Migration, erstes Konto wird Administrator, Verwaltung erreichbar |
|
||||
| 7.3 | 21:05 | 21:16 | 11 min | Rückfrage aus dem Betrieb umgesetzt: „hab ich" für die eigene Sammlung (Jellyfin, Plex, NAS, Blu-ray) — Schema, Logik, Oberfläche, Filter, Import/Export, API, Tests, Anleitung |
|
||||
|
||||
**Nacharbeiten gesamt: 51 min** (2026-09-01, 20:25 – 21:16)
|
||||
|
||||
---
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Etappe | Version | Dauer |
|
||||
@@ -56,5 +110,41 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
|
||||
| 2 — TMDB-Anbindung | 0.2.0 | 14 min |
|
||||
| 3 — Kern-Tracking | 0.3.0 | 24 min |
|
||||
| 4 — Universen | 0.4.0 | 14 min |
|
||||
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
||||
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
||||
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
|
||||
|
||||
**Bisher gesamt: 76 min**
|
||||
**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
|
||||
|
||||
### Womit die Zeit verging
|
||||
|
||||
| Anteil | Bereich |
|
||||
|---|---|
|
||||
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
|
||||
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
|
||||
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
|
||||
| ~25 min | Tests — 52 Stück, die unterwegs acht echte Fehler gefunden haben |
|
||||
| ~15 min | Dokumentation, Screenshots, Changelog, Releases |
|
||||
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
|
||||
|
||||
### Fehler, die die Tests gefunden haben
|
||||
|
||||
1. `importSeries` gab veraltete Staffel- und Episodenzähler zurück.
|
||||
2. Testdateien räumten sich im parallelen Lauf gegenseitig die Fixtures weg.
|
||||
3. Das Monatsdiagramm blieb wegen eines Zeitzonenversatzes leer.
|
||||
4. Abgeschlossene, aber weiterlaufende Serien verschwanden aus dem Kalender.
|
||||
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
|
||||
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
|
||||
7. Der SVG-Titel eines Diagramms brach die Hydration.
|
||||
8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code.
|
||||
|
||||
### Fehler, die erst der echte Containerstart zeigte
|
||||
|
||||
1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die
|
||||
Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe
|
||||
vollständig installiert.
|
||||
2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die
|
||||
plattformpassende Prisma-Engine.
|
||||
3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build
|
||||
im Container hängen. Die Schrift liegt jetzt im Repository.
|
||||
|
||||
@@ -16,6 +16,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
# npm blockt Install-Skripte seit Version 11. Welche laufen dürfen, steht im
|
||||
# Feld "allowScripts" der package.json — für Prisma, damit die zur Plattform
|
||||
# passende Engine bereitgelegt wird.
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# --- Build -------------------------------------------------------------------
|
||||
@@ -32,6 +35,18 @@ ENV NODE_ENV=production
|
||||
|
||||
RUN npx prisma generate && npx next build
|
||||
|
||||
# --- Prisma-CLI --------------------------------------------------------------
|
||||
# Eigene Stufe, damit die CLI samt ihrer transitiven Abhängigkeiten vollständig
|
||||
# ins Laufzeitimage kommt. Einzelne Pakete aus node_modules herauszupicken ging
|
||||
# schief: @prisma/config braucht weitere Module, die dabei fehlten.
|
||||
FROM base AS prisma-cli
|
||||
WORKDIR /cli
|
||||
# Die package.json liegt als Datei bei: nur mit dem Feld "allowScripts" lässt
|
||||
# npm die Install-Skripte von Prisma zu — sonst fehlt im Image die zur
|
||||
# Plattform passende Engine.
|
||||
COPY docker/prisma-cli-package.json ./package.json
|
||||
RUN npm install --omit=dev --no-audit --no-fund prisma@7.10.0
|
||||
|
||||
# --- Laufzeit ----------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -48,12 +63,16 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=flimmer:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=flimmer:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Prisma-CLI und Schema bleiben im Image, damit der Container beim Start seine
|
||||
# Schema und Migrationen bleiben im Image, damit der Container beim Start seine
|
||||
# eigenen Migrationen ausspielen kann (`prisma migrate deploy`).
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.mjs ./prisma.config.mjs
|
||||
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# Die CLI liegt bewusst unter /node_modules statt in /app/node_modules: dort
|
||||
# stört sie den getracten Abhängigkeitsbaum der Anwendung nicht, und Node
|
||||
# findet sie beim Auflösen von "prisma/config" trotzdem — die Suche wandert
|
||||
# von /app aus nach oben.
|
||||
COPY --from=prisma-cli /cli/node_modules /node_modules
|
||||
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh \
|
||||
|
||||
@@ -15,6 +15,14 @@ der Flimmerkiste und dem warmen Flimmern, das ein Bildschirm im dunklen Zimmer
|
||||
verbreitet. Metadaten kommen von [TMDB](https://www.themoviedb.org/), die Daten
|
||||
bleiben auf deinem Server.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/02-uebersicht.webp" alt="Die Übersicht von Flimmer" width="820">
|
||||
</p>
|
||||
|
||||
> **Bebilderte Anleitung:** [docs/ANLEITUNG.md](docs/ANLEITUNG.md) —
|
||||
> führt einmal durch die ganze Anwendung.
|
||||
> **Schnittstelle:** [docs/API.md](docs/API.md).
|
||||
|
||||
## Was Flimmer kann
|
||||
|
||||
- **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert,
|
||||
@@ -25,12 +33,20 @@ bleiben auf deinem Server.
|
||||
Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt.
|
||||
- **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle
|
||||
Collections, Streaming-Verfügbarkeit in deiner Region.
|
||||
- **Eigene Sammlung** — markiere, was du schon hast, und wo: Jellyfin, Plex,
|
||||
NAS, Blu-ray. Gerade die Titel, die nirgends mehr streamen, findest du so
|
||||
wieder.
|
||||
- **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy,
|
||||
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
|
||||
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
|
||||
sich teilen. Der erste registrierte Zugang wird Administrator.
|
||||
- **Übersicht, Kalender und Statistik** — was als Nächstes dran ist, wann welche
|
||||
Episode läuft, und wie viel Zeit wohin geflossen ist.
|
||||
- **Import und Export** — aus Trakt.tv oder CSV hinein, als CSV oder JSON hinaus.
|
||||
- **REST-API und PWA** — dokumentierte Schnittstelle mit eigenen API-Schlüsseln,
|
||||
installierbar auf dem Startbildschirm.
|
||||
installierbar auf dem Startbildschirm, mit Offline-Rückfall.
|
||||
- **Papierkorb statt Löschen** — 30 Tage Bedenkzeit, und jede Sichtung lässt sich
|
||||
mit einem Klick zurücknehmen.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
@@ -98,14 +114,31 @@ flimmer.example.com {
|
||||
|
||||
```bash
|
||||
cd flimmer
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener
|
||||
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist
|
||||
trotzdem eine gute Angewohnheit.
|
||||
Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
|
||||
und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
|
||||
konkreten Weg zurück — mit dem Befehl und der Sicherungsdatei, nicht bloß dem
|
||||
Rat, ein Backup zu haben.
|
||||
|
||||
```
|
||||
./update.sh --ohne-backup ohne Datenbanksicherung
|
||||
./update.sh --ohne-pull nur neu bauen und starten
|
||||
./update.sh --behalte 20 wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||
./update.sh --hilfe
|
||||
```
|
||||
|
||||
Die Sicherungen landen unter `backups/` und sind gzip-komprimierte `pg_dump`s.
|
||||
|
||||
Von Hand geht es genauso:
|
||||
|
||||
```bash
|
||||
git pull && docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen in beiden Fällen automatisch beim Start des
|
||||
Containers — ein eigener Handgriff ist nicht nötig.
|
||||
|
||||
## Backup
|
||||
|
||||
@@ -165,22 +198,56 @@ npm run dev
|
||||
| `npm run db:migrate` | Migration erzeugen und anwenden |
|
||||
| `npm run db:studio` | Prisma Studio |
|
||||
| `npm run db:seed` | Beispieldaten |
|
||||
| `npm test` | Testlauf gegen die Entwicklungsdatenbank (braucht keinen TMDB-Schlüssel) |
|
||||
| `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen |
|
||||
| `npm run screenshots` | Screenshots der Anleitung neu aufnehmen |
|
||||
|
||||
Ohne TMDB-Schlüssel lässt sich die Suche mit einem lokalen Ersatz ausprobieren,
|
||||
der aus den Beispieldaten antwortet:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/tmdb-stub.mjs 3099
|
||||
TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||
```
|
||||
|
||||
Aufbau des Projekts:
|
||||
|
||||
```
|
||||
src/
|
||||
app/ Routen (App Router), API-Endpunkte
|
||||
app/ Routen (App Router), API-Endpunkte, Server-Aktionen
|
||||
components/ Wiederverwendbare Bausteine
|
||||
lib/ Serverseitige Logik: DB, Auth, TMDB, Benachrichtigungen
|
||||
lib/ Serverseitige Logik: DB, Auth, TMDB, Tracking, Statistik,
|
||||
Universen, Benachrichtigungen, Abgleich, Import/Export
|
||||
i18n/ Oberflächentexte
|
||||
prisma/ Schema, Migrationen, Seed
|
||||
prisma/ Schema, Migrationen, Seed samt Beispielkatalog
|
||||
tests/ Testlauf mit nachgebildeten TMDB-Antworten
|
||||
public/brand/ Logo in allen Varianten
|
||||
docker/ Entrypoint des Containers
|
||||
docs/ Anleitung, Design-Dokumentation
|
||||
docs/ Anleitung mit Screenshots, API, Design-Dokumentation
|
||||
```
|
||||
|
||||
### Automatisierung
|
||||
|
||||
Unter `.gitea/workflows/` liegen zwei Abläufe für Gitea Actions: `ci.yml`
|
||||
prüft bei jedem Push Typen, Tests und Produktionsbuild gegen eine echte
|
||||
PostgreSQL-Instanz, `release.yml` legt beim Pushen eines Versions-Tags das
|
||||
Release an — mit den Notizen aus `releases/vX.Y.Z.md`.
|
||||
|
||||
## Hintergrundlauf
|
||||
|
||||
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
|
||||
Episoden, prüft die Verfügbarkeit von Watchlist-Titeln und leert den
|
||||
Papierkorb. Gesteuert wird das über `ENABLE_SCHEDULER` und `SYNC_HOUR`.
|
||||
|
||||
Wer lieber einen eigenen Cron benutzt, setzt `ENABLE_SCHEDULER=false`, vergibt
|
||||
ein `CRON_SECRET` und ruft auf:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://flimmer.example.com/api/cron/sync
|
||||
```
|
||||
|
||||
In der Verwaltung lässt sich der Lauf jederzeit von Hand anstoßen.
|
||||
|
||||
## Technik
|
||||
|
||||
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
|
||||
|
||||
@@ -40,7 +40,7 @@ attempt();
|
||||
'
|
||||
|
||||
echo "Flimmer: spiele Migrationen ein ..."
|
||||
node node_modules/prisma/build/index.js migrate deploy
|
||||
node /node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
echo "Flimmer: starte Anwendung."
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "flimmer-prisma-cli",
|
||||
"private": true,
|
||||
"description": "Nur zum Installieren der Prisma-CLI ins Laufzeitimage. npm blockt Install-Skripte seit Version 11; ohne die Freigabe hier fehlt im Container die plattformpassende Engine. Bei einem Prisma-Update auch die Version in package.json anpassen.",
|
||||
"allowScripts": {
|
||||
"prisma@7.10.0": true,
|
||||
"@prisma/engines@7.10.0": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
<p align="center">
|
||||
<img src="../public/brand/flimmer-logo.svg" alt="Flimmer" width="320">
|
||||
</p>
|
||||
|
||||
# Flimmer — Anleitung
|
||||
|
||||
Diese Anleitung führt einmal durch die ganze Anwendung: vom ersten Konto über
|
||||
das tägliche Abhaken bis zu Universen, Statistik und Schnittstelle.
|
||||
|
||||
Die Bilder stammen aus einer Instanz mit Beispieldaten (`npm run db:seed`).
|
||||
Die Poster darin sind erfunden — mit hinterlegtem TMDB-Schlüssel stehen dort
|
||||
die echten.
|
||||
|
||||
## Inhalt
|
||||
|
||||
1. [Erster Start](#erster-start)
|
||||
2. [Die Übersicht](#die-übersicht)
|
||||
3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen)
|
||||
4. [Eine Serie verfolgen](#eine-serie-verfolgen)
|
||||
5. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung)
|
||||
6. [Die Bibliothek](#die-bibliothek)
|
||||
7. [Universen](#universen)
|
||||
8. [Kalender](#kalender)
|
||||
9. [Statistik](#statistik)
|
||||
10. [Einstellungen](#einstellungen)
|
||||
11. [Benachrichtigungen](#benachrichtigungen)
|
||||
12. [Import und Export](#import-und-export)
|
||||
13. [API-Schlüssel](#api-schlüssel)
|
||||
14. [Papierkorb](#papierkorb)
|
||||
15. [Verwaltung](#verwaltung)
|
||||
16. [Öffentliches Profil](#öffentliches-profil)
|
||||
17. [Auf dem Handy](#auf-dem-handy)
|
||||
18. [Helles Farbschema](#helles-farbschema)
|
||||
|
||||
---
|
||||
|
||||
## Erster Start
|
||||
|
||||

|
||||
|
||||
Nach der Installation ruf die Adresse deiner Instanz auf. Solange es noch kein
|
||||
einziges Konto gibt, ist die Registrierung offen — **der erste Zugang wird
|
||||
automatisch Administrator**. Lege ihn also gleich für dich an.
|
||||
|
||||
Danach kannst du die Registrierung in der [Verwaltung](#verwaltung) schließen,
|
||||
damit sich niemand sonst anmeldet.
|
||||
|
||||
> Ist SSO eingerichtet (`OIDC_*` in der `.env`), erscheint hier zusätzlich ein
|
||||
> Knopf für die Anmeldung über den eigenen Anbieter.
|
||||
|
||||
---
|
||||
|
||||
## Die Übersicht
|
||||
|
||||

|
||||
|
||||
Die Startseite beantwortet drei Fragen:
|
||||
|
||||
- **Weiterschauen** — welche Serien laufen gerade, und welche Episode ist als
|
||||
Nächstes dran. Der Knopf **+1 Episode** hakt genau diese Folge ab; du musst
|
||||
die Serie dafür nicht öffnen.
|
||||
- **Demnächst** — welche Episoden deiner Serien in den nächsten vier Wochen
|
||||
ausgestrahlt werden.
|
||||
- **Neu verfügbar** — welche Titel von deiner Watchlist zuletzt auf einem
|
||||
Streamingdienst in deiner Region aufgetaucht sind.
|
||||
|
||||
Ist die Bibliothek noch leer, steht hier stattdessen der Weg zur Suche.
|
||||
|
||||
---
|
||||
|
||||
## Titel finden und aufnehmen
|
||||
|
||||

|
||||
|
||||
Über **Suchen** durchsuchst du TMDB. Die Treffer lassen sich auf *Alles*,
|
||||
*Serien* oder *Filme* eingrenzen; die Suchanfrage steht in der Adresszeile und
|
||||
ist damit teilbar.
|
||||
|
||||
Ein Klick auf einen Treffer holt die vollständigen Daten — bei Serien alle
|
||||
Staffeln und Episoden — und öffnet die Detailseite. Von dort nimmst du den
|
||||
Titel auf, wahlweise als **Ich schaue das gerade** oder **Auf die Watchlist**.
|
||||
|
||||
> **Nichts dabei?** Unter *Titel selbst anlegen* trägst du etwas ein, das TMDB
|
||||
> nicht kennt — Heimvideos, Vorträge, regionale Produktionen. Solche Titel
|
||||
> werden vom automatischen Abgleich nie angefasst.
|
||||
|
||||
---
|
||||
|
||||
## Eine Serie verfolgen
|
||||
|
||||

|
||||
|
||||
Die Detailseite ist der Ort, an dem du eine Serie führst:
|
||||
|
||||
- **Fortschrittsbalken** mit gesehenen Episoden und verbleibender Laufzeit,
|
||||
berechnet aus der echten Episodenanzahl.
|
||||
- **Als Nächstes dran** nennt Staffel, Episode, Titel und Ausstrahlungsdatum.
|
||||
Der Knopf **+1 Episode** hakt sie ab.
|
||||
- Der **Status** (Schaue gerade, Watchlist, Pausiert, Abgeschlossen,
|
||||
Abgebrochen) ergibt sich beim Abhaken von selbst: die erste Episode startet
|
||||
die Serie, die letzte schließt sie ab. Pausiert und Abgebrochen bleiben
|
||||
stehen — wer bewusst pausiert hat, will das nicht durch ein Häkchen
|
||||
aufgehoben bekommen.
|
||||
- In der **Staffelliste** hakst du einzelne Episoden ab, ganze Staffeln auf
|
||||
einmal, oder über *Bis hierhin gesehen* alles Vorherige. Beim Abhaken glimmt
|
||||
die Zeile kurz warm auf.
|
||||
- Rechts stehen **Bewertung** (1 bis 10), **wo du das schaust** (mit Vorschlag
|
||||
aus der Streaming-Verfügbarkeit), **Tags** und ein **Notizfeld**.
|
||||
|
||||
Jede Sichtung meldet sich unten kurz zurück und lässt sich mit **Rückgängig**
|
||||
zurücknehmen.
|
||||
|
||||
Ist eine Serie durch, erscheint **Noch einmal schauen** — der Fortschritt
|
||||
beginnt von vorn, die alten Sichtungen bleiben für die Statistik erhalten. Die
|
||||
Karte zeigt dann „2. Durchlauf".
|
||||
|
||||
---
|
||||
|
||||
## Hab ich — die eigene Sammlung
|
||||
|
||||

|
||||
|
||||
„Wo läuft das?" beantwortet nur die halbe Frage. Manche Filme laufen nirgends
|
||||
mehr — die hat man gekauft und liegen bei sich zu Hause, in Jellyfin, Plex,
|
||||
Emby, auf dem NAS oder als Scheibe im Regal.
|
||||
|
||||
Dafür gibt es das Häkchen **Hab ich** auf der Detailseite, rechts über dem Feld
|
||||
„wo schaue ich das". Daneben trägst du ein, *wo* der Titel liegt; das Feld
|
||||
schlägt die gängigen Möglichkeiten vor, nimmt aber auch alles andere an.
|
||||
|
||||
Was das bewirkt:
|
||||
|
||||
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit —
|
||||
was du hast, ist die verlässlichere Antwort.
|
||||
- Poster in der Bibliothek bekommen ein Abzeichen, und statt des
|
||||
Streamingdienstes steht dort deine Quelle.
|
||||
- In der Filterleiste gibt es einen eigenen Knopf **Hab ich** samt Zähler.
|
||||
- Export und Import führen die Angabe mit (Spalten `HabIch` und `Sammlung`),
|
||||
ebenso die REST-API.
|
||||
|
||||
Bewusst getrennt vom Feld „wo schaue ich das": Das eine sagt, wo ein Titel
|
||||
gerade läuft — das ändert sich ständig. Das andere sagt, was dir gehört, und
|
||||
das bleibt.
|
||||
|
||||
---
|
||||
|
||||
## Die Bibliothek
|
||||
|
||||

|
||||
|
||||
Alles, was du verfolgst, als Posterraster. Jede Kachel zeigt Status,
|
||||
Bewertung und — bei Serien — den Fortschritt.
|
||||
|
||||
Filtern kannst du nach:
|
||||
|
||||
| Filter | Wirkung |
|
||||
|---|---|
|
||||
| Volltextsuche | Titel und Originaltitel |
|
||||
| Status | Schaue gerade, Watchlist, Pausiert, Abgeschlossen, Abgebrochen |
|
||||
| Typ | nur Serien oder nur Filme |
|
||||
| Genre | alle Genres, die in deiner Bibliothek vorkommen |
|
||||
| Dienst | Streamingdienste deiner Titel |
|
||||
| Tag | deine eigenen Schlagworte |
|
||||
|
||||
Sortieren geht nach zuletzt aktualisiert, zuletzt hinzugefügt, Titel, Bewertung
|
||||
oder Erscheinungsjahr. Die gesamte Auswahl steht in der Adresszeile — ein
|
||||
Lesezeichen auf „alle pausierten Krimis" ist damit möglich.
|
||||
|
||||
---
|
||||
|
||||
## Universen
|
||||
|
||||

|
||||
|
||||
Ein Universum bündelt Titel, die zusammengehören — MCU, Star Wars, Wizarding
|
||||
World — und zwar **Filme und Serien gemeinsam**.
|
||||
|
||||

|
||||
|
||||
Auf der Detailseite steht oben der Gesamtfortschritt: wie viel abgeschlossen
|
||||
ist, wie viel Zeit schon drinsteckt und wie lange es bis zum Ende noch dauert.
|
||||
Der erste noch offene Eintrag ist als **Als Nächstes dran** hervorgehoben.
|
||||
|
||||
**Mehrere Reihenfolgen** sind der Kern der Sache:
|
||||
|
||||
- Die **Erscheinungsreihenfolge** ist immer da und wird aus den
|
||||
Erscheinungsdaten berechnet — sie stimmt damit auch, wenn später etwas
|
||||
dazukommt.
|
||||
- Über **+ Reihenfolge** legst du weitere an, etwa *Chronologisch* oder
|
||||
*Für Einsteiger*. Sortiert wird per Ziehen oder über die Pfeile neben jedem
|
||||
Eintrag; gespeichert wird sofort.
|
||||
|
||||
Titel nimmst du rechts aus deiner Bibliothek auf. Gehört ein Film zu einer
|
||||
TMDB-Reihe (etwa „Spider-Man Collection"), lässt sich die ganze Reihe mit einem
|
||||
Klick übernehmen.
|
||||
|
||||
**Teilen** geht mit anderen Konten derselben Instanz, wahlweise nur zum Ansehen
|
||||
oder zum gemeinsamen Pflegen. Der Fortschritt bleibt dabei persönlich: jede
|
||||
Person sieht ihren eigenen Stand.
|
||||
|
||||
---
|
||||
|
||||
## Kalender
|
||||
|
||||

|
||||
|
||||
Das Monatsraster zeigt, wann Episoden deiner Serien laufen. Tage mit Terminen
|
||||
sind hinterlegt, der heutige Tag trägt den Signaturverlauf. Unter dem Raster
|
||||
stehen dieselben Termine noch einmal als Liste mit vollem Datum.
|
||||
|
||||
Mit den Pfeilen blätterst du durch die Monate, **Heute** springt zurück.
|
||||
|
||||
Abgebrochene Serien tauchen hier nicht auf. Abgeschlossene schon — wer bei
|
||||
einer laufenden Serie auf dem Stand ist, will die nächste Folge erst recht
|
||||
sehen.
|
||||
|
||||
---
|
||||
|
||||
## Statistik
|
||||
|
||||

|
||||
|
||||
Oben die **Gesamtschauzeit** in Stunden und umgerechnet in Tage am Stück, daneben
|
||||
Episoden, Filme und abgeschlossene Titel.
|
||||
|
||||
- **Die letzten zwölf Monate** — abgehakte Episoden und Filme je Monat.
|
||||
Beim Überfahren einer Säule erscheinen die genauen Zahlen samt Schauzeit.
|
||||
- **Nach Streamingdienst** — wo du tatsächlich geschaut hast. Grundlage ist das
|
||||
Feld *wo schaue ich das*, nicht die bloße Verfügbarkeit.
|
||||
- **Nach Genre** — Titel mit mehreren Genres zählen in jedem mit.
|
||||
- **Jahr in Zahlen** — Schauzeit, Episoden, Filme, neu Angefangenes,
|
||||
Abgeschlossenes, der stärkste Monat und der Titel mit der meisten Zeit.
|
||||
Über die Jahreszahlen wechselst du das Jahr.
|
||||
- Darunter **wo die meiste Zeit hinging** und **deine besten Bewertungen**.
|
||||
|
||||
Alles ist aus den Sichtungen gerechnet; Rewatches zählen mit.
|
||||
|
||||
---
|
||||
|
||||
## Einstellungen
|
||||
|
||||

|
||||
|
||||
Hier stellst du Anzeigename und **Region** ein — Letztere bestimmt, welche
|
||||
Streamingdienste angezeigt werden (Standard: DE).
|
||||
|
||||
Darunter legst du fest, **wovon Flimmer dir berichten soll**: neue Episoden,
|
||||
neue Releases, neu verfügbare Watchlist-Titel.
|
||||
|
||||
Wenn der Betreiber öffentliche Profile erlaubt, kannst du deins freischalten
|
||||
und eine Adresse wählen. Die Profilseite zeigt Zahlen und zuletzt Gesehenes —
|
||||
**keine** Notizen, keine Watchlist, keine E-Mail-Adresse.
|
||||
|
||||
Das Passwort änderst du im Abschnitt darunter; danach wirst du auf allen
|
||||
Geräten abgemeldet.
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||

|
||||
|
||||
*Wovon* berichtet wird, steht im Profil — *wohin*, hier. Vier Wege stehen zur
|
||||
Wahl:
|
||||
|
||||
| Kanal | Nötige Angaben |
|
||||
|---|---|
|
||||
| **E-Mail** | nichts (oder eine abweichende Adresse); der SMTP-Zugang kommt vom Betreiber |
|
||||
| **ntfy** | Server (Standard `https://ntfy.sh`) und Topic, optional ein Token |
|
||||
| **Gotify** | Serveradresse und Anwendungs-Token |
|
||||
| **Webhook** | die Zieladresse — funktioniert mit Discord, Slack und Mattermost |
|
||||
|
||||
Jeder Kanal lässt sich einzeln stummschalten und mit **Testen** ausprobieren.
|
||||
Unten steht, was zuletzt verschickt wurde — auch fehlgeschlagene Versuche mit
|
||||
Grund.
|
||||
|
||||
Dieselbe Meldung kommt nie zweimal: Flimmer merkt sich, was schon draußen ist.
|
||||
|
||||
---
|
||||
|
||||
## Import und Export
|
||||
|
||||

|
||||
|
||||
**Import** aus zwei Quellen:
|
||||
|
||||
- **CSV**, wie es Tabellenprogramme und viele Tracker ausgeben. Die Kopfzeile
|
||||
braucht mindestens eine Titelspalte (`Titel`, `title`, `name` oder `show`);
|
||||
erkannt werden außerdem Typ, Status, Bewertung, TMDB-ID, Jahr und Notiz.
|
||||
Komma und Semikolon als Trennzeichen funktionieren beide.
|
||||
- **Trakt.tv** — ein JSON-Export aus Watchlist, History, Ratings oder
|
||||
Collection. Flimmer sucht sich die Einträge selbst heraus.
|
||||
|
||||
Der Import **überschreibt nichts**. Titel, die schon in deiner Bibliothek
|
||||
liegen, bleiben unangetastet; es kommt nur dazu, was fehlt.
|
||||
|
||||
**Export** als JSON (mit allen Sichtungen und Universen) oder als CSV für
|
||||
Tabellenkalkulationen.
|
||||
|
||||
---
|
||||
|
||||
## API-Schlüssel
|
||||
|
||||

|
||||
|
||||
Für Skripte und eigene Anwendungen. Ein Schlüssel ist **einmalig** sichtbar —
|
||||
danach liegt in der Datenbank nur ein Hash. Er hat dieselben Rechte wie dein
|
||||
Konto; zieh ihn zurück, sobald du ihn nicht mehr brauchst.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer flm_…" \
|
||||
https://flimmer.example.com/api/v1/bibliothek?status=WATCHING
|
||||
```
|
||||
|
||||
Alle Endpunkte stehen in [`API.md`](API.md); die maschinenlesbare Fassung
|
||||
liegt unter `/api/v1/openapi.json`.
|
||||
|
||||
---
|
||||
|
||||
## Papierkorb
|
||||
|
||||

|
||||
|
||||
Entfernte Titel landen für **30 Tage** im Papierkorb und behalten dabei ihren
|
||||
Fortschritt. Von dort lassen sie sich wiederherstellen oder sofort endgültig
|
||||
löschen. Nimmst du denselben Titel erneut auf, lebt der alte Eintrag samt
|
||||
Sichtungen wieder auf.
|
||||
|
||||
Nach Ablauf der Frist räumt der tägliche Hintergrundlauf auf.
|
||||
|
||||
---
|
||||
|
||||
## Verwaltung
|
||||
|
||||

|
||||
|
||||
Nur für Administratoren. Hier lässt sich
|
||||
|
||||
- der **Name der Instanz** ändern,
|
||||
- die **Registrierung** öffnen oder schließen,
|
||||
- **öffentliche Profile** instanzweit erlauben oder abschalten,
|
||||
- der **Abgleich** von Hand anstoßen,
|
||||
- und der Zustand der Instanz ablesen: Anzahl Konten, Umfang des Katalogs und
|
||||
ob TMDB, SMTP, Zeitplan und OIDC eingerichtet sind.
|
||||
|
||||
In der Kontenliste vergibst du Verwaltungsrechte und sperrst Konten. Ein
|
||||
gesperrtes Konto wird sofort von allen Geräten abgemeldet. Die letzte
|
||||
Administration lässt sich nicht entmachten, und sich selbst kann niemand
|
||||
sperren.
|
||||
|
||||
---
|
||||
|
||||
## Öffentliches Profil
|
||||
|
||||

|
||||
|
||||
Wer mag, kann eine schlichte Seite unter `/u/<adresse>` freischalten — mit
|
||||
Schauzeit, Zahlen, zuletzt Gesehenem und den Listen „Die meiste Zeit" und
|
||||
„Bestbewertet".
|
||||
|
||||
Was dort **nicht** steht: Notizen, Watchlist, E-Mail-Adresse, Tags. Die Seite
|
||||
braucht zwei Freigaben — deine in den Einstellungen und die des Betreibers in
|
||||
der Verwaltung. Fehlt eine davon, ist die Adresse schlicht nicht erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## Auf dem Handy
|
||||
|
||||

|
||||
|
||||
Die Oberfläche ist für schmale Geräte ausgelegt: die Navigation rutscht in eine
|
||||
eigene, wischbare Zeile, Karten stapeln sich untereinander, und der Knopf
|
||||
**+1 Episode** bleibt groß genug für den Daumen.
|
||||
|
||||
Flimmer ist als **PWA** installierbar — im Browsermenü *Zum Startbildschirm
|
||||
hinzufügen*. Danach startet die App im eigenen Fenster ohne Adresszeile.
|
||||
Bereits geladene Seiten und Poster bleiben auch ohne Verbindung sichtbar;
|
||||
statt einer Fehlerseite erscheint dann ein Hinweis.
|
||||
|
||||
---
|
||||
|
||||
## Helles Farbschema
|
||||
|
||||

|
||||
|
||||
Über den Knopf im Kopfbereich wechselst du zwischen **Dunkel**, **Hell** und
|
||||
**System**. Die Wahl bleibt auf dem Gerät gespeichert und greift, bevor die
|
||||
Seite gezeichnet wird — es blitzt nichts auf.
|
||||
|
||||
---
|
||||
|
||||
## Und wenn etwas fehlt?
|
||||
|
||||
- **Die Suche findet nichts** → Es fehlt der TMDB-Schlüssel. Trag
|
||||
`TMDB_API_KEY` in die `.env` ein und starte die Instanz neu.
|
||||
- **Keine Streamingdienste** → Prüfe die Region in den Einstellungen. TMDB hat
|
||||
nicht für jedes Land Daten zu jedem Titel.
|
||||
- **Keine Benachrichtigungen** → Erst im Profil auswählen, *wovon* berichtet
|
||||
werden soll, dann einen Kanal anlegen und mit *Testen* prüfen.
|
||||
- **Neue Episoden fehlen** → Der Abgleich läuft einmal täglich. In der
|
||||
Verwaltung lässt er sich sofort anstoßen.
|
||||
|
||||
Alles Weitere zur Installation, zum Aktualisieren und zum Backup steht in der
|
||||
[README](../README.md).
|
||||
@@ -0,0 +1,201 @@
|
||||
# Flimmer — REST-API
|
||||
|
||||
Alles, was die Oberfläche kann, geht auch über die Schnittstelle: Bibliothek
|
||||
lesen und pflegen, Episoden abhaken, suchen, Kalender und Statistik abrufen,
|
||||
Universen verwalten, Daten exportieren.
|
||||
|
||||
Die maschinenlesbare Beschreibung liegt unter
|
||||
`GET /api/v1/openapi.json` (OpenAPI 3.1) — damit lassen sich Clients erzeugen
|
||||
oder die Endpunkte in Insomnia, Bruno oder Postman importieren.
|
||||
|
||||
## Anmeldung
|
||||
|
||||
Jede Anfrage trägt einen persönlichen API-Schlüssel im `Authorization`-Kopf:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer flm_a1b2c3d4.xxxxxxxxxxxx" \
|
||||
https://flimmer.example.com/api/v1/me
|
||||
```
|
||||
|
||||
Schlüssel legst du unter **Einstellungen → API-Schlüssel** an. Der Klartext ist
|
||||
**einmalig** sichtbar; danach liegt in der Datenbank nur ein Hash. Ein Schlüssel
|
||||
hat dieselben Rechte wie dein Konto — behandle ihn wie ein Passwort und zieh ihn
|
||||
zurück, sobald du ihn nicht mehr brauchst.
|
||||
|
||||
Aus dem Browser heraus funktioniert stattdessen das Sitzungscookie; dieselben
|
||||
Endpunkte bedienen also Skripte und das eigene Frontend.
|
||||
|
||||
## Antwortformat
|
||||
|
||||
Alle Antworten sind JSON. Zeitangaben in ISO 8601, Laufzeiten in Minuten.
|
||||
Fehler tragen immer ein Feld `error`:
|
||||
|
||||
```json
|
||||
{ "error": "Diesen Eintrag gibt es nicht." }
|
||||
```
|
||||
|
||||
| Status | Bedeutung |
|
||||
|---|---|
|
||||
| 200 | In Ordnung |
|
||||
| 401 | Kein oder ungültiger Schlüssel |
|
||||
| 404 | Nicht vorhanden (oder nicht deins) |
|
||||
| 422 | Anfrage unvollständig — `details` nennt die Felder |
|
||||
| 502 / 503 | TMDB nicht erreichbar bzw. kein Schlüssel hinterlegt |
|
||||
|
||||
## Endpunkte
|
||||
|
||||
### Konto
|
||||
|
||||
```http
|
||||
GET /api/v1/me
|
||||
```
|
||||
|
||||
### Bibliothek
|
||||
|
||||
```http
|
||||
GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&offset=0
|
||||
POST /api/v1/bibliothek
|
||||
```
|
||||
|
||||
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `sammlung`, `limit`,
|
||||
`offset`. Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
|
||||
`sammlung=1` liefert nur Titel, die in der eigenen Sammlung liegen.
|
||||
|
||||
Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||
-d '{"tmdbId": 1396, "type": "SERIES", "status": "WATCHING"}' \
|
||||
https://flimmer.example.com/api/v1/bibliothek
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/v1/bibliothek/{entryId}
|
||||
PATCH /api/v1/bibliothek/{entryId}
|
||||
DELETE /api/v1/bibliothek/{entryId}
|
||||
```
|
||||
|
||||
`GET` liefert den Eintrag samt `progress` und `nextEpisode`.
|
||||
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`
|
||||
sowie `owned` (liegt der Titel in der eigenen Sammlung?) und `ownedSource`
|
||||
(wo genau — Jellyfin, Plex, Blu-ray …).
|
||||
`DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage.
|
||||
|
||||
### Sichtungen
|
||||
|
||||
```http
|
||||
GET /api/v1/bibliothek/{entryId}/sichtungen
|
||||
POST /api/v1/bibliothek/{entryId}/sichtungen
|
||||
DELETE /api/v1/bibliothek/{entryId}/sichtungen
|
||||
```
|
||||
|
||||
`POST` kennt fünf Formen:
|
||||
|
||||
| Rumpf | Wirkung |
|
||||
|---|---|
|
||||
| `{"action":"next"}` | Die nächste offene Episode abhaken |
|
||||
| `{"action":"movie"}` | Einen Film als gesehen markieren |
|
||||
| `{"action":"episodes","episodeIds":["…"]}` | Eine Auswahl abhaken |
|
||||
| `{"action":"season","seasonId":"…"}` | Eine ganze Staffel |
|
||||
| `{"action":"upTo","episodeId":"…"}` | Alles bis einschließlich dieser Episode |
|
||||
|
||||
Die Antwort trägt `watchIds` — damit lässt sich genau diese Aktion zurücknehmen:
|
||||
|
||||
```bash
|
||||
# Nächste Episode abhaken
|
||||
IDS=$(curl -s -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||
-d '{"action":"next"}' \
|
||||
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen | jq -c .watchIds)
|
||||
|
||||
# … und wieder zurücknehmen
|
||||
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||
-d "{\"watchIds\": $IDS}" \
|
||||
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
|
||||
```
|
||||
|
||||
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
|
||||
Markierung dieser Episoden im aktuellen Durchlauf.
|
||||
|
||||
### Suche
|
||||
|
||||
```http
|
||||
GET /api/v1/suche?q=nordlicht&typ=serie
|
||||
```
|
||||
|
||||
Durchsucht TMDB. Die Treffer tragen `tmdbId` und `type` — direkt verwendbar für
|
||||
`POST /api/v1/bibliothek`. Ohne hinterlegten TMDB-Schlüssel antwortet der
|
||||
Endpunkt mit 503.
|
||||
|
||||
### Kalender
|
||||
|
||||
```http
|
||||
GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
|
||||
```
|
||||
|
||||
Kommende Episoden der getrackten Serien. Ohne Zeitraum: die nächsten 30 Tage.
|
||||
|
||||
### Statistik
|
||||
|
||||
```http
|
||||
GET /api/v1/statistik?jahr=2026
|
||||
```
|
||||
|
||||
Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
|
||||
|
||||
### Universen
|
||||
|
||||
```http
|
||||
GET /api/v1/universen
|
||||
POST /api/v1/universen
|
||||
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
||||
POST /api/v1/universen/{id}
|
||||
```
|
||||
|
||||
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
||||
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
||||
Erscheinungsreihenfolge.
|
||||
|
||||
### Export
|
||||
|
||||
```http
|
||||
GET /api/v1/export?format=json
|
||||
GET /api/v1/export?format=csv
|
||||
```
|
||||
|
||||
JSON enthält zusätzlich alle Sichtungen und Universen.
|
||||
|
||||
## Beispiel: was ich habe, aber noch nicht gesehen habe
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $KEY" \
|
||||
"$HOST/api/v1/bibliothek?sammlung=1&status=WATCHLIST" \
|
||||
| jq -r '.items[] | "\(.title) — liegt bei: \(.ownedSource // "ohne Angabe")"'
|
||||
```
|
||||
|
||||
## Beispiel: Serien mit offener Episode auflisten
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
KEY="flm_…"
|
||||
HOST="https://flimmer.example.com"
|
||||
|
||||
curl -s -H "Authorization: Bearer $KEY" \
|
||||
"$HOST/api/v1/bibliothek?status=WATCHING&typ=SERIES" \
|
||||
| jq -r '.items[] | "\(.title): \(.watched)/\(.total)"'
|
||||
```
|
||||
|
||||
## Grenzen
|
||||
|
||||
Es gibt bewusst keine Ratenbegrenzung: Flimmer läuft auf deinem eigenen Server,
|
||||
und ein Limit gegen dich selbst hilft niemandem. Wer die Instanz öffentlich
|
||||
erreichbar macht, setzt so etwas besser im Reverse Proxy.
|
||||
|
||||
Der Abgleich mit TMDB lässt sich über einen eigenen Endpunkt anstoßen — der
|
||||
nimmt allerdings das Cron-Geheimnis, nicht einen API-Schlüssel:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
|
||||
https://flimmer.example.com/api/cron/sync
|
||||
```
|
||||
@@ -103,6 +103,30 @@ denselben Variablennamen — keine Änderung an Komponenten.
|
||||
| `.fl-progress` | Fortschrittsbalken im Signaturverlauf |
|
||||
| `.fl-glimmer` | Mikro-Animation beim Abhaken einer Episode |
|
||||
|
||||
## Diagrammfarben
|
||||
|
||||
Die Signaturfarben sind für Diagrammflächen auf dunklem Grund zu hell. Für die
|
||||
beiden Serien der Statistik stehen deshalb abgedunkelte Varianten:
|
||||
|
||||
| Serie | Wert | Prüfung |
|
||||
|---|---|---|
|
||||
| Episoden | `#c47a18` | Helligkeitsband, Sättigung, Kontrast ≥ 3:1 — in **beiden** Modi |
|
||||
| Filme | `#d92d78` | dito |
|
||||
|
||||
Das schlechteste Paar erreicht ΔE 12,1 unter Deuteranopie und 20,6 bei
|
||||
normalem Sehen (Ziel: ≥ 8 bzw. ≥ 15). Beide Werte gelten auf hellem wie
|
||||
dunklem Grund, weshalb es nur **eine** Diagrammpalette gibt statt zweier.
|
||||
|
||||
Balken ohne Identitätsbedeutung (Genre-Verteilung) tragen einheitlich
|
||||
`#c47a18` — die Länge zeigt schon die Größe, die Farbe muss nichts
|
||||
wiederholen. Streamingdienste tragen ihre Markenfarbe, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreicht; sonst fällt der Balken auf
|
||||
den geprüften Standardton zurück.
|
||||
|
||||
Die Aussage hängt nie allein an der Farbe: Legende, Achsenwerte, ein Tooltip
|
||||
beim Überfahren und eine für Screenreader hinterlegte Tabelle tragen dieselbe
|
||||
Information.
|
||||
|
||||
## Bewegung
|
||||
|
||||
Ruhig und kurz: 200–420 ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Keine harten
|
||||
@@ -111,9 +135,14 @@ verklingt. `prefers-reduced-motion: reduce` schaltet sämtliche Übergänge ab.
|
||||
|
||||
## Typografie
|
||||
|
||||
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Wird beim Bauen
|
||||
heruntergeladen und mit ausgeliefert; die laufende Instanz fragt nie bei Google
|
||||
an. Rückfallkette: Inter, dann System-Sans.
|
||||
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Die variable
|
||||
Fassung (Strichstärke 100–900) liegt als Datei im Repository
|
||||
(`public/fonts/`), aufgeteilt in Latin und Latin-Extended.
|
||||
|
||||
Bewusst nicht `next/font/google`: das lädt die Schrift beim Bauen herunter und
|
||||
macht den Build von einer Netzverbindung abhängig — im Container blieb er
|
||||
daran hängen. Mit der mitgelieferten Datei gibt es weder beim Bauen noch zur
|
||||
Laufzeit einen Abruf nach außen. Rückfallkette: Inter, dann System-Sans.
|
||||
|
||||
## Grundhaltung
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -14,12 +14,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
|
||||
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
|
||||
// next/image-Remote-Loader — deshalb hier keine remotePatterns nötig.
|
||||
images: {
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
},
|
||||
// Bilder laufen über den eigenen Cache-Endpunkt (/api/image), nicht über
|
||||
// next/image — deshalb ist hier nichts zu konfigurieren.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
@@ -25,7 +25,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
@@ -1945,17 +1944,6 @@
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
|
||||
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcryptjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
@@ -15,8 +15,9 @@
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts"
|
||||
"screenshots": "node scripts/screenshots.mjs && node scripts/optimize-screenshots.mjs",
|
||||
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts",
|
||||
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
@@ -35,7 +36,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
@@ -50,5 +50,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@7.10.0": true,
|
||||
"@prisma/engines@7.10.0": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryEntry" ADD COLUMN "owned" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "ownedSource" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryEntry_userId_owned_idx" ON "LibraryEntry"("userId", "owned");
|
||||
@@ -289,6 +289,13 @@ model LibraryEntry {
|
||||
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
|
||||
whereToWatch String?
|
||||
|
||||
/// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade
|
||||
/// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr.
|
||||
owned Boolean @default(false)
|
||||
|
||||
/// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" …
|
||||
ownedSource String?
|
||||
|
||||
favorite Boolean @default(false)
|
||||
|
||||
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
|
||||
@@ -309,6 +316,7 @@ model LibraryEntry {
|
||||
@@unique([userId, titleId])
|
||||
@@index([userId, status])
|
||||
@@index([userId, updatedAt])
|
||||
@@index([userId, owned])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,9 @@ type EntryPlan = {
|
||||
startedDaysAgo?: number;
|
||||
/** Abstand zwischen zwei Episoden in Tagen. */
|
||||
spacingDays?: number;
|
||||
/** Liegt der Titel in der eigenen Sammlung? */
|
||||
owned?: boolean;
|
||||
ownedSource?: string;
|
||||
};
|
||||
|
||||
const ENTRY_PLANS: EntryPlan[] = [
|
||||
@@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
},
|
||||
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
|
||||
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 },
|
||||
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 },
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, owned: true, ownedSource: "Jellyfin" },
|
||||
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" },
|
||||
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
|
||||
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
|
||||
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
|
||||
@@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
startedDaysAgo: 180,
|
||||
},
|
||||
{ key: "stille-bilder-2", status: "WATCHLIST" },
|
||||
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 },
|
||||
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, owned: true, ownedSource: "Blu-ray" },
|
||||
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
|
||||
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
|
||||
];
|
||||
@@ -355,6 +358,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
|
||||
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
|
||||
@@ -364,6 +369,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
},
|
||||
});
|
||||
@@ -458,6 +465,73 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Universum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Erscheinungsreihenfolge berechnet Flimmer selbst — hier steht nur die
|
||||
* abweichende Story-Chronologie, damit die Seite zwei echte Reihenfolgen zeigt.
|
||||
*/
|
||||
const CHRONOLOGICAL = [
|
||||
"sternenpfad-null",
|
||||
"sternenpfad-1",
|
||||
"sternenpfad-akademie",
|
||||
"sternenpfad-2",
|
||||
"sternenpfad-serie",
|
||||
"sternenpfad-3",
|
||||
"sternenpfad-4",
|
||||
];
|
||||
|
||||
async function seedUniverse(ownerId: string, titleIds: Map<string, string>) {
|
||||
const existing = await prisma.universe.findUnique({
|
||||
where: { ownerId_slug: { ownerId, slug: "sternenpfad" } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.universe.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
const universe = await prisma.universe.create({
|
||||
data: {
|
||||
ownerId,
|
||||
name: "Sternenpfad",
|
||||
slug: "sternenpfad",
|
||||
description:
|
||||
"Fünf Filme und zwei Serien. Die Kinoreihenfolge und die Story-Chronologie gehen auseinander — deshalb zwei Reihenfolgen.",
|
||||
orders: {
|
||||
create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const itemIdByKey = new Map<string, string>();
|
||||
for (const key of CHRONOLOGICAL) {
|
||||
const titleId = titleIds.get(key);
|
||||
if (!titleId) continue;
|
||||
const item = await prisma.universeItem.create({
|
||||
data: { universeId: universe.id, titleId },
|
||||
select: { id: true },
|
||||
});
|
||||
itemIdByKey.set(key, item.id);
|
||||
}
|
||||
|
||||
const order = await prisma.universeOrder.create({
|
||||
data: { universeId: universe.id, name: "Chronologisch", kind: "CUSTOM" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.universeOrderItem.createMany({
|
||||
data: CHRONOLOGICAL.flatMap((key, index) => {
|
||||
const itemId = itemIdByKey.get(key);
|
||||
return itemId ? [{ orderId: order.id, itemId, position: index }] : [];
|
||||
}),
|
||||
});
|
||||
|
||||
return universe;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
@@ -509,6 +583,15 @@ async function main() {
|
||||
console.log("Seed: Bibliothek und Sichtungen ...");
|
||||
await seedLibrary(admin.id, titleIds);
|
||||
|
||||
console.log("Seed: Universum ...");
|
||||
const universe = await seedUniverse(admin.id, titleIds);
|
||||
// Mit dem zweiten Konto teilen, damit die Freigabe sichtbar ist.
|
||||
await prisma.universeMember.upsert({
|
||||
where: { universeId_userId: { universeId: universe.id, userId: friend.id } },
|
||||
create: { universeId: universe.id, userId: friend.id, permission: "READ" },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const watches = await prisma.watch.count({ where: { userId: admin.id } });
|
||||
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Flimmer — Service Worker
|
||||
*
|
||||
* Bewusst handgeschrieben und knapp gehalten. Drei Strategien:
|
||||
*
|
||||
* Bilder und statische Dateien → zuerst aus dem Zwischenspeicher.
|
||||
* Poster ändern sich unter einem TMDB-Pfad nie, das spart jede Anfrage.
|
||||
* Seitenaufrufe → zuerst das Netz, sonst der Speicher,
|
||||
* sonst die Offline-Seite. Ein Tracker, der veraltete Stände zeigt, wäre
|
||||
* schlimmer als einer, der ehrlich sagt: gerade keine Verbindung.
|
||||
* Alles Übrige (API, Aktionen) → gar kein Zwischenspeicher.
|
||||
*
|
||||
* Schreibende Anfragen laufen nie über den Service Worker.
|
||||
*/
|
||||
|
||||
const VERSION = "flimmer-v1";
|
||||
const SHELL_CACHE = `${VERSION}-shell`;
|
||||
const IMAGE_CACHE = `${VERSION}-images`;
|
||||
const PAGE_CACHE = `${VERSION}-pages`;
|
||||
|
||||
const OFFLINE_URL = "/offline";
|
||||
|
||||
const SHELL = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/manifest.webmanifest"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(SHELL))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(keys.filter((key) => !key.startsWith(VERSION)).map((key) => caches.delete(key))),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
/** Zwischenspeicher begrenzen, damit Poster ihn nicht endlos füllen. */
|
||||
async function trim(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= maxEntries) return;
|
||||
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
void trim(cacheName, maxEntries);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
const cache = await caches.open(PAGE_CACHE);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
void trim(PAGE_CACHE, 40);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
|
||||
const offline = await caches.match(OFFLINE_URL);
|
||||
if (offline) return offline;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Bilder aus dem eigenen Cache-Endpunkt und statische Next-Dateien
|
||||
if (url.pathname.startsWith("/api/image")) {
|
||||
event.respondWith(cacheFirst(request, IMAGE_CACHE, 400));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/_next/static") || url.pathname.startsWith("/icons/")) {
|
||||
event.respondWith(cacheFirst(request, SHELL_CACHE, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
// Übrige API-Aufrufe nie zwischenspeichern
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(networkFirst(request));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
# Flimmer 0.4.0
|
||||
|
||||
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
|
||||
Wizarding World oder was auch immer zusammengehört.
|
||||
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
|
||||
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
|
||||
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
|
||||
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
|
||||
Tastatur nicht bedienbar und auf dem Handy fummelig.
|
||||
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
|
||||
vorkommen, lassen sich mit einem Klick übernehmen.
|
||||
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
|
||||
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
|
||||
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
|
||||
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
|
||||
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
|
||||
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
|
||||
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
|
||||
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
|
||||
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
|
||||
Serien hinweg, Freigaben und die Rechteprüfung.
|
||||
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
|
||||
Serien und einer abweichenden Story-Chronologie.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
|
||||
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
|
||||
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
|
||||
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
|
||||
serverseitig.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Flimmer 0.5.0
|
||||
|
||||
Übersicht, Kalender und Statistik.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
|
||||
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
|
||||
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
|
||||
verfügbar geworden sind.
|
||||
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
|
||||
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
|
||||
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
|
||||
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
|
||||
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
|
||||
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
|
||||
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
|
||||
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
|
||||
zählen mit.
|
||||
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
|
||||
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
|
||||
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
|
||||
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
|
||||
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
|
||||
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
|
||||
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
|
||||
Standardton.
|
||||
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
|
||||
„Weiterschauen" und den Kalenderzeitraum.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
|
||||
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
|
||||
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
|
||||
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
|
||||
(„2026-04") aus der Datenbank.
|
||||
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
|
||||
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
|
||||
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
|
||||
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
|
||||
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
|
||||
hängt jetzt als `aria-label` am `<svg>`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Flimmer 0.6.0
|
||||
|
||||
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
|
||||
— und eine bebilderte Anleitung.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
|
||||
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
|
||||
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
|
||||
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
|
||||
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
|
||||
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
|
||||
Logo und Farbwelt von Flimmer.
|
||||
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
|
||||
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
|
||||
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
|
||||
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
|
||||
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
|
||||
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
|
||||
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
|
||||
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
|
||||
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
|
||||
Universen.
|
||||
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
|
||||
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
|
||||
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
|
||||
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
|
||||
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
|
||||
API-Schlüssel sowie Import und Export.
|
||||
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
|
||||
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
|
||||
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
|
||||
Administration lässt sich nicht entmachten.
|
||||
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
|
||||
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
|
||||
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
|
||||
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
|
||||
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
|
||||
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
|
||||
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
|
||||
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
|
||||
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
|
||||
laufenden Instanz, aufgenommen von `npm run screenshots`.
|
||||
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
|
||||
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Flimmer 0.7.0
|
||||
|
||||
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
||||
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
|
||||
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
|
||||
zeigt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
|
||||
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
|
||||
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
|
||||
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
|
||||
nirgends mehr laufen, will man ja wiederfinden.
|
||||
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
|
||||
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
|
||||
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
|
||||
- Eigener Filter in der Bibliothek samt Zähler
|
||||
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
|
||||
Lesen, `owned` und `ownedSource` beim Ändern)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
|
||||
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
|
||||
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
|
||||
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
|
||||
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
|
||||
getracten Abhängigkeitsbaum der Anwendung nicht stört.
|
||||
|
||||
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
|
||||
Outfit beim Bauen von Google herunter — das macht den Build von einer
|
||||
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
|
||||
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
|
||||
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
|
||||
das Richtige.
|
||||
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
|
||||
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
|
||||
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
|
||||
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
|
||||
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
|
||||
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
|
||||
immer verhindern.
|
||||
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
|
||||
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
|
||||
Pushen eines Versions-Tags das Release mit den Notizen aus
|
||||
`releases/vX.Y.Z.md` an.
|
||||
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
|
||||
öffentliche Profil in die Anleitung aufgenommen.
|
||||
|
||||
### Entfernt
|
||||
|
||||
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
|
||||
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
|
||||
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Flimmer 0.7.1
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
@@ -0,0 +1,34 @@
|
||||
import sharp from "sharp";
|
||||
import { readdir, unlink, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Verkleinert die aufgenommenen Screenshots für das Repository.
|
||||
* Die Aufnahme läuft mit doppelter Pixeldichte (scharfe Schrift), abgelegt
|
||||
* wird die halbe Kantenlänge als WebP — das spart rund 90 Prozent.
|
||||
*/
|
||||
const DIR = "docs/screenshots";
|
||||
const files = (await readdir(DIR)).filter((name) => name.endsWith(".png"));
|
||||
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
|
||||
for (const name of files) {
|
||||
const source = join(DIR, name);
|
||||
before += (await stat(source)).size;
|
||||
|
||||
const image = sharp(source);
|
||||
const meta = await image.metadata();
|
||||
const target = join(DIR, name.replace(/\.png$/, ".webp"));
|
||||
|
||||
await image
|
||||
.resize({ width: Math.round((meta.width ?? 1400) / 2) })
|
||||
.webp({ quality: 82, effort: 5 })
|
||||
.toFile(target);
|
||||
|
||||
after += (await stat(target)).size;
|
||||
await unlink(source);
|
||||
}
|
||||
|
||||
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
||||
console.log(`${files.length} Bilder: ${mb(before)} MB -> ${mb(after)} MB`);
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
|
||||
const OUT = "docs/screenshots";
|
||||
const BASE = "http://localhost:3011";
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const errs = [];
|
||||
|
||||
async function session(width, height, theme) {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: theme === "light" ? "light" : "dark",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
});
|
||||
await ctx.addInitScript((mode) => localStorage.setItem("flimmer-theme", mode), theme);
|
||||
// Der Entwicklungs-Indikator von Next hat in einer Anleitung nichts verloren.
|
||||
await ctx.addStyleTag?.({ content: "nextjs-portal { display: none !important; }" });
|
||||
const page = await ctx.newPage();
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||
page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${BASE}/anmelden`);
|
||||
await page.fill("#email", "demo@flimmer.local");
|
||||
await page.fill("#password", "flimmer-demo-2026");
|
||||
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
|
||||
}
|
||||
|
||||
async function shot(page, name, url, opts = {}) {
|
||||
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||
await page.waitForTimeout(opts.wait ?? 900);
|
||||
if (opts.before) await opts.before(page);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: Boolean(opts.full) });
|
||||
console.log(" ", name);
|
||||
}
|
||||
|
||||
// --- Anmeldung (ohne Sitzung) ---
|
||||
{
|
||||
const { ctx, page } = await session(1280, 800, "dark");
|
||||
await shot(page, "01-anmeldung", "/anmelden");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Angemeldet, dunkel ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "dark");
|
||||
await login(page);
|
||||
|
||||
await shot(page, "02-uebersicht", "/", { full: true });
|
||||
await shot(page, "03-suche", "/suche?q=sternenpfad");
|
||||
await shot(page, "05-bibliothek", "/bibliothek");
|
||||
await shot(page, "09-kalender", "/kalender");
|
||||
await shot(page, "10-statistik", "/statistik", { full: true });
|
||||
await shot(page, "11-einstellungen", "/einstellungen");
|
||||
await shot(page, "12-benachrichtigungen", "/einstellungen/benachrichtigungen");
|
||||
await shot(page, "13-api", "/einstellungen/api");
|
||||
await shot(page, "14-daten", "/einstellungen/daten");
|
||||
await shot(page, "15-verwaltung", "/verwaltung", { full: true });
|
||||
await shot(page, "16-papierkorb", "/papierkorb");
|
||||
|
||||
// Detailseite und Universum brauchen IDs aus der Bibliothek
|
||||
await page.goto(`${BASE}/bibliothek?q=nordlicht`, { waitUntil: "networkidle" });
|
||||
const titleHref = await page.locator("a[href^='/titel/']").first().getAttribute("href");
|
||||
if (titleHref) await shot(page, "04-serie", titleHref, { full: true });
|
||||
|
||||
await page.goto(`${BASE}/universen`, { waitUntil: "networkidle" });
|
||||
await shot(page, "06-universen", "/universen");
|
||||
const uniHref = await page.locator("a[href^='/universen/']").first().getAttribute("href");
|
||||
if (uniHref) await shot(page, "07-universum", uniHref, { full: true });
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Hell ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "light");
|
||||
await login(page);
|
||||
await shot(page, "08-hell", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Mobil ---
|
||||
{
|
||||
const { ctx, page } = await session(430, 900, "dark");
|
||||
await login(page);
|
||||
await shot(page, "17-mobil-uebersicht", "/");
|
||||
await shot(page, "18-mobil-serie", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
|
||||
await browser.close();
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Kleiner TMDB-Ersatz für Screenshots und manuelles Ausprobieren.
|
||||
*
|
||||
* node scripts/tmdb-stub.mjs [port]
|
||||
*
|
||||
* Beantwortet die Suchendpunkte aus dem lokalen Katalog (den das Seed-Skript
|
||||
* angelegt hat) im Format der TMDB-API. Damit lässt sich die Suchseite auch
|
||||
* ohne echten TMDB-Schlüssel zeigen — für Screenshots der Anleitung und um
|
||||
* die Oberfläche auszuprobieren, bevor man einen Schlüssel besorgt.
|
||||
*
|
||||
* Verwendung zusammen mit:
|
||||
* TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "../src/generated/prisma/client.ts";
|
||||
|
||||
try {
|
||||
process.loadEnvFile(".env");
|
||||
} catch {
|
||||
// Werte kommen aus der Umgebung.
|
||||
}
|
||||
|
||||
const port = Number(process.argv[2] ?? 3099);
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
function toResult(title, mediaType) {
|
||||
const date = title.releaseDate?.toISOString().slice(0, 10) ?? null;
|
||||
return {
|
||||
id: title.tmdbId,
|
||||
media_type: mediaType,
|
||||
name: mediaType === "tv" ? title.title : undefined,
|
||||
title: mediaType === "movie" ? title.title : undefined,
|
||||
original_name: title.originalTitle ?? undefined,
|
||||
original_title: title.originalTitle ?? undefined,
|
||||
overview: title.overview ?? "",
|
||||
poster_path: title.posterPath,
|
||||
backdrop_path: title.backdropPath,
|
||||
first_air_date: mediaType === "tv" ? date : undefined,
|
||||
release_date: mediaType === "movie" ? date : undefined,
|
||||
vote_average: title.voteAverage ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function search(query, type) {
|
||||
const titles = await prisma.title.findMany({
|
||||
where: {
|
||||
tmdbId: { not: null },
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ originalTitle: { contains: query, mode: "insensitive" } },
|
||||
{ genres: { has: query } },
|
||||
],
|
||||
},
|
||||
orderBy: { releaseDate: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return titles.map((title) => toResult(title, title.type === "SERIES" ? "tv" : "movie"));
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
||||
const query = url.searchParams.get("query") ?? "";
|
||||
|
||||
const send = (body, status = 200) => {
|
||||
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
try {
|
||||
if (url.pathname.startsWith("/3/search/")) {
|
||||
const kind = url.pathname.split("/").pop();
|
||||
const type = kind === "tv" ? "SERIES" : kind === "movie" ? "MOVIE" : null;
|
||||
const results = await search(query, type);
|
||||
return send({ page: 1, results, total_pages: 1, total_results: results.length });
|
||||
}
|
||||
|
||||
return send({ status_message: `Kein Ersatz für ${url.pathname}` }, 404);
|
||||
} catch (error) {
|
||||
return send({ status_message: String(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`TMDB-Ersatz läuft auf http://127.0.0.1:${port}/3`);
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
server.close();
|
||||
void prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { chromium } from "playwright";
|
||||
const [,, SP, ...specs] = process.argv;
|
||||
const b = await chromium.launch();
|
||||
const c = await b.newContext({ viewport: { width: 1280, height: 950 }, deviceScaleFactor: 2, colorScheme: "dark" });
|
||||
const p = await c.newPage();
|
||||
const errs = [];
|
||||
p.on("pageerror", e => errs.push(String(e).slice(0,200)));
|
||||
p.on("console", m => { if (m.type()==="error") errs.push("console: "+m.text().slice(0,160)); });
|
||||
await p.goto("http://localhost:3011/anmelden");
|
||||
await p.fill("#email", "demo@flimmer.local");
|
||||
await p.fill("#password", "flimmer-demo-2026");
|
||||
await Promise.all([p.waitForURL("http://localhost:3011/"), p.click("button[type=submit]")]);
|
||||
for (const spec of specs) {
|
||||
const [name, url, full] = spec.split("|");
|
||||
await p.goto(`http://localhost:3011${url}`, { waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(900);
|
||||
await p.screenshot({ path: `${SP}/${name}.png`, fullPage: full === "full" });
|
||||
}
|
||||
console.log("Fehler:", errs.length ? errs.slice(0,4) : "keine");
|
||||
await b.close();
|
||||
@@ -114,6 +114,8 @@ const detailsSchema = z.object({
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function updateEntryAction(entryId: string, data: unknown) {
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
checkPasswordStrength,
|
||||
createApiKey,
|
||||
destroyAllSessions,
|
||||
getSessionUser,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { setSetting } from "@/lib/settings";
|
||||
import { sendTestNotification } from "@/lib/notify";
|
||||
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
||||
import { runSync } from "@/lib/sync";
|
||||
|
||||
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
|
||||
|
||||
export type SettingsResult = { ok: boolean; message?: string; secret?: string };
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>,
|
||||
): Promise<SettingsResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
revalidatePath("/einstellungen", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
return { ok: true, ...result };
|
||||
} catch (error) {
|
||||
console.error("Einstellungs-Aktion fehlgeschlagen:", error);
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function requireAdminUser() {
|
||||
const user = await getSessionUser();
|
||||
if (!user || user.role !== "ADMIN") {
|
||||
throw new Error("Dafür fehlen dir die Rechte.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const profileSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
region: z.string().trim().length(2).toUpperCase(),
|
||||
publicProfile: z.boolean(),
|
||||
profileSlug: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.")
|
||||
.nullable(),
|
||||
notifyNewEpisode: z.boolean(),
|
||||
notifyRelease: z.boolean(),
|
||||
notifyAvailability: z.boolean(),
|
||||
});
|
||||
|
||||
export async function updateProfileAction(data: unknown) {
|
||||
return run(async (userId) => {
|
||||
const parsed = profileSchema.parse(data);
|
||||
|
||||
if (parsed.publicProfile && !parsed.profileSlug) {
|
||||
throw new Error("Für ein öffentliches Profil braucht es eine Adresse.");
|
||||
}
|
||||
|
||||
if (parsed.profileSlug) {
|
||||
const taken = await prisma.user.findFirst({
|
||||
where: { profileSlug: parsed.profileSlug, id: { not: userId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) throw new Error("Diese Profiladresse ist schon vergeben.");
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: parsed });
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
const passwordSchema = z.object({
|
||||
current: z.string().min(1),
|
||||
next: z.string().min(1),
|
||||
repeat: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function changePasswordAction(data: unknown) {
|
||||
return run(async (userId) => {
|
||||
const parsed = passwordSchema.parse(data);
|
||||
|
||||
if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein.");
|
||||
const weak = checkPasswordStrength(parsed.next);
|
||||
if (weak) throw new Error(weak);
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
if (!user.passwordHash) {
|
||||
throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort.");
|
||||
}
|
||||
if (!(await verifyPassword(parsed.current, user.passwordHash))) {
|
||||
throw new Error("Das bisherige Passwort stimmt nicht.");
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: await hashPassword(parsed.next) },
|
||||
});
|
||||
|
||||
// Nach einem Passwortwechsel fliegen alle anderen Geräte raus.
|
||||
await destroyAllSessions(userId);
|
||||
return { message: "Passwort geändert — bitte neu anmelden." };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API-Schlüssel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createApiKeyAction(name: string) {
|
||||
return run(async (userId) => {
|
||||
const clean = z.string().trim().min(1).max(60).parse(name);
|
||||
const { plain } = await createApiKey(userId, clean);
|
||||
// Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash.
|
||||
return { message: "Schlüssel angelegt", secret: plain };
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeApiKeyAction(keyId: string) {
|
||||
return run(async (userId) => {
|
||||
await prisma.apiKey.updateMany({
|
||||
where: { id: keyId, userId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Schlüssel zurückgezogen" };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benachrichtigungskanäle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const channelSchema = z.object({
|
||||
kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
config: z.record(z.string(), z.string().max(500)).default({}),
|
||||
});
|
||||
|
||||
export async function saveChannelAction(data: unknown, channelId?: string) {
|
||||
return run(async (userId) => {
|
||||
const parsed = channelSchema.parse(data);
|
||||
|
||||
if (channelId) {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: { id: channelId, userId },
|
||||
data: { kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||||
});
|
||||
return { message: "Kanal gespeichert" };
|
||||
}
|
||||
|
||||
await prisma.notificationChannel.create({
|
||||
data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||||
});
|
||||
return { message: "Kanal angelegt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleChannelAction(channelId: string, enabled: boolean) {
|
||||
return run(async (userId) => {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: { id: channelId, userId },
|
||||
data: { enabled },
|
||||
});
|
||||
return { message: enabled ? "Kanal aktiv" : "Kanal ruht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChannelAction(channelId: string) {
|
||||
return run(async (userId) => {
|
||||
await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } });
|
||||
return { message: "Kanal entfernt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function testChannelAction(channelId: string) {
|
||||
return run(async (userId) => {
|
||||
await sendTestNotification(userId, channelId);
|
||||
return { message: "Testnachricht verschickt" };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary };
|
||||
|
||||
export async function importDataAction(
|
||||
format: "csv" | "trakt",
|
||||
content: string,
|
||||
): Promise<ImportResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
if (content.length > 8_000_000) {
|
||||
return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." };
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content);
|
||||
revalidatePath("/bibliothek", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`,
|
||||
summary,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Import fehlgeschlagen:", error);
|
||||
return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verwaltung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setRegistrationAction(enabled: boolean) {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
await setSetting("registrationEnabled", enabled);
|
||||
return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setInstanceNameAction(name: string) {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name));
|
||||
return { message: "Name gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setPublicProfilesAction(enabled: boolean) {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
await setSetting("publicProfilesEnabled", enabled);
|
||||
return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setUserActiveAction(targetId: string, active: boolean) {
|
||||
return run(async (userId) => {
|
||||
await requireAdminUser();
|
||||
if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren.");
|
||||
|
||||
await prisma.user.update({ where: { id: targetId }, data: { isActive: active } });
|
||||
if (!active) await destroyAllSessions(targetId);
|
||||
|
||||
revalidatePath("/verwaltung", "layout");
|
||||
return { message: active ? "Konto freigegeben" : "Konto gesperrt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") {
|
||||
return run(async (userId) => {
|
||||
await requireAdminUser();
|
||||
if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern.");
|
||||
|
||||
if (role === "USER") {
|
||||
const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } });
|
||||
if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben.");
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: targetId }, data: { role } });
|
||||
revalidatePath("/verwaltung", "layout");
|
||||
return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSyncAction() {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
const stats = await runSync({ limit: 40 });
|
||||
revalidatePath("/verwaltung", "layout");
|
||||
|
||||
return {
|
||||
message:
|
||||
stats.errors.length === 0
|
||||
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen`
|
||||
: `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import {
|
||||
addCollection,
|
||||
addTitle,
|
||||
createOrder,
|
||||
createUniverse,
|
||||
deleteOrder,
|
||||
removeTitle,
|
||||
renameOrder,
|
||||
renameUniverse,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
unshareUniverse,
|
||||
UniverseError,
|
||||
} from "@/lib/universes";
|
||||
|
||||
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
|
||||
|
||||
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
|
||||
): Promise<UniverseActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
revalidatePath("/universen", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
return { ok: true, message: result.message, id: result.id };
|
||||
} catch (error) {
|
||||
if (error instanceof UniverseError) return { ok: false, message: error.message };
|
||||
console.error("Universums-Aktion fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const name = z.string().trim().min(1).max(120);
|
||||
|
||||
export async function createUniverseAction(rawName: string, description?: string) {
|
||||
return run(async (userId) => {
|
||||
const universe = await createUniverse(userId, name.parse(rawName), description);
|
||||
return { message: `„${universe.name}" angelegt`, id: universe.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameUniverseAction(
|
||||
universeId: string,
|
||||
data: { name?: string; description?: string | null },
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
await renameUniverse(userId, id.parse(universeId), {
|
||||
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
|
||||
...(data.description !== undefined
|
||||
? { description: z.string().max(2000).nullable().parse(data.description) }
|
||||
: {}),
|
||||
});
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function trashUniverseAction(universeId: string) {
|
||||
return run(async (userId) => {
|
||||
await trashUniverse(userId, id.parse(universeId));
|
||||
return { message: "Universum gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
|
||||
return run(async (userId) => {
|
||||
await addTitle(userId, id.parse(universeId), id.parse(titleId));
|
||||
return { message: "Aufgenommen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromUniverseAction(universeId: string, itemId: string) {
|
||||
return run(async (userId) => {
|
||||
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
|
||||
return { message: "Aus dem Universum entfernt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
|
||||
return run(async (userId) => {
|
||||
const added = await addCollection(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.number().int().parse(tmdbCollectionId),
|
||||
);
|
||||
return {
|
||||
message:
|
||||
added === 0
|
||||
? "Alles daraus war schon drin"
|
||||
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrderAction(universeId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
|
||||
return { message: "Reihenfolge angelegt", id: order.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameOrderAction(orderId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
|
||||
return { message: "Umbenannt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOrderAction(orderId: string) {
|
||||
return run(async (userId) => {
|
||||
await deleteOrder(userId, id.parse(orderId));
|
||||
return { message: "Reihenfolge gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
||||
return { message: "Reihenfolge gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareUniverseAction(
|
||||
universeId: string,
|
||||
email: string,
|
||||
permission: "READ" | "WRITE",
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
const target = await shareUniverse(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.string().trim().email().parse(email),
|
||||
z.enum(["READ", "WRITE"]).parse(permission),
|
||||
);
|
||||
return { message: `Mit ${target.name} geteilt` };
|
||||
});
|
||||
}
|
||||
|
||||
export async function unshareUniverseAction(universeId: string, memberId: string) {
|
||||
return run(async (userId) => {
|
||||
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
|
||||
return { message: "Freigabe aufgehoben" };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/lib/env";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { runSync } from "@/lib/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Abgleich von außen anstoßen.
|
||||
*
|
||||
* curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
|
||||
* https://flimmer.example/api/cron/sync
|
||||
*
|
||||
* Zugelassen ist entweder das Cron-Geheimnis oder eine angemeldete
|
||||
* Administration — Letzteres für den Knopf im Verwaltungsbereich.
|
||||
*/
|
||||
async function authorize(request: Request): Promise<boolean> {
|
||||
const header = request.headers.get("authorization");
|
||||
if (env.scheduler.cronSecret && header === `Bearer ${env.scheduler.cronSecret}`) return true;
|
||||
|
||||
const user = await getSessionUser();
|
||||
return user?.role === "ADMIN";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await authorize(request))) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 401 });
|
||||
}
|
||||
|
||||
const stats = await runSync();
|
||||
return NextResponse.json({ ok: stats.errors.length === 0, ...stats });
|
||||
}
|
||||
|
||||
/** GET erlaubt einfache Uptime-Cron-Dienste, die kein POST können. */
|
||||
export async function GET(request: Request) {
|
||||
return POST(request);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getNextEpisode,
|
||||
getProgress,
|
||||
moveToTrash,
|
||||
setStatus,
|
||||
updateEntryDetails,
|
||||
} from "@/lib/library";
|
||||
|
||||
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
|
||||
|
||||
/** GET /api/v1/bibliothek/{id} — ein Eintrag mit Fortschritt und nächster Episode. */
|
||||
export const GET = withApi(async (_request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const entry = await prisma.libraryEntry.findFirst({
|
||||
where: { id: entryId, userId: user.id },
|
||||
include: {
|
||||
title: true,
|
||||
tags: { include: { tag: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (!entry) return apiError("Diesen Eintrag gibt es nicht.", 404);
|
||||
|
||||
const [progress, next] = await Promise.all([
|
||||
getProgress(user.id, entry.id),
|
||||
getNextEpisode(user.id, entry.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
status: entry.status,
|
||||
rating: entry.rating,
|
||||
notes: entry.notes,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
owned: entry.owned,
|
||||
ownedSource: entry.ownedSource,
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
title: {
|
||||
id: entry.title.id,
|
||||
title: entry.title.title,
|
||||
type: entry.title.type,
|
||||
tmdbId: entry.title.tmdbId,
|
||||
year: entry.title.releaseDate?.getFullYear() ?? null,
|
||||
genres: entry.title.genres,
|
||||
},
|
||||
progress,
|
||||
nextEpisode: next,
|
||||
};
|
||||
});
|
||||
|
||||
const patchSchema = z.object({
|
||||
status: z.enum(STATUSES).optional(),
|
||||
rating: z.number().int().min(1).max(10).nullable().optional(),
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
|
||||
export const PATCH = withApi(async (request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const { status, ...details } = await readJson(request, patchSchema);
|
||||
|
||||
if (status) await setStatus(user.id, entryId, status);
|
||||
if (Object.keys(details).length > 0) {
|
||||
await updateEntryDetails(user.id, entryId, details);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/bibliothek/{id} — in den Papierkorb legen. */
|
||||
export const DELETE = withApi(async (_request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
await moveToTrash(user.id, entryId);
|
||||
return { ok: true, message: "In den Papierkorb gelegt." };
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
markEpisodesWatched,
|
||||
markMovieWatched,
|
||||
markSeasonWatched,
|
||||
markUpTo,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
watchNextEpisode,
|
||||
} from "@/lib/library";
|
||||
|
||||
/** GET — die Sichtungen eines Eintrags, jüngste zuerst. */
|
||||
export const GET = withApi(async (_request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const watches = await prisma.watch.findMany({
|
||||
where: { userId: user.id, entryId },
|
||||
orderBy: { watchedAt: "desc" },
|
||||
take: 500,
|
||||
include: { episode: { select: { seasonNumber: true, episodeNumber: true, name: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
total: watches.length,
|
||||
items: watches.map((watch) => ({
|
||||
id: watch.id,
|
||||
run: watch.run,
|
||||
watchedAt: watch.watchedAt,
|
||||
minutes: watch.minutes,
|
||||
episode: watch.episode,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const postSchema = z.union([
|
||||
z.object({ action: z.literal("next") }),
|
||||
z.object({ action: z.literal("movie") }),
|
||||
z.object({
|
||||
action: z.literal("episodes"),
|
||||
episodeIds: z.array(z.string().min(1)).min(1).max(500),
|
||||
}),
|
||||
z.object({ action: z.literal("season"), seasonId: z.string().min(1) }),
|
||||
z.object({ action: z.literal("upTo"), episodeId: z.string().min(1) }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST — etwas als gesehen markieren.
|
||||
*
|
||||
* Die Antwort enthält immer `watchIds`. Damit lässt sich die Aktion über
|
||||
* DELETE gezielt zurücknehmen, ohne raten zu müssen, was dazugehörte.
|
||||
*/
|
||||
export const POST = withApi(async (request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const body = await readJson(request, postSchema);
|
||||
|
||||
switch (body.action) {
|
||||
case "next":
|
||||
return watchNextEpisode(user.id, entryId);
|
||||
case "movie":
|
||||
return markMovieWatched(user.id, entryId);
|
||||
case "episodes":
|
||||
return markEpisodesWatched(user.id, entryId, body.episodeIds);
|
||||
case "season":
|
||||
return markSeasonWatched(user.id, entryId, body.seasonId);
|
||||
case "upTo":
|
||||
return markUpTo(user.id, entryId, body.episodeId);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteSchema = z.union([
|
||||
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||
]);
|
||||
|
||||
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
|
||||
export const DELETE = withApi(async (request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
|
||||
const body = await readJson(request, deleteSchema);
|
||||
|
||||
if ("watchIds" in body) {
|
||||
await undoWatches(user.id, entryId, body.watchIds);
|
||||
} else {
|
||||
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
import { readJson, readPaging, withApi } from "@/lib/api";
|
||||
import { getLibrary } from "@/lib/queries";
|
||||
import { addToLibrary } from "@/lib/library";
|
||||
import { ensureTitle } from "@/lib/catalog";
|
||||
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||
|
||||
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
|
||||
|
||||
/** GET /api/v1/bibliothek — Bibliothek lesen, mit denselben Filtern wie im UI. */
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const { limit, offset } = readPaging(request);
|
||||
|
||||
const status = url.searchParams.get("status");
|
||||
const type = url.searchParams.get("typ");
|
||||
|
||||
const items = await getLibrary(user.id, {
|
||||
query: url.searchParams.get("q") ?? undefined,
|
||||
status: STATUSES.includes(status as EntryStatus) ? (status as EntryStatus) : undefined,
|
||||
type: type === "SERIES" || type === "MOVIE" ? (type as TitleType) : undefined,
|
||||
genre: url.searchParams.get("genre") ?? undefined,
|
||||
tag: url.searchParams.get("tag") ?? undefined,
|
||||
provider: url.searchParams.get("dienst") ?? undefined,
|
||||
owned: url.searchParams.get("sammlung") === "1" ? true : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
total: items.length,
|
||||
limit,
|
||||
offset,
|
||||
items: items.slice(offset, offset + limit),
|
||||
};
|
||||
});
|
||||
|
||||
const addSchema = z
|
||||
.object({
|
||||
titleId: z.string().min(1).optional(),
|
||||
tmdbId: z.number().int().positive().optional(),
|
||||
type: z.enum(["SERIES", "MOVIE"]).optional(),
|
||||
status: z.enum(STATUSES).default("WATCHLIST"),
|
||||
})
|
||||
.refine((value) => Boolean(value.titleId) || Boolean(value.tmdbId && value.type), {
|
||||
message: "Entweder titleId oder tmdbId zusammen mit type angeben.",
|
||||
});
|
||||
|
||||
/** POST /api/v1/bibliothek — Titel aufnehmen, per interner ID oder TMDB-ID. */
|
||||
export const POST = withApi(async (request, user) => {
|
||||
const body = await readJson(request, addSchema);
|
||||
|
||||
let titleId = body.titleId;
|
||||
if (!titleId && body.tmdbId && body.type) {
|
||||
const title = await ensureTitle(body.type, body.tmdbId, { region: user.region });
|
||||
titleId = title.id;
|
||||
}
|
||||
if (!titleId) {
|
||||
throw new z.ZodError([
|
||||
{ code: "custom", path: ["titleId"], message: "Kein Titel angegeben." },
|
||||
]);
|
||||
}
|
||||
|
||||
const entry = await addToLibrary(user.id, titleId, body.status);
|
||||
return { id: entry.id, titleId: entry.titleId, status: entry.status };
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApi } from "@/lib/api";
|
||||
import { exportCsv, exportJson } from "@/lib/transfer";
|
||||
|
||||
/**
|
||||
* GET /api/v1/export?format=json|csv
|
||||
*
|
||||
* Vollständiger Export der eigenen Daten. JSON enthält zusätzlich Sichtungen
|
||||
* und Universen, CSV ist die schlanke Tabellenform für Tabellenkalkulationen.
|
||||
*/
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const format = url.searchParams.get("format") === "csv" ? "csv" : "json";
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (format === "csv") {
|
||||
const csv = await exportCsv(user.id);
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="flimmer-${stamp}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const data = await exportJson(user.id);
|
||||
return new NextResponse(JSON.stringify(data, null, 2), {
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="flimmer-${stamp}.json"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import { getUpcomingEpisodes } from "@/lib/stats";
|
||||
|
||||
/**
|
||||
* GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
|
||||
*
|
||||
* Kommende Episoden der getrackten Serien. Ohne Angabe: die nächsten 30 Tage.
|
||||
*/
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const parse = (value: string | null, fallback: Date) => {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? fallback : date;
|
||||
};
|
||||
|
||||
const from = parse(url.searchParams.get("von"), new Date());
|
||||
const to = parse(url.searchParams.get("bis"), new Date(Date.now() + 30 * 86_400_000));
|
||||
|
||||
const episodes = await getUpcomingEpisodes(user.id, from, to);
|
||||
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
total: episodes.length,
|
||||
items: episodes,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** GET /api/v1/me — wer bin ich, und wie groß ist meine Bibliothek? */
|
||||
export const GET = withApi(async (_request, user) => {
|
||||
const [entries, watches] = await Promise.all([
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
prisma.watch.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
region: user.region,
|
||||
library: { entries, watches },
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/lib/env";
|
||||
import pkg from "../../../../../package.json";
|
||||
|
||||
/**
|
||||
* Maschinenlesbare Beschreibung der REST-API.
|
||||
*
|
||||
* Handgeschrieben statt aus Zod erzeugt: die Schnittstelle ist überschaubar,
|
||||
* und ein Generator brächte eine weitere Abhängigkeit samt Versionsrhythmus
|
||||
* für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
const document = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "Flimmer API",
|
||||
version: pkg.version,
|
||||
description:
|
||||
"REST-Schnittstelle einer Flimmer-Instanz. Anmeldung über einen persönlichen " +
|
||||
"API-Schlüssel (Einstellungen → API-Schlüssel) als Bearer-Token. Alle Antworten " +
|
||||
"sind JSON; Zeitangaben in ISO 8601, Laufzeiten in Minuten.",
|
||||
license: { name: "MIT" },
|
||||
},
|
||||
servers: [{ url: `${env.appUrl}/api/v1` }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: "http", scheme: "bearer", description: "Persönlicher API-Schlüssel" },
|
||||
},
|
||||
schemas: {
|
||||
Fehler: {
|
||||
type: "object",
|
||||
properties: { error: { type: "string" }, details: {} },
|
||||
required: ["error"],
|
||||
},
|
||||
Sichtungsergebnis: {
|
||||
type: "object",
|
||||
properties: {
|
||||
watchIds: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Kennungen der neu angelegten Sichtungen — für DELETE zum Zurücknehmen.",
|
||||
},
|
||||
status: { type: "string" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
"/me": {
|
||||
get: {
|
||||
summary: "Angemeldetes Konto",
|
||||
responses: { "200": { description: "Konto samt Umfang der Bibliothek" } },
|
||||
},
|
||||
},
|
||||
"/bibliothek": {
|
||||
get: {
|
||||
summary: "Bibliothek lesen",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", schema: { type: "string" }, description: "Volltextsuche" },
|
||||
{
|
||||
name: "status",
|
||||
in: "query",
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||
},
|
||||
},
|
||||
{ name: "typ", in: "query", schema: { type: "string", enum: ["SERIES", "MOVIE"] } },
|
||||
{ name: "genre", in: "query", schema: { type: "string" } },
|
||||
{ name: "tag", in: "query", schema: { type: "string" } },
|
||||
{ name: "dienst", in: "query", schema: { type: "string" } },
|
||||
{
|
||||
name: "sammlung",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description: "Nur Titel, die in der eigenen Sammlung liegen.",
|
||||
},
|
||||
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
|
||||
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
|
||||
],
|
||||
responses: { "200": { description: "Einträge mit Fortschritt" } },
|
||||
},
|
||||
post: {
|
||||
summary: "Titel aufnehmen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
titleId: { type: "string", description: "Interne Kennung" },
|
||||
tmdbId: { type: "integer", description: "Alternativ: TMDB-Kennung" },
|
||||
type: { type: "string", enum: ["SERIES", "MOVIE"] },
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||
default: "WATCHLIST",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Angelegter oder vorhandener Eintrag" } },
|
||||
},
|
||||
},
|
||||
"/bibliothek/{entryId}": {
|
||||
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
|
||||
get: { summary: "Eintrag mit Fortschritt", responses: { "200": { description: "Eintrag" } } },
|
||||
patch: {
|
||||
summary: "Status, Bewertung, Notiz ändern",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||
},
|
||||
rating: { type: "integer", minimum: 1, maximum: 10, nullable: true },
|
||||
notes: { type: "string", nullable: true },
|
||||
whereToWatch: { type: "string", nullable: true },
|
||||
favorite: { type: "boolean" },
|
||||
owned: {
|
||||
type: "boolean",
|
||||
description: "Liegt der Titel in der eigenen Sammlung?",
|
||||
},
|
||||
ownedSource: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Gespeichert" } },
|
||||
},
|
||||
delete: {
|
||||
summary: "In den Papierkorb legen",
|
||||
responses: { "200": { description: "Verschoben" } },
|
||||
},
|
||||
},
|
||||
"/bibliothek/{entryId}/sichtungen": {
|
||||
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
|
||||
get: { summary: "Sichtungen auflisten", responses: { "200": { description: "Sichtungen" } } },
|
||||
post: {
|
||||
summary: "Als gesehen markieren",
|
||||
description:
|
||||
"action=next hakt die nächste offene Episode ab, movie einen Film, " +
|
||||
"episodes eine Auswahl, season eine ganze Staffel, upTo alles bis zur genannten Episode.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "next" } },
|
||||
required: ["action"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "movie" } },
|
||||
required: ["action"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { const: "episodes" },
|
||||
episodeIds: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["action", "episodeIds"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "season" }, seasonId: { type: "string" } },
|
||||
required: ["action", "seasonId"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "upTo" }, episodeId: { type: "string" } },
|
||||
required: ["action", "episodeId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Angelegte Sichtungen",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Sichtungsergebnis" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: "Sichtungen zurücknehmen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { watchIds: { type: "array", items: { type: "string" } } },
|
||||
required: ["watchIds"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
||||
required: ["episodeIds"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Zurückgenommen" } },
|
||||
},
|
||||
},
|
||||
"/suche": {
|
||||
get: {
|
||||
summary: "TMDB durchsuchen",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", required: true, schema: { type: "string", minLength: 2 } },
|
||||
{ name: "typ", in: "query", schema: { type: "string", enum: ["serie", "film"] } },
|
||||
],
|
||||
responses: {
|
||||
"200": { description: "Treffer mit TMDB-Kennung" },
|
||||
"503": { description: "Kein TMDB-Schlüssel hinterlegt" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/kalender": {
|
||||
get: {
|
||||
summary: "Kommende Episoden",
|
||||
parameters: [
|
||||
{ name: "von", in: "query", schema: { type: "string", format: "date" } },
|
||||
{ name: "bis", in: "query", schema: { type: "string", format: "date" } },
|
||||
],
|
||||
responses: { "200": { description: "Termine im Zeitraum" } },
|
||||
},
|
||||
},
|
||||
"/statistik": {
|
||||
get: {
|
||||
summary: "Kennzahlen und Verteilungen",
|
||||
parameters: [{ name: "jahr", in: "query", schema: { type: "integer" } }],
|
||||
responses: { "200": { description: "Statistik" } },
|
||||
},
|
||||
},
|
||||
"/universen": {
|
||||
get: { summary: "Universen auflisten", responses: { "200": { description: "Universen" } } },
|
||||
post: {
|
||||
summary: "Universum anlegen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, description: { type: "string" } },
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Angelegt" } },
|
||||
},
|
||||
},
|
||||
"/universen/{id}": {
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
get: {
|
||||
summary: "Universum mit Reihenfolge und Fortschritt",
|
||||
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
||||
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
||||
},
|
||||
post: {
|
||||
summary: "Titel ins Universum aufnehmen",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { titleId: { type: "string" } },
|
||||
required: ["titleId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Aufgenommen" } },
|
||||
},
|
||||
},
|
||||
"/export": {
|
||||
get: {
|
||||
summary: "Eigene Daten exportieren",
|
||||
parameters: [
|
||||
{ name: "format", in: "query", schema: { type: "string", enum: ["json", "csv"] } },
|
||||
],
|
||||
responses: { "200": { description: "Datei" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(document, {
|
||||
headers: { "cache-control": "public, max-age=300" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import {
|
||||
getGenreDistribution,
|
||||
getMonthlyActivity,
|
||||
getProviderDistribution,
|
||||
getTotals,
|
||||
getYearReview,
|
||||
} from "@/lib/stats";
|
||||
|
||||
/** GET /api/v1/statistik?jahr=2026 — alle Kennzahlen auf einmal. */
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const parsed = Number.parseInt(url.searchParams.get("jahr") ?? "", 10);
|
||||
const year = Number.isFinite(parsed) ? parsed : new Date().getFullYear();
|
||||
|
||||
const [totals, monthly, providers, genres, review] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMonthlyActivity(user.id, 12),
|
||||
getProviderDistribution(user.id),
|
||||
getGenreDistribution(user.id),
|
||||
getYearReview(user.id, year),
|
||||
]);
|
||||
|
||||
return { totals, monthly, providers, genres, yearReview: review };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apiError, withApi } from "@/lib/api";
|
||||
import { env } from "@/lib/env";
|
||||
import { searchMovies, searchMulti, searchSeries, TmdbNotConfiguredError } from "@/lib/tmdb";
|
||||
|
||||
/**
|
||||
* GET /api/v1/suche?q=…&typ=serie|film
|
||||
*
|
||||
* Durchsucht TMDB. Die Treffer tragen die TMDB-ID — damit lässt sich der
|
||||
* Titel über POST /api/v1/bibliothek direkt aufnehmen.
|
||||
*/
|
||||
export const GET = withApi(async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const query = (url.searchParams.get("q") ?? "").trim();
|
||||
const type = url.searchParams.get("typ");
|
||||
|
||||
if (query.length < 2) {
|
||||
return apiError("Der Suchbegriff braucht mindestens zwei Zeichen.", 400);
|
||||
}
|
||||
if (!env.tmdb.isConfigured) {
|
||||
return apiError("Auf dieser Instanz ist kein TMDB-Schlüssel hinterlegt.", 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const data =
|
||||
type === "serie"
|
||||
? await searchSeries(query)
|
||||
: type === "film"
|
||||
? await searchMovies(query)
|
||||
: await searchMulti(query);
|
||||
|
||||
return {
|
||||
query,
|
||||
total: data.total_results,
|
||||
items: data.results.map((item) => {
|
||||
const isSeries =
|
||||
item.media_type === "tv" || (type === "serie" && item.media_type === undefined);
|
||||
const date = isSeries ? item.first_air_date : item.release_date;
|
||||
return {
|
||||
tmdbId: item.id,
|
||||
type: isSeries ? "SERIES" : "MOVIE",
|
||||
title: item.name ?? item.title ?? "Ohne Titel",
|
||||
overview: item.overview ?? null,
|
||||
posterPath: item.poster_path ?? null,
|
||||
year: date ? Number.parseInt(date.slice(0, 4), 10) : null,
|
||||
voteAverage: item.vote_average ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TmdbNotConfiguredError) return apiError(error.message, 503);
|
||||
return apiError(error instanceof Error ? error.message : "Die Suche ist fehlgeschlagen.", 502);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { addTitle, getUniverse } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||
export const GET = withApi(async (request, user, context) => {
|
||||
const { id } = await context.params;
|
||||
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const universe = await getUniverse(user.id, id, url.searchParams.get("reihenfolge") ?? undefined);
|
||||
if (!universe) return apiError("Dieses Universum gibt es nicht.", 404);
|
||||
|
||||
return universe;
|
||||
});
|
||||
|
||||
const addSchema = z.object({ titleId: z.string().min(1) });
|
||||
|
||||
/** POST /api/v1/universen/{id} — Titel aufnehmen. */
|
||||
export const POST = withApi(async (request, user, context) => {
|
||||
const { id } = await context.params;
|
||||
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||
|
||||
const body = await readJson(request, addSchema);
|
||||
await addTitle(user.id, id, body.titleId);
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { readJson, withApi } from "@/lib/api";
|
||||
import { createUniverse, listUniverses } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen — eigene und geteilte Universen. */
|
||||
export const GET = withApi(async (_request, user) => {
|
||||
const universes = await listUniverses(user.id);
|
||||
return { total: universes.length, items: universes };
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/v1/universen — neues Universum anlegen. */
|
||||
export const POST = withApi(async (request, user) => {
|
||||
const body = await readJson(request, createSchema);
|
||||
const universe = await createUniverse(user.id, body.name, body.description);
|
||||
return { id: universe.id, name: universe.name, slug: universe.slug };
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export default async function LibraryPage({
|
||||
genre: params.genre || undefined,
|
||||
tag: params.tag || undefined,
|
||||
provider: params.dienst || undefined,
|
||||
owned: params.sammlung === "1" ? true : undefined,
|
||||
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { ApiKeys } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "API-Schlüssel" };
|
||||
|
||||
export default async function ApiPage() {
|
||||
const user = await requireUser("/einstellungen/api");
|
||||
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
const example = [
|
||||
'curl -H "Authorization: Bearer <dein-schlüssel>" \\',
|
||||
` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`,
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">API-Schlüssel</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Für Skripte und eigene Anwendungen. Ein Schlüssel hat dieselben Rechte wie dein Konto —
|
||||
gib ihn nicht weiter und zieh ihn zurück, sobald du ihn nicht mehr brauchst.
|
||||
</p>
|
||||
<ApiKeys keys={keys} />
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-3 text-lg font-semibold">So wird er benutzt</h2>
|
||||
<pre className="overflow-x-auto rounded-xl bg-[var(--fl-surface-2)] p-3 text-xs leading-relaxed">
|
||||
{example}
|
||||
</pre>
|
||||
<p className="mt-3 text-sm text-[var(--fl-muted)]">
|
||||
Die vollständige Beschreibung steht in{" "}
|
||||
<a
|
||||
href="/api/v1/openapi.json"
|
||||
className="text-[var(--fl-accent)] underline-offset-4 hover:underline"
|
||||
>
|
||||
openapi.json
|
||||
</a>{" "}
|
||||
und in <code>docs/API.md</code> im Repository.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { ChannelList } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "Benachrichtigungen" };
|
||||
|
||||
export default async function NotificationsPage() {
|
||||
const user = await requireUser("/einstellungen/benachrichtigungen");
|
||||
|
||||
const [channels, recent] = await Promise.all([
|
||||
prisma.notificationChannel.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.notificationLog.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { sentAt: "desc" },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Kanäle</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein.
|
||||
</p>
|
||||
<ChannelList
|
||||
channels={channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
kind: channel.kind,
|
||||
name: channel.name,
|
||||
config: (channel.config ?? {}) as Record<string, string>,
|
||||
enabled: channel.enabled,
|
||||
}))}
|
||||
smtpReady={env.smtp.isConfigured}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Zuletzt verschickt</h2>
|
||||
{recent.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch nichts verschickt.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--fl-border)] text-sm">
|
||||
{recent.map((entry) => (
|
||||
<li key={entry.id} className="py-2.5">
|
||||
<p className="font-medium">{entry.subject}</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{entry.sentAt.toLocaleString("de-DE")}
|
||||
{entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ImportForm } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "Import und Export" };
|
||||
|
||||
export default async function DataPage() {
|
||||
const user = await requireUser("/einstellungen/daten");
|
||||
|
||||
const [entries, watches] = await Promise.all([
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
prisma.watch.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Import</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Bringt Titel aus einem anderen Werkzeug mit.
|
||||
</p>
|
||||
<ImportForm />
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Export</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href="/api/v1/export?format=json" className="fl-btn fl-btn-primary" download>
|
||||
Als JSON
|
||||
</a>
|
||||
<a href="/api/v1/export?format=csv" className="fl-btn fl-btn-ghost" download>
|
||||
Als CSV
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-[var(--fl-faint)]">
|
||||
JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform
|
||||
für Tabellenkalkulationen.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SettingsNav } from "@/components/settings/SettingsNav";
|
||||
|
||||
/** Rahmen der Einstellungen: Seitennavigation links, Inhalt rechts. */
|
||||
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser("/einstellungen");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<SettingsNav isAdmin={user.role === "ADMIN"} />
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await requireUser("/einstellungen");
|
||||
const settings = await getSettings();
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.id },
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
region: true,
|
||||
publicProfile: true,
|
||||
profileSlug: true,
|
||||
notifyNewEpisode: true,
|
||||
notifyRelease: true,
|
||||
notifyAvailability: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { passwordHash, ...profile } = user;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Profil</h2>
|
||||
<ProfileForm user={profile} publicProfilesEnabled={settings.publicProfilesEnabled} />
|
||||
</section>
|
||||
|
||||
{passwordHash && (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Passwort</h2>
|
||||
<PasswordForm />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getUpcomingEpisodes } from "@/lib/stats";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CalendarMonth } from "@/components/CalendarMonth";
|
||||
import { episodeCode, formatDateLong } from "@/lib/labels";
|
||||
|
||||
export const metadata: Metadata = { title: "Kalender" };
|
||||
|
||||
/** Monatsangabe aus der URL („2026-09") oder der laufende Monat. */
|
||||
function parseMonth(value: string | undefined): Date {
|
||||
const match = value?.match(/^(\d{4})-(\d{2})$/);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]) - 1;
|
||||
if (year >= 1900 && year <= 2200 && month >= 0 && month <= 11) {
|
||||
return new Date(year, month, 1);
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
|
||||
function monthParam(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function CalendarPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ monat?: string }>;
|
||||
}) {
|
||||
const user = await requireUser("/kalender");
|
||||
const settings = await getSettings();
|
||||
const params = await searchParams;
|
||||
|
||||
const month = parseMonth(params.monat);
|
||||
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
// Das Raster reicht in den Vor- und Folgemonat hinein — die Termine auch.
|
||||
const gridFrom = new Date(from.getTime() - 7 * 86_400_000);
|
||||
const gridTo = new Date(from.getFullYear(), from.getMonth() + 1, 7);
|
||||
|
||||
const episodes = await getUpcomingEpisodes(user.id, gridFrom, gridTo);
|
||||
const inMonth = episodes.filter((episode) => episode.airDate.getMonth() === month.getMonth());
|
||||
|
||||
const previous = new Date(month.getFullYear(), month.getMonth() - 1, 1);
|
||||
const next = new Date(month.getFullYear(), month.getMonth() + 1, 1);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Kalender</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Ausstrahlungstermine der Serien in deiner Bibliothek.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2" aria-label="Monat wechseln">
|
||||
<Link
|
||||
href={`/kalender?monat=${monthParam(previous)}`}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
aria-label={`Zum ${previous.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
|
||||
>
|
||||
←
|
||||
</Link>
|
||||
<span className="min-w-40 text-center font-medium">
|
||||
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<Link
|
||||
href={`/kalender?monat=${monthParam(next)}`}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
aria-label={`Zum ${next.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
|
||||
>
|
||||
→
|
||||
</Link>
|
||||
<Link href="/kalender" className="ml-1 text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Heute
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="fl-card overflow-x-auto p-3 sm:p-4">
|
||||
<div className="min-w-[44rem]">
|
||||
<CalendarMonth month={month} episodes={episodes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-6" aria-labelledby="liste">
|
||||
<h2 id="liste" className="mb-3 text-lg font-semibold">
|
||||
Termine im {month.toLocaleDateString("de-DE", { month: "long" })}
|
||||
</h2>
|
||||
|
||||
{inMonth.length === 0 ? (
|
||||
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
In diesem Monat läuft nichts aus deiner Bibliothek.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="fl-card divide-y divide-[var(--fl-border)]">
|
||||
{inMonth.map((episode) => (
|
||||
<li key={episode.episodeId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
|
||||
<span className="w-52 shrink-0 text-sm text-[var(--fl-faint)]">
|
||||
{formatDateLong(episode.airDate)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
className="min-w-0 flex-1 text-sm hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
<span className="font-medium">{episode.title}</span>
|
||||
<span className="ml-2 font-mono text-xs text-[var(--fl-faint)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>
|
||||
<span className="ml-2 text-[var(--fl-muted)]">{episode.name}</span>
|
||||
</Link>
|
||||
{episode.runtime && (
|
||||
<span className="shrink-0 text-xs text-[var(--fl-faint)]">{episode.runtime} min</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,30 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Outfit } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
||||
import { ToastProvider } from "@/components/Toasts";
|
||||
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
|
||||
import { env } from "@/lib/env";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende
|
||||
* Instanz fragt also niemals bei Google an.
|
||||
* Outfit liegt als Datei im Repository.
|
||||
*
|
||||
* next/font/google würde die Schrift beim Bauen herunterladen — das macht den
|
||||
* Build von einer Netzverbindung abhängig und ließ ihn im Container hängen.
|
||||
* Für eine selbst gehostete Anwendung ist die mitgelieferte Datei ohnehin das
|
||||
* Richtige: kein Abruf beim Bauen, keiner zur Laufzeit.
|
||||
*
|
||||
* Es ist die variable Fassung (Strichstärke 100–900), aufgeteilt in Latin und
|
||||
* Latin-Extended — der Browser lädt nur, was die Seite wirklich braucht.
|
||||
*/
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
const outfit = localFont({
|
||||
src: [
|
||||
{ path: "../../public/fonts/outfit-latin.woff2", weight: "100 900", style: "normal" },
|
||||
{ path: "../../public/fonts/outfit-latin-ext.woff2", weight: "100 900", style: "normal" },
|
||||
],
|
||||
display: "swap",
|
||||
variable: "--font-outfit",
|
||||
fallback: ["Inter", "Segoe UI", "system-ui", "sans-serif"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -75,6 +87,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
<ServiceWorkerRegistration />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
|
||||
export const metadata: Metadata = { title: "Keine Verbindung" };
|
||||
|
||||
/**
|
||||
* Rückfallseite des Service Workers.
|
||||
*
|
||||
* Bewusst ohne Datenzugriff: sie muss auch dann funktionieren, wenn der
|
||||
* Server nicht erreichbar ist, und wird deshalb statisch vorgerendert.
|
||||
*/
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<main
|
||||
id="inhalt"
|
||||
className="flex min-h-dvh flex-col items-center justify-center px-6 text-center"
|
||||
>
|
||||
<FlimmerMark className="h-16 w-16" />
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight">Gerade keine Verbindung</h1>
|
||||
<p className="mt-2 max-w-sm text-[var(--fl-muted)]">
|
||||
Flimmer erreicht deinen Server nicht. Bereits geladene Seiten und Poster bleiben verfügbar —
|
||||
alles Weitere kommt zurück, sobald die Verbindung wieder steht.
|
||||
</p>
|
||||
<Link href="/" className="fl-btn fl-btn-primary mt-6">
|
||||
Noch einmal versuchen
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,196 @@ import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import {
|
||||
getContinueWatching,
|
||||
getNewlyAvailable,
|
||||
getUpcomingEpisodes,
|
||||
} from "@/lib/stats";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { episodeCode, formatDate, formatRelative, isUpcoming } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { QuickWatch } from "@/components/library/QuickWatch";
|
||||
|
||||
/**
|
||||
* Übersichtsseite.
|
||||
* Übersicht: der Einstieg für „was schaue ich als Nächstes".
|
||||
*
|
||||
* In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten
|
||||
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein.
|
||||
* Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
|
||||
* Watchlist verfügbar geworden ist.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
|
||||
const now = new Date();
|
||||
const inFourWeeks = new Date(now.getTime() + 28 * 86_400_000);
|
||||
|
||||
const [continueItems, upcoming, available, libraryCount] = await Promise.all([
|
||||
getContinueWatching(user.id, 8),
|
||||
getUpcomingEpisodes(user.id, now, inFourWeeks),
|
||||
getNewlyAvailable(user.id, user.region),
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
]);
|
||||
|
||||
if (libraryCount === 0) {
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<EmptyStart tmdbReady={env.tmdb.isConfigured} name={user.name} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<h1 className="sr-only">Übersicht</h1>
|
||||
|
||||
<section aria-labelledby="weiterschauen">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 id="weiterschauen" className="text-lg font-semibold">
|
||||
Weiterschauen
|
||||
</h2>
|
||||
<Link href="/bibliothek?status=WATCHING" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Alle laufenden
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{continueItems.length === 0 ? (
|
||||
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{continueItems.map((item) => (
|
||||
<li key={item.entryId} className="fl-card flex gap-3 p-3">
|
||||
<Link href={`/titel/${item.titleId}`} className="w-16 shrink-0 sm:w-20">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w185" />
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Link href={`/titel/${item.titleId}`} className="truncate font-medium hover:text-[var(--fl-accent)]">
|
||||
{item.title}
|
||||
</Link>
|
||||
|
||||
{item.next && (
|
||||
<p className="mt-0.5 truncate text-sm">
|
||||
<span className="fl-gradient-text font-mono font-semibold">
|
||||
{episodeCode(item.next.seasonNumber, item.next.episodeNumber)}
|
||||
</span>{" "}
|
||||
<span className="text-[var(--fl-muted)]">{item.next.name}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||
{item.watched} von {item.total} Episoden
|
||||
{item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
percent={item.percent}
|
||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5">
|
||||
{item.next && isUpcoming(item.next.airDate) ? (
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Nächste Episode erst am {formatDate(item.next.airDate)}
|
||||
</p>
|
||||
) : (
|
||||
<QuickWatch entryId={item.entryId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<section aria-labelledby="demnaechst">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 id="demnaechst" className="text-lg font-semibold">
|
||||
Demnächst
|
||||
</h2>
|
||||
<Link href="/kalender" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Zum Kalender
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-2.5">
|
||||
{upcoming.slice(0, 6).map((episode) => (
|
||||
<li key={episode.episodeId} className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs text-[var(--fl-faint)]">
|
||||
{formatDate(episode.airDate)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
className="min-w-0 flex-1 truncate text-sm hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
<span className="font-medium">{episode.title}</span>
|
||||
<span className="ml-1.5 font-mono text-xs text-[var(--fl-faint)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="verfuegbar">
|
||||
<h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
|
||||
Neu verfügbar
|
||||
</h2>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
{available.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
|
||||
einem Dienst in {user.region} auftaucht, steht er hier.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{available.slice(0, 5).map((item) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/titel/${item.titleId}`}
|
||||
className="block truncate text-sm font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{item.providers.map((provider) => provider.name).join(", ")} ·{" "}
|
||||
{formatRelative(item.since)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
|
||||
return (
|
||||
<section className="fl-card relative overflow-hidden p-6 sm:p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -24,60 +199,31 @@ export default async function DashboardPage() {
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<FlimmerMark className="h-12 w-12" />
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Hallo {user.name.split(" ")[0]}.
|
||||
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Hallo {name.split(" ")[0]}.
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-[var(--fl-muted)]">
|
||||
Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier
|
||||
deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade
|
||||
verfügbar geworden ist.
|
||||
Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du
|
||||
hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner
|
||||
Watchlist verfügbar geworden ist.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary">
|
||||
Titel suchen und hinzufügen
|
||||
Titel suchen
|
||||
</Link>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-ghost">
|
||||
Zur Bibliothek
|
||||
<Link href="/titel/neu" className="fl-btn fl-btn-ghost">
|
||||
Titel selbst anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!env.tmdb.isConfigured && (
|
||||
{!tmdbReady && (
|
||||
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
|
||||
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine
|
||||
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte
|
||||
die Instanz neu.
|
||||
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Metadaten —
|
||||
trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte die Instanz neu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<PlaceholderCard
|
||||
title="Weiterschauen"
|
||||
text="Deine laufenden Serien mit der jeweils nächsten Episode."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Demnächst"
|
||||
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Neu verfügbar"
|
||||
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind."
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderCard({ title, text }: { title: string; text: string }) {
|
||||
return (
|
||||
<div className="fl-card p-5">
|
||||
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import {
|
||||
getActiveYears,
|
||||
getGenreDistribution,
|
||||
getMonthlyActivity,
|
||||
getMostWatched,
|
||||
getProviderDistribution,
|
||||
getTopRated,
|
||||
getTotals,
|
||||
getYearReview,
|
||||
} from "@/lib/stats";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
|
||||
|
||||
export const metadata: Metadata = { title: "Statistik" };
|
||||
|
||||
export default async function StatsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ jahr?: string }>;
|
||||
}) {
|
||||
const user = await requireUser("/statistik");
|
||||
const settings = await getSettings();
|
||||
const params = await searchParams;
|
||||
|
||||
const years = await getActiveYears(user.id);
|
||||
const requested = Number.parseInt(params.jahr ?? "", 10);
|
||||
const year = years.includes(requested) ? requested : (years[0] ?? new Date().getFullYear());
|
||||
|
||||
const [totals, monthly, providers, genres, topRated, mostWatched, review] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMonthlyActivity(user.id, 12),
|
||||
getProviderDistribution(user.id),
|
||||
getGenreDistribution(user.id),
|
||||
getTopRated(user.id, 5),
|
||||
getMostWatched(user.id, 5),
|
||||
getYearReview(user.id, year),
|
||||
]);
|
||||
|
||||
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
||||
</header>
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
|
||||
Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier
|
||||
Schauzeit, Verteilungen und der Jahresrückblick.
|
||||
</p>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-primary mt-4">
|
||||
Zur Bibliothek
|
||||
</Link>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const days = totals.minutes / (60 * 24);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="fl-card mb-6 p-5 sm:p-7" aria-label="Gesamtschauzeit">
|
||||
<p className="text-sm font-medium text-[var(--fl-muted)]">Gesamte Schauzeit</p>
|
||||
<p className="fl-gradient-text mt-1 text-5xl font-semibold tabular-nums sm:text-6xl">
|
||||
{Math.round(totals.minutes / 60).toLocaleString("de-DE")} h
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück
|
||||
</p>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
||||
<Stat
|
||||
label="In der Bibliothek"
|
||||
value={`${totals.seriesCount} Serien · ${totals.movieCount} Filme`}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="fl-card mb-6 p-5" aria-labelledby="verlauf">
|
||||
<h2 id="verlauf" className="mb-1 text-lg font-semibold">
|
||||
Die letzten zwölf Monate
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Abgehakte Episoden und Filme je Monat.
|
||||
</p>
|
||||
<MonthlyChart points={monthly.map(({ label, episodes, movies, minutes }) => ({ label, episodes, movies, minutes }))} />
|
||||
</section>
|
||||
|
||||
<div className="mb-6 grid gap-6 lg:grid-cols-2">
|
||||
<section className="fl-card p-5" aria-labelledby="dienste">
|
||||
<h2 id="dienste" className="mb-1 text-lg font-semibold">
|
||||
Nach Streamingdienst
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das".
|
||||
</p>
|
||||
<DistributionBars
|
||||
rows={providers.slice(0, 8)}
|
||||
emptyText="Noch keine Sichtung einem Dienst zugeordnet."
|
||||
useBrandColors
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5" aria-labelledby="genres">
|
||||
<h2 id="genres" className="mb-1 text-lg font-semibold">
|
||||
Nach Genre
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Titel mit mehreren Genres zählen in jedem mit.
|
||||
</p>
|
||||
<DistributionBars rows={genres.slice(0, 8)} emptyText="Noch keine Genres erfasst." />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="fl-card mb-6 p-5" aria-labelledby="rueckblick">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 id="rueckblick" className="text-lg font-semibold">
|
||||
{review.year} in Zahlen
|
||||
</h2>
|
||||
{years.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Jahr wählen">
|
||||
{years.slice(0, 6).map((entry) => (
|
||||
<Link
|
||||
key={entry}
|
||||
href={`/statistik?jahr=${entry}`}
|
||||
aria-current={entry === review.year ? "true" : undefined}
|
||||
className="rounded-full border px-3 py-1 text-sm font-medium transition-colors"
|
||||
style={
|
||||
entry === review.year
|
||||
? {
|
||||
backgroundImage: "var(--fl-gradient)",
|
||||
color: "var(--fl-on-accent)",
|
||||
borderColor: "transparent",
|
||||
}
|
||||
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
||||
}
|
||||
>
|
||||
{entry}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<Stat label="Schauzeit" value={formatMinutes(review.minutes)} />
|
||||
<Stat label="Episoden" value={review.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={review.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Neu angefangen" value={review.titlesStarted.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={review.titlesCompleted.toLocaleString("de-DE")} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
{review.busiestMonth && (
|
||||
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
||||
<span className="text-[var(--fl-faint)]">Stärkster Monat: </span>
|
||||
<span className="font-medium">{review.busiestMonth.label}</span>
|
||||
<span className="text-[var(--fl-muted)]">
|
||||
{" "}
|
||||
mit {formatMinutes(review.busiestMonth.minutes)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{review.topTitle && (
|
||||
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
||||
<span className="text-[var(--fl-faint)]">Die meiste Zeit ging an: </span>
|
||||
<Link href={`/titel/${review.topTitle.titleId}`} className="font-medium hover:text-[var(--fl-accent)]">
|
||||
{review.topTitle.title}
|
||||
</Link>
|
||||
<span className="text-[var(--fl-muted)]"> ({formatMinutes(review.topTitle.minutes)})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TitleList
|
||||
id="meistzeit"
|
||||
heading="Wo die meiste Zeit hinging"
|
||||
items={mostWatched}
|
||||
renderMeta={(item) => formatMinutes(item.minutes)}
|
||||
emptyText="Noch nichts gesehen."
|
||||
/>
|
||||
<TitleList
|
||||
id="bestbewertet"
|
||||
heading="Deine besten Bewertungen"
|
||||
items={topRated}
|
||||
renderMeta={(item) => (item.rating ? `${item.rating}/10` : "—")}
|
||||
emptyText="Noch nichts bewertet."
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleList({
|
||||
id,
|
||||
heading,
|
||||
items,
|
||||
renderMeta,
|
||||
emptyText,
|
||||
}: {
|
||||
id: string;
|
||||
heading: string;
|
||||
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: string }[];
|
||||
renderMeta: (item: { rating: number | null; minutes: number }) => string;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="fl-card p-5" aria-labelledby={id}>
|
||||
<h2 id={id} className="mb-4 text-lg font-semibold">
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">{emptyText}</p>
|
||||
) : (
|
||||
<ol className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/titel/${item.titleId}`}
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||
{renderMeta(item)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
{year ? ` · ${year}` : ""}
|
||||
{title.status ? ` · ${title.status}` : ""}
|
||||
</p>
|
||||
{entry?.owned && (
|
||||
<span
|
||||
className="rounded-full px-2.5 py-0.5 text-xs font-medium text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
title={
|
||||
entry.ownedSource
|
||||
? `In deiner Sammlung: ${entry.ownedSource}`
|
||||
: "In deiner Sammlung"
|
||||
}
|
||||
>
|
||||
Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{entry && (
|
||||
<span
|
||||
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
@@ -225,6 +238,7 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
|
||||
) : (
|
||||
<div className="fl-card p-4">
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
whereToWatch={entry.whereToWatch}
|
||||
tags={entry.tags.map((link) => link.tag.name)}
|
||||
suggestion={suggestion}
|
||||
owned={entry.owned}
|
||||
ownedSource={entry.ownedSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Wo läuft das?
|
||||
</h2>
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
@@ -299,6 +316,34 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweis auf die eigene Sammlung — steht bewusst **vor** der
|
||||
* Streaming-Verfügbarkeit: was man hat, ist die verlässlichere Antwort auf
|
||||
* „wo kann ich das schauen".
|
||||
*/
|
||||
function OwnedNotice({ owned, source }: { owned: boolean; source: string | null }) {
|
||||
if (!owned) return null;
|
||||
|
||||
return (
|
||||
<p
|
||||
className="mb-3 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"
|
||||
style={{
|
||||
borderColor: "color-mix(in oklab, var(--fl-success) 45%, transparent)",
|
||||
backgroundColor: "color-mix(in oklab, var(--fl-success) 10%, transparent)",
|
||||
color: "var(--fl-success)",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
In deiner Sammlung
|
||||
{source ? <span className="font-medium"> · {source}</span> : null}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
|
||||
import { formatMinutes, formatRelative } from "@/lib/labels";
|
||||
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
/**
|
||||
* Öffentliche Profilseite.
|
||||
*
|
||||
* Zeigt Zahlen und zuletzt Gesehenes — **keine** Notizen, keine Watchlist,
|
||||
* keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Die Seite ist
|
||||
* nur erreichbar, wenn der Nutzer sie freigeschaltet hat *und* der Betreiber
|
||||
* öffentliche Profile instanzweit erlaubt.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||
select: { name: true },
|
||||
});
|
||||
return user ? { title: `${user.name} · Profil` } : { title: "Profil" };
|
||||
}
|
||||
|
||||
export default async function PublicProfilePage({ params }: { params: Params }) {
|
||||
const { slug } = await params;
|
||||
const settings = await getSettings();
|
||||
if (!settings.publicProfilesEnabled) notFound();
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||
select: { id: true, name: true, createdAt: true },
|
||||
});
|
||||
if (!user) notFound();
|
||||
|
||||
const [totals, mostWatched, topRated, recent] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMostWatched(user.id, 6),
|
||||
getTopRated(user.id, 6),
|
||||
prisma.watch.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { watchedAt: "desc" },
|
||||
take: 8,
|
||||
distinct: ["titleId"],
|
||||
include: {
|
||||
title: { select: { id: true, title: true, posterPath: true, type: true } },
|
||||
episode: { select: { seasonNumber: true, episodeNumber: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
|
||||
<Link href="/" aria-label="Flimmer">
|
||||
<FlimmerLogo />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<main id="inhalt" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||
<header className="fl-card relative overflow-hidden p-6 sm:p-8">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-20 -top-28 h-64 w-64 rounded-full opacity-25 blur-3xl"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Öffentliches Profil
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{user.name}</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</p>
|
||||
|
||||
<dl className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Stat label="Schauzeit" value={formatMinutes(totals.minutes)} />
|
||||
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{recent.map((watch) => (
|
||||
<li key={watch.id}>
|
||||
<Poster path={watch.title.posterPath} alt={watch.title.title} size="w185" />
|
||||
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-snug">
|
||||
{watch.title.title}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{formatRelative(watch.watchedAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<TitleList heading="Die meiste Zeit" items={mostWatched} showMinutes />
|
||||
<TitleList heading="Bestbewertet" items={topRated} />
|
||||
</div>
|
||||
|
||||
{totals.minutes === 0 && (
|
||||
<p className="fl-card mt-6 p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
Hier ist noch nichts zu sehen.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 text-center text-xs text-[var(--fl-faint)] sm:px-6">
|
||||
Erstellt mit Flimmer · Metadaten von TMDB
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleList({
|
||||
heading,
|
||||
items,
|
||||
showMinutes = false,
|
||||
}: {
|
||||
heading: string;
|
||||
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number }[];
|
||||
showMinutes?: boolean;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">{heading}</h2>
|
||||
<ol className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||
{showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUniverse, suggestCollections } from "@/lib/universes";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { OrderedList } from "@/components/universes/OrderedList";
|
||||
import {
|
||||
AddTitles,
|
||||
DeleteUniverse,
|
||||
OrderTabs,
|
||||
SharePanel,
|
||||
} from "@/components/universes/UniverseTools";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
type Search = Promise<{ reihenfolge?: string }>;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const universe = await prisma.universe.findUnique({ where: { id }, select: { name: true } });
|
||||
return { title: universe?.name ?? "Universum" };
|
||||
}
|
||||
|
||||
export default async function UniversePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Params;
|
||||
searchParams: Search;
|
||||
}) {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
const { id } = await params;
|
||||
const { reihenfolge } = await searchParams;
|
||||
|
||||
const universe = await getUniverse(user.id, id, reihenfolge);
|
||||
if (!universe) notFound();
|
||||
|
||||
const activeOrder = universe.orders.find((order) => order.id === universe.activeOrderId);
|
||||
const inUniverse = new Set(universe.entries.map((entry) => entry.titleId));
|
||||
|
||||
// Vorschläge zum Aufnehmen: alles aus der eigenen Bibliothek, was noch fehlt.
|
||||
const [libraryTitles, collections] = universe.canWrite
|
||||
? await Promise.all([
|
||||
prisma.libraryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { title: { title: "asc" } },
|
||||
select: {
|
||||
title: { select: { id: true, title: true, type: true, releaseDate: true } },
|
||||
},
|
||||
}),
|
||||
suggestCollections(user.id),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
const candidates = libraryTitles
|
||||
.map((entry) => entry.title)
|
||||
.filter((title) => !inUniverse.has(title.id))
|
||||
.map((title) => ({
|
||||
id: title.id,
|
||||
title: title.title,
|
||||
type: title.type,
|
||||
year: title.releaseDate?.getFullYear() ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Universum
|
||||
{!universe.isOwner && ` · geteilt von ${universe.ownerName}`}
|
||||
{!universe.canWrite && " · nur ansehen"}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{universe.name}</h1>
|
||||
{universe.description && (
|
||||
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">{universe.description}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section className="fl-card mb-5 p-5" aria-label="Gesamtfortschritt">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">
|
||||
{universe.progress.completed}{" "}
|
||||
<span className="text-base font-normal text-[var(--fl-muted)]">
|
||||
von {universe.progress.total} abgeschlossen
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{formatMinutes(universe.progress.watchedMinutes)} gesehen
|
||||
{universe.progress.remainingMinutes > 0 && (
|
||||
<>
|
||||
{" · noch "}
|
||||
<span className="text-[var(--fl-text)]">
|
||||
{formatMinutes(universe.progress.remainingMinutes)}
|
||||
</span>
|
||||
{" bis zum Ende des Universums"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{universe.nextUp && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Als Nächstes dran
|
||||
</p>
|
||||
<Link
|
||||
href={`/titel/${universe.nextUp.titleId}`}
|
||||
className="fl-gradient-text text-lg font-semibold"
|
||||
>
|
||||
{universe.nextUp.title}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<ProgressBar
|
||||
percent={universe.progress.percent}
|
||||
label={`${universe.progress.percent} Prozent des Universums abgeschlossen`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mb-4">
|
||||
<OrderTabs universe={universe} canWrite={universe.canWrite} />
|
||||
{activeOrder?.kind === "RELEASE" ? (
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell.
|
||||
</p>
|
||||
) : universe.canWrite ? (
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div className="order-2 lg:order-1">
|
||||
<OrderedList
|
||||
universeId={universe.id}
|
||||
orderId={universe.activeOrderId}
|
||||
entries={universe.entries}
|
||||
sortable={activeOrder?.kind === "CUSTOM"}
|
||||
canWrite={universe.canWrite}
|
||||
nextUpItemId={universe.nextUp?.itemId ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="order-1 space-y-5 lg:order-2">
|
||||
{universe.canWrite && (
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Titel aufnehmen
|
||||
</h2>
|
||||
<AddTitles
|
||||
universeId={universe.id}
|
||||
candidates={candidates}
|
||||
collections={collections}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{universe.isOwner && (
|
||||
<>
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Teilen
|
||||
</h2>
|
||||
<SharePanel universe={universe} />
|
||||
</div>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
<DeleteUniverse universeId={universe.id} name={universe.name} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm">
|
||||
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
||||
← Alle Universen
|
||||
</Link>
|
||||
</p>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { NewUniverseForm } from "@/components/universes/UniverseTools";
|
||||
|
||||
export const metadata: Metadata = { title: "Neues Universum" };
|
||||
|
||||
export default async function NewUniversePage() {
|
||||
const user = await requireUser("/universen/neu");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<div className="mx-auto max-w-xl">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Neues Universum</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Ein Universum bündelt Filme und Serien, die zusammengehören. Die
|
||||
Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du
|
||||
danach an.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="fl-card p-5 sm:p-6">
|
||||
<NewUniverseForm />
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm">
|
||||
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
||||
← Zurück zur Übersicht
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { listUniverses } from "@/lib/universes";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
|
||||
export const metadata: Metadata = { title: "Universen" };
|
||||
|
||||
export default async function UniversesPage() {
|
||||
const user = await requireUser("/universen");
|
||||
const settings = await getSettings();
|
||||
const universes = await listUniverses(user.id);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Universen</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">
|
||||
Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem
|
||||
Gesamtfortschritt.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/universen/neu" className="fl-btn fl-btn-primary">
|
||||
Neues Universum
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{universes.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
|
||||
Noch kein Universum angelegt. Ein Universum bündelt zusammengehörige Titel — etwa alle
|
||||
MCU-Filme samt Serien — und zeigt dir, was als Nächstes dran ist.
|
||||
</p>
|
||||
<Link href="/universen/neu" className="fl-btn fl-btn-primary mt-4">
|
||||
Erstes Universum anlegen
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{universes.map((universe) => (
|
||||
<li key={universe.id}>
|
||||
<Link
|
||||
href={`/universen/${universe.id}`}
|
||||
className="fl-card fl-poster-card block overflow-hidden p-4"
|
||||
>
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
{universe.posters.length > 0 ? (
|
||||
universe.posters.map((path, index) => (
|
||||
<div key={`${path}-${index}`} className="w-1/5">
|
||||
<Poster path={path} alt="" size="w154" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="w-1/5">
|
||||
<Poster path={null} alt={universe.name} size="w154" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="font-semibold">{universe.name}</h2>
|
||||
{universe.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-[var(--fl-muted)]">
|
||||
{universe.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
{universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"}
|
||||
{!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
percent={universe.percent}
|
||||
label={`${universe.completed} von ${universe.itemCount} abgeschlossen`}
|
||||
showValue
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { AdminSettings, UserRow } from "@/components/settings/Forms";
|
||||
import { formatRelative } from "@/lib/labels";
|
||||
|
||||
export const metadata: Metadata = { title: "Verwaltung" };
|
||||
|
||||
export default async function AdminPage() {
|
||||
const admin = await requireAdmin();
|
||||
const settings = await getSettings();
|
||||
|
||||
const [users, lastRun, catalog] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
_count: { select: { entries: true } },
|
||||
},
|
||||
}),
|
||||
prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } }),
|
||||
Promise.all([
|
||||
prisma.title.count(),
|
||||
prisma.episode.count(),
|
||||
prisma.watch.count(),
|
||||
]),
|
||||
]);
|
||||
|
||||
const [titleCount, episodeCount, watchCount] = catalog;
|
||||
|
||||
return (
|
||||
<AppShell user={admin} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verwaltung</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Einstellungen der Instanz, Konten und Hintergrundlauf.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Instanz</h2>
|
||||
<AdminSettings
|
||||
registrationEnabled={settings.registrationEnabled}
|
||||
publicProfilesEnabled={settings.publicProfilesEnabled}
|
||||
instanceName={settings.instanceName}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Zustand</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<Fact label="Konten" value={String(users.length)} />
|
||||
<Fact label="Titel im Katalog" value={titleCount.toLocaleString("de-DE")} />
|
||||
<Fact label="Episoden" value={episodeCount.toLocaleString("de-DE")} />
|
||||
<Fact label="Sichtungen" value={watchCount.toLocaleString("de-DE")} />
|
||||
<Fact
|
||||
label="TMDB"
|
||||
value={env.tmdb.isConfigured ? "eingerichtet" : "kein Schlüssel"}
|
||||
/>
|
||||
<Fact label="SMTP" value={env.smtp.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||
<Fact
|
||||
label="Zeitplan"
|
||||
value={
|
||||
env.scheduler.enabled
|
||||
? `täglich ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr`
|
||||
: "abgeschaltet"
|
||||
}
|
||||
/>
|
||||
<Fact label="OIDC" value={env.oidc.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-3 text-lg font-semibold">Letzter Abgleich</h2>
|
||||
{lastRun ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
{formatRelative(lastRun.startedAt)} ·{" "}
|
||||
<span style={{ color: lastRun.ok ? "var(--fl-success)" : "var(--fl-danger)" }}>
|
||||
{lastRun.ok ? "erfolgreich" : "mit Fehlern"}
|
||||
</span>
|
||||
{lastRun.message ? ` · ${lastRun.message}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch kein Abgleich gelaufen.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="fl-card mt-6 p-5">
|
||||
<h2 className="mb-2 text-lg font-semibold">Konten</h2>
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
entries: user._count.entries,
|
||||
}}
|
||||
isSelf={user.id === admin.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
import { episodeCode } from "@/lib/labels";
|
||||
import type { UpcomingEpisode } from "@/lib/stats";
|
||||
|
||||
/**
|
||||
* Monatsraster mit den Ausstrahlungsterminen.
|
||||
*
|
||||
* Bewusst als Tabelle ausgezeichnet: ein Kalender *ist* eine Tabelle, und mit
|
||||
* Screenreader wird daraus dann „Woche 3, Donnerstag, 14." statt einer
|
||||
* ununterscheidbaren Kachelwüste.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function startOfGrid(month: Date): Date {
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
// Montag als Wochenanfang: Sonntag (0) wird zu 6.
|
||||
const weekday = (first.getDay() + 6) % 7;
|
||||
return new Date(first.getFullYear(), first.getMonth(), 1 - weekday);
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarMonth({
|
||||
month,
|
||||
episodes,
|
||||
}: {
|
||||
month: Date;
|
||||
episodes: UpcomingEpisode[];
|
||||
}) {
|
||||
const today = new Date();
|
||||
const start = startOfGrid(month);
|
||||
|
||||
// Sechs Wochen zeigen immer den ganzen Monat, egal auf welchen Tag er fällt.
|
||||
const days = Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return date;
|
||||
});
|
||||
|
||||
const byDay = new Map<string, UpcomingEpisode[]>();
|
||||
for (const episode of episodes) {
|
||||
const key = episode.airDate.toDateString();
|
||||
const list = byDay.get(key);
|
||||
if (list) list.push(episode);
|
||||
else byDay.set(key, [episode]);
|
||||
}
|
||||
|
||||
const weeks = Array.from({ length: 6 }, (_, index) => days.slice(index * 7, index * 7 + 7));
|
||||
|
||||
return (
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<caption className="sr-only">
|
||||
Ausstrahlungstermine im {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{WEEKDAYS.map((day) => (
|
||||
<th
|
||||
key={day}
|
||||
scope="col"
|
||||
className="pb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]"
|
||||
>
|
||||
{day}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<tr key={weekIndex}>
|
||||
{week.map((date) => {
|
||||
const inMonth = date.getMonth() === month.getMonth();
|
||||
const isToday = isSameDay(date, today);
|
||||
const list = byDay.get(date.toDateString()) ?? [];
|
||||
|
||||
return (
|
||||
<td
|
||||
key={date.toISOString()}
|
||||
className={clsx(
|
||||
"h-24 border border-[var(--fl-border)] p-1 align-top sm:h-28",
|
||||
!inMonth && "opacity-40",
|
||||
)}
|
||||
style={
|
||||
list.length > 0
|
||||
? { backgroundColor: "color-mix(in oklab, var(--fl-magenta) 7%, transparent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"mb-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs tabular-nums",
|
||||
isToday
|
||||
? "font-semibold text-[var(--fl-on-accent)]"
|
||||
: "text-[var(--fl-faint)]",
|
||||
)}
|
||||
style={isToday ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
{list.slice(0, 3).map((episode) => (
|
||||
<li key={episode.episodeId}>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
title={`${episode.title} — ${episodeCode(episode.seasonNumber, episode.episodeNumber)} ${episode.name}`}
|
||||
className="block truncate rounded px-1 py-0.5 text-[0.7rem] leading-tight text-[var(--fl-text)] transition-colors hover:bg-[var(--fl-surface-2)]"
|
||||
>
|
||||
<span className="font-mono text-[var(--fl-accent)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>{" "}
|
||||
{episode.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{list.length > 3 && (
|
||||
<li className="px-1 text-[0.7rem] text-[var(--fl-faint)]">
|
||||
+ {list.length - 3} weitere
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clsx } from "clsx";
|
||||
import { posterUrl, type ImageSize } from "@/lib/images";
|
||||
import { posterUrl, type ImageSize } from "@/lib/image-url";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { posterUrl } from "@/lib/images";
|
||||
import { posterUrl } from "@/lib/image-url";
|
||||
|
||||
/**
|
||||
* Streamingdienste als Badges.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Meldet den Service Worker an.
|
||||
*
|
||||
* Nur in der Produktion: im Entwicklungsmodus würde ein Zwischenspeicher
|
||||
* dauernd veraltete Bündel ausliefern und jede Änderung verschleiern.
|
||||
*/
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const register = () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch((error) => {
|
||||
console.warn("Service Worker konnte nicht angemeldet werden:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") register();
|
||||
else window.addEventListener("load", register, { once: true });
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
|
||||
@@ -17,6 +18,8 @@ export function EntryNotes({
|
||||
whereToWatch,
|
||||
tags,
|
||||
suggestion,
|
||||
owned,
|
||||
ownedSource,
|
||||
}: {
|
||||
entryId: string;
|
||||
notes: string | null;
|
||||
@@ -24,9 +27,12 @@ export function EntryNotes({
|
||||
tags: string[];
|
||||
/** Vorschlag aus der Streaming-Verfügbarkeit. */
|
||||
suggestion: string | null;
|
||||
owned: boolean;
|
||||
ownedSource: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<OwnedEditor entryId={entryId} owned={owned} source={ownedSource} />
|
||||
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
|
||||
<TagEditor entryId={entryId} tags={tags} />
|
||||
<Notes entryId={entryId} value={notes} />
|
||||
@@ -34,6 +40,94 @@ export function EntryNotes({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* „Hab ich" — der Titel liegt in der eigenen Sammlung.
|
||||
*
|
||||
* Bewusst getrennt von „wo schaue ich das": Streaming-Verfügbarkeit kommt und
|
||||
* geht, der gekaufte Film auf dem Heimserver bleibt. Genau die Titel, die
|
||||
* nirgends mehr laufen, will man ja wiederfinden.
|
||||
*/
|
||||
function OwnedEditor({
|
||||
entryId,
|
||||
owned,
|
||||
source,
|
||||
}: {
|
||||
entryId: string;
|
||||
owned: boolean;
|
||||
source: string | null;
|
||||
}) {
|
||||
const save = useAutosave(entryId);
|
||||
const [isOwned, setIsOwned] = useState(owned);
|
||||
const [text, setText] = useState(source ?? "");
|
||||
const initial = useRef(source ?? "");
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-[var(--fl-border)] p-3">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOwned}
|
||||
onChange={(event) => {
|
||||
setIsOwned(event.target.checked);
|
||||
save({ owned: event.target.checked });
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
|
||||
isOwned ? "border-transparent" : "border-[var(--fl-border-strong)]",
|
||||
)}
|
||||
style={isOwned ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{isOwned && (
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">Hab ich</span>
|
||||
<span className="block text-xs text-[var(--fl-faint)]">
|
||||
Liegt in der eigenen Sammlung — unabhängig davon, ob es gerade irgendwo läuft.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{isOwned && (
|
||||
<div className="pl-8">
|
||||
<label htmlFor="ownedSource" className="sr-only">
|
||||
Wo liegt der Titel?
|
||||
</label>
|
||||
<input
|
||||
id="ownedSource"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onBlur={() => {
|
||||
if (text === initial.current) return;
|
||||
initial.current = text;
|
||||
save({ ownedSource: text });
|
||||
}}
|
||||
list="fl-owned-quellen"
|
||||
placeholder="Jellyfin, Plex, NAS, Blu-ray …"
|
||||
className="fl-input py-1.5 text-sm"
|
||||
/>
|
||||
<datalist id="fl-owned-quellen">
|
||||
<option value="Jellyfin" />
|
||||
<option value="Plex" />
|
||||
<option value="Emby" />
|
||||
<option value="NAS" />
|
||||
<option value="Blu-ray" />
|
||||
<option value="DVD" />
|
||||
<option value="Gekauft (digital)" />
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAutosave(entryId: string) {
|
||||
const { report } = useToasts();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -19,8 +19,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
<div className="relative">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
|
||||
{/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
|
||||
den Postern oft der Titel, dort würden sie ihn verdecken. */}
|
||||
<span className="absolute left-2 top-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
style={{
|
||||
color: STATUS_COLOR[status],
|
||||
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
||||
@@ -30,6 +33,19 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
|
||||
{item.owned && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
title={
|
||||
item.ownedSource ? `In deiner Sammlung: ${item.ownedSource}` : "In deiner Sammlung"
|
||||
}
|
||||
>
|
||||
Hab ich
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{item.rating != null && (
|
||||
<span
|
||||
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
@@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||
{item.year ? ` · ${item.year}` : ""}
|
||||
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
|
||||
{item.owned && item.ownedSource
|
||||
? ` · ${item.ownedSource}`
|
||||
: item.whereToWatch
|
||||
? ` · ${item.whereToWatch}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{showProgress && (
|
||||
|
||||
@@ -56,7 +56,9 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
||||
const status = params.get("status");
|
||||
const type = params.get("typ");
|
||||
const sort = params.get("sortierung") ?? "aktualisiert";
|
||||
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length;
|
||||
const activeCount = ["genre", "tag", "dienst", "typ", "sammlung"].filter((key) =>
|
||||
params.get(key),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -78,6 +80,15 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
||||
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{facets.owned > 0 && (
|
||||
<Chip
|
||||
active={params.get("sammlung") === "1"}
|
||||
onClick={() => apply({ sammlung: params.get("sammlung") === "1" ? null : "1" })}
|
||||
>
|
||||
Hab ich <Count value={facets.owned} />
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { undoAction, watchNextAction } from "@/app/actions/library";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
|
||||
/**
|
||||
* „+1 Episode" für die Übersicht — derselbe Griff wie auf der Detailseite,
|
||||
* nur ohne Kontext drumherum.
|
||||
*/
|
||||
export function QuickWatch({ entryId }: { entryId: string }) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
async function undo(watchIds: string[]) {
|
||||
await undoAction(entryId, watchIds);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
const result = await watchNextAction(entryId);
|
||||
report(result, (ids) => undo(ids));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
+1 Episode
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
changePasswordAction,
|
||||
createApiKeyAction,
|
||||
deleteChannelAction,
|
||||
importDataAction,
|
||||
revokeApiKeyAction,
|
||||
runSyncAction,
|
||||
saveChannelAction,
|
||||
setInstanceNameAction,
|
||||
setPublicProfilesAction,
|
||||
setRegistrationAction,
|
||||
setUserActiveAction,
|
||||
setUserRoleAction,
|
||||
testChannelAction,
|
||||
toggleChannelAction,
|
||||
updateProfileAction,
|
||||
} from "@/app/actions/settings";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import { formatDate } from "@/lib/labels";
|
||||
|
||||
/** Wiederverwendete Bausteine der Einstellungsseiten. */
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & { id: string; label: string; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
{label}
|
||||
</label>
|
||||
<input id={id} name={id} className="fl-input" {...props} />
|
||||
{hint && <p className="text-xs text-[var(--fl-faint)]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"mt-0.5 h-5 w-9 shrink-0 rounded-full p-0.5 transition-colors",
|
||||
checked ? "" : "bg-[var(--fl-surface-2)] ring-1 ring-[var(--fl-border-strong)]",
|
||||
)}
|
||||
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"block h-4 w-4 rounded-full bg-[var(--fl-text)] transition-transform",
|
||||
checked && "translate-x-4",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
{hint && <span className="block text-xs text-[var(--fl-faint)]">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ProfileForm({
|
||||
user,
|
||||
publicProfilesEnabled,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
region: string;
|
||||
publicProfile: boolean;
|
||||
profileSlug: string | null;
|
||||
notifyNewEpisode: boolean;
|
||||
notifyRelease: boolean;
|
||||
notifyAvailability: boolean;
|
||||
};
|
||||
publicProfilesEnabled: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [state, setState] = useState(user);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={() =>
|
||||
startTransition(async () => {
|
||||
report(
|
||||
await updateProfileAction({
|
||||
name: state.name,
|
||||
region: state.region,
|
||||
publicProfile: state.publicProfile,
|
||||
profileSlug: state.profileSlug || null,
|
||||
notifyNewEpisode: state.notifyNewEpisode,
|
||||
notifyRelease: state.notifyRelease,
|
||||
notifyAvailability: state.notifyAvailability,
|
||||
}),
|
||||
);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="space-y-5"
|
||||
>
|
||||
<Field
|
||||
id="name"
|
||||
label="Anzeigename"
|
||||
value={state.name}
|
||||
onChange={(event) => setState({ ...state, name: event.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-[var(--fl-muted)]">E-Mail-Adresse</label>
|
||||
<p className="fl-input text-[var(--fl-faint)]">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="region"
|
||||
label="Region"
|
||||
value={state.region}
|
||||
maxLength={2}
|
||||
onChange={(event) => setState({ ...state, region: event.target.value.toUpperCase() })}
|
||||
hint="Zwei Buchstaben nach ISO 3166 — bestimmt, welche Streamingdienste angezeigt werden (DE, AT, CH …)."
|
||||
/>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||
Wovon soll Flimmer dir berichten?
|
||||
</legend>
|
||||
<Toggle
|
||||
checked={state.notifyNewEpisode}
|
||||
onChange={(value) => setState({ ...state, notifyNewEpisode: value })}
|
||||
label="Neue Episoden"
|
||||
hint="Wenn bei einer Serie in deiner Bibliothek eine Folge dazukommt."
|
||||
/>
|
||||
<Toggle
|
||||
checked={state.notifyRelease}
|
||||
onChange={(value) => setState({ ...state, notifyRelease: value })}
|
||||
label="Neue Releases"
|
||||
hint="Kinostart oder Heimkinostart eines Films auf deiner Watchlist."
|
||||
/>
|
||||
<Toggle
|
||||
checked={state.notifyAvailability}
|
||||
onChange={(value) => setState({ ...state, notifyAvailability: value })}
|
||||
label="Neu verfügbar"
|
||||
hint="Wenn ein Watchlist-Titel auf einem Dienst in deiner Region auftaucht."
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{publicProfilesEnabled && (
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||
Öffentliches Profil
|
||||
</legend>
|
||||
<Toggle
|
||||
checked={state.publicProfile}
|
||||
onChange={(value) => setState({ ...state, publicProfile: value })}
|
||||
label="Profil öffentlich zeigen"
|
||||
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
||||
/>
|
||||
{state.publicProfile && (
|
||||
<Field
|
||||
id="profileSlug"
|
||||
label="Adresse"
|
||||
value={state.profileSlug ?? ""}
|
||||
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
|
||||
placeholder="dein-name"
|
||||
hint="Erreichbar unter /u/<adresse>"
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
{pending ? "Einen Moment …" : "Speichern"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordForm() {
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(formData) =>
|
||||
startTransition(async () => {
|
||||
const result = await changePasswordAction({
|
||||
current: String(formData.get("current") ?? ""),
|
||||
next: String(formData.get("next") ?? ""),
|
||||
repeat: String(formData.get("repeat") ?? ""),
|
||||
});
|
||||
report(result);
|
||||
if (result.ok) window.location.href = "/anmelden";
|
||||
})
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field id="current" label="Bisheriges Passwort" type="password" autoComplete="current-password" required />
|
||||
<Field
|
||||
id="next"
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
hint="Mindestens 10 Zeichen."
|
||||
/>
|
||||
<Field id="repeat" label="Neues Passwort wiederholen" type="password" autoComplete="new-password" required />
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Nach dem Wechsel wirst du auf allen Geräten abgemeldet.
|
||||
</p>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||
Passwort ändern
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API-Schlüssel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ApiKeys({
|
||||
keys,
|
||||
}: {
|
||||
keys: { id: string; name: string; prefix: string; lastUsedAt: Date | null; createdAt: Date }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("keyName") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
const result = await createApiKeyAction(name);
|
||||
report(result);
|
||||
if (result.secret) setSecret(result.secret);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<div className="min-w-48 flex-1 space-y-1.5">
|
||||
<label htmlFor="keyName" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Neuer Schlüssel
|
||||
</label>
|
||||
<input id="keyName" name="keyName" placeholder="z. B. Handy-Skript" className="fl-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{secret && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3"
|
||||
style={{
|
||||
borderColor: "color-mix(in oklab, var(--fl-warning) 45%, transparent)",
|
||||
backgroundColor: "color-mix(in oklab, var(--fl-warning) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--fl-warning)]">
|
||||
Einmalig sichtbar — jetzt kopieren:
|
||||
</p>
|
||||
<code className="mt-2 block break-all rounded-lg bg-[var(--fl-surface-2)] p-2 font-mono text-xs">
|
||||
{secret}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(secret);
|
||||
report({ ok: true, message: "In die Zwischenablage kopiert" });
|
||||
}}
|
||||
className="fl-btn fl-btn-ghost mt-2 px-3 py-1.5 text-sm"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch kein Schlüssel angelegt.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{keys.map((key) => (
|
||||
<li key={key.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
<p className="font-mono text-xs text-[var(--fl-faint)]">
|
||||
{key.prefix}… · angelegt {formatDate(key.createdAt)}
|
||||
{key.lastUsedAt ? ` · zuletzt benutzt ${formatDate(key.lastUsedAt)}` : " · nie benutzt"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await revokeApiKeyAction(key.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Zurückziehen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benachrichtigungskanäle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHANNEL_FIELDS: Record<string, { key: string; label: string; placeholder: string }[]> = {
|
||||
EMAIL: [{ key: "email", label: "Abweichende Adresse", placeholder: "leer = Konto-Adresse" }],
|
||||
NTFY: [
|
||||
{ key: "server", label: "Server", placeholder: "https://ntfy.sh" },
|
||||
{ key: "topic", label: "Topic", placeholder: "flimmer-abc123" },
|
||||
{ key: "token", label: "Zugriffstoken", placeholder: "optional" },
|
||||
],
|
||||
GOTIFY: [
|
||||
{ key: "url", label: "Server", placeholder: "https://gotify.example.com" },
|
||||
{ key: "token", label: "Anwendungs-Token", placeholder: "AxB..." },
|
||||
],
|
||||
WEBHOOK: [
|
||||
{ key: "webhookUrl", label: "Adresse", placeholder: "https://discord.com/api/webhooks/…" },
|
||||
],
|
||||
};
|
||||
|
||||
const CHANNEL_LABEL: Record<string, string> = {
|
||||
EMAIL: "E-Mail",
|
||||
NTFY: "ntfy",
|
||||
GOTIFY: "Gotify",
|
||||
WEBHOOK: "Webhook (Discord, Slack, …)",
|
||||
};
|
||||
|
||||
export function ChannelList({
|
||||
channels,
|
||||
smtpReady,
|
||||
}: {
|
||||
channels: { id: string; kind: string; name: string; config: Record<string, string>; enabled: boolean }[];
|
||||
smtpReady: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [kind, setKind] = useState<string>("NTFY");
|
||||
const [config, setConfig] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{channels.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{channels.map((channel) => (
|
||||
<li key={channel.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{channel.name}
|
||||
<span className="ml-2 text-xs font-normal text-[var(--fl-faint)]">
|
||||
{CHANNEL_LABEL[channel.kind] ?? channel.kind}
|
||||
</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{Object.entries(channel.config)
|
||||
.filter(([key]) => key !== "token")
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(" · ") || "ohne weitere Angaben"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
checked={channel.enabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await toggleChannelAction(channel.id, value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await testChannelAction(channel.id));
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
Testen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await deleteChannelAction(channel.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("channelName") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
report(await saveChannelAction({ kind, name, config }));
|
||||
setConfig({});
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="space-y-4 border-t border-[var(--fl-border)] pt-5"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Kanal hinzufügen</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="kind" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id="kind"
|
||||
value={kind}
|
||||
onChange={(event) => {
|
||||
setKind(event.target.value);
|
||||
setConfig({});
|
||||
}}
|
||||
className="fl-input"
|
||||
>
|
||||
{Object.entries(CHANNEL_LABEL).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{kind === "EMAIL" && !smtpReady && (
|
||||
<p className="text-xs text-[var(--fl-warning)]">
|
||||
Für diese Instanz ist kein SMTP-Zugang eingerichtet — E-Mails können nicht verschickt
|
||||
werden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field id="channelName" label="Name" placeholder="z. B. Handy" required />
|
||||
|
||||
{(CHANNEL_FIELDS[kind] ?? []).map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor={`cfg-${field.key}`}
|
||||
className="block text-sm font-medium text-[var(--fl-muted)]"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`cfg-${field.key}`}
|
||||
value={config[field.key] ?? ""}
|
||||
onChange={(event) => setConfig({ ...config, [field.key]: event.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
className="fl-input"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
Kanal anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ImportForm() {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [format, setFormat] = useState<"csv" | "trakt">("csv");
|
||||
const [summary, setSummary] = useState<string[] | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{(["csv", "trakt"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setFormat(option)}
|
||||
aria-pressed={format === option}
|
||||
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={
|
||||
format === option
|
||||
? {
|
||||
backgroundImage: "var(--fl-gradient)",
|
||||
color: "var(--fl-on-accent)",
|
||||
borderColor: "transparent",
|
||||
}
|
||||
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
||||
}
|
||||
>
|
||||
{option === "csv" ? "CSV" : "Trakt.tv (JSON)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
{format === "csv"
|
||||
? "Die Kopfzeile braucht mindestens eine Spalte mit dem Titel. Erkannt werden außerdem Typ, Status, Bewertung, TMDB, Jahr und Notiz."
|
||||
: "Ein Export aus Trakt.tv — Watchlist, History, Ratings oder Collection. Flimmer sucht selbst die Einträge heraus."}
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept={format === "csv" ? ".csv,text/csv" : ".json,application/json"}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setSummary(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const content = await file.text();
|
||||
const result = await importDataAction(format, content);
|
||||
report({ ok: result.ok, message: result.message });
|
||||
|
||||
if (result.summary) {
|
||||
setSummary([
|
||||
`${result.summary.processed} Zeilen gelesen`,
|
||||
`${result.summary.added} neu aufgenommen`,
|
||||
`${result.summary.skipped} übersprungen (schon vorhanden oder unvollständig)`,
|
||||
...result.summary.errors.slice(0, 5),
|
||||
]);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={pending}
|
||||
className="fl-input file:mr-3 file:rounded-lg file:border-0 file:bg-[var(--fl-surface-2)] file:px-3 file:py-1.5 file:text-sm file:text-[var(--fl-text)]"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Der Import überschreibt nichts: Titel, die schon in deiner Bibliothek liegen, bleiben
|
||||
unangetastet.
|
||||
</p>
|
||||
|
||||
{summary && (
|
||||
<ul className="rounded-xl border border-[var(--fl-border)] p-3 text-sm text-[var(--fl-muted)]">
|
||||
{summary.map((line, index) => (
|
||||
<li key={index}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verwaltung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AdminSettings({
|
||||
registrationEnabled,
|
||||
publicProfilesEnabled,
|
||||
instanceName,
|
||||
}: {
|
||||
registrationEnabled: boolean;
|
||||
publicProfilesEnabled: boolean;
|
||||
instanceName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
action={(formData) =>
|
||||
startTransition(async () => {
|
||||
report(await setInstanceNameAction(String(formData.get("instanceName") ?? "")));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<div className="min-w-48 flex-1">
|
||||
<Field id="instanceName" label="Name der Instanz" defaultValue={instanceName} />
|
||||
</div>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Toggle
|
||||
checked={registrationEnabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await setRegistrationAction(value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label="Registrierung offen"
|
||||
hint="Solange es kein einziges Konto gibt, bleibt die Registrierung unabhängig davon offen."
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
checked={publicProfilesEnabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await setPublicProfilesAction(value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label="Öffentliche Profile erlauben"
|
||||
hint="Schaltet die Profilseiten instanzweit ab, unabhängig von der Einstellung der Nutzer."
|
||||
/>
|
||||
|
||||
<div className="border-t border-[var(--fl-border)] pt-5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await runSyncAction());
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-primary"
|
||||
>
|
||||
{pending ? "Läuft …" : "Abgleich jetzt starten"}
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Frischt laufende Serien bei TMDB auf, verschickt fällige Benachrichtigungen und räumt den
|
||||
Papierkorb. Kann eine Weile dauern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
}: {
|
||||
user: { id: string; name: string; email: string; role: string; isActive: boolean; entries: number };
|
||||
isSelf: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{user.name}
|
||||
{user.role === "ADMIN" && (
|
||||
<span
|
||||
className="ml-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
>
|
||||
Verwaltung
|
||||
</span>
|
||||
)}
|
||||
{!user.isActive && (
|
||||
<span className="ml-2 text-[0.65rem] font-semibold text-[var(--fl-danger)]">gesperrt</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{user.email} · {user.entries} {user.entries === 1 ? "Titel" : "Titel"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isSelf && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await setUserRoleAction(user.id, user.role === "ADMIN" ? "USER" : "ADMIN"));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
{user.role === "ADMIN" ? "Rechte entziehen" : "Zur Verwaltung"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await setUserActiveAction(user.id, !user.isActive));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||
>
|
||||
{user.isActive ? "Sperren" : "Freigeben"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Seitennavigation der Einstellungen. */
|
||||
const ITEMS = [
|
||||
{ href: "/einstellungen", label: "Profil" },
|
||||
{ href: "/einstellungen/benachrichtigungen", label: "Benachrichtigungen" },
|
||||
{ href: "/einstellungen/api", label: "API-Schlüssel" },
|
||||
{ href: "/einstellungen/daten", label: "Import und Export" },
|
||||
];
|
||||
|
||||
export function SettingsNav({ isAdmin }: { isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Einstellungen"
|
||||
className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible"
|
||||
>
|
||||
{ITEMS.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
"shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-[var(--fl-surface-2)] text-[var(--fl-text)]"
|
||||
: "text-[var(--fl-muted)] hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="shrink-0 rounded-lg px-3 py-2 text-sm font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
Verwaltung
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* Diagramme der Statistikseite.
|
||||
*
|
||||
* Handgeschriebenes SVG statt einer Chart-Bibliothek: die Diagramme sind
|
||||
* schlicht, sollen sich an die Design-Tokens halten und in beiden Farbschemata
|
||||
* funktionieren — dafür lohnt keine Abhängigkeit mit eigenem Versionsrhythmus.
|
||||
*
|
||||
* Farbwahl: Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
* Signaturfarben. Sie erfüllen in **beiden** Modi die Prüfungen auf
|
||||
* Helligkeitsband, Farbsättigung, Farbfehlsichtigkeit (schlechtestes Paar
|
||||
* ΔE 12,1 unter Deuteranopie) und Kontrast zum Untergrund. Die vollen
|
||||
* Signaturtöne wären auf dunklem Grund zu hell und fielen durch.
|
||||
*/
|
||||
|
||||
export const SERIES_COLORS = {
|
||||
episodes: "#c47a18",
|
||||
movies: "#d92d78",
|
||||
} as const;
|
||||
|
||||
/** Ein Ton für Balken, die nur eine Größe zeigen (keine Identität). */
|
||||
const SINGLE_HUE = "#c47a18";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Säulen je Monat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MonthlyPointData = {
|
||||
label: string;
|
||||
episodes: number;
|
||||
movies: number;
|
||||
minutes: number;
|
||||
};
|
||||
|
||||
/** Rundet die Achsenspitze auf einen glatten Wert auf. */
|
||||
function niceMax(value: number): number {
|
||||
if (value <= 0) return 4;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||
for (const step of [1, 2, 2.5, 5, 10]) {
|
||||
const candidate = step * magnitude;
|
||||
if (candidate >= value) return candidate;
|
||||
}
|
||||
return 10 * magnitude;
|
||||
}
|
||||
|
||||
export function MonthlyChart({ points }: { points: MonthlyPointData[] }) {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
const max = niceMax(Math.max(1, ...points.map((point) => point.episodes + point.movies)));
|
||||
const ticks = [0, max / 2, max];
|
||||
|
||||
// Breites Seitenverhältnis: das SVG füllt die Karte und bleibt trotzdem flach.
|
||||
const width = 1100;
|
||||
const height = 260;
|
||||
const padding = { top: 12, right: 8, bottom: 28, left: 38 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
|
||||
const band = plotWidth / Math.max(1, points.length);
|
||||
const barWidth = Math.min(24, band - 10);
|
||||
const scale = (value: number) => (value / max) * plotHeight;
|
||||
|
||||
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
|
||||
if (total === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-[var(--fl-faint)]">
|
||||
In den letzten zwölf Monaten wurde noch nichts abgehakt.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<figure className="m-0">
|
||||
<Legend
|
||||
items={[
|
||||
{ label: "Episoden", color: SERIES_COLORS.episodes },
|
||||
{ label: "Filme", color: SERIES_COLORS.movies },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="relative mt-2 overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-auto w-full min-w-[32rem]"
|
||||
role="img"
|
||||
// Bewusst aria-label statt eines <title>-Kindes: React behandelt
|
||||
// <title> als Dokument-Metadatum und hebt es aus dem SVG heraus —
|
||||
// das brach die Hydration.
|
||||
aria-label={`Gesehene Episoden und Filme je Monat in den letzten ${points.length} Monaten`}
|
||||
onMouseLeave={() => setActive(null)}
|
||||
>
|
||||
{ticks.map((tick) => {
|
||||
const y = padding.top + plotHeight - scale(tick);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="var(--fl-border)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
className="fill-[var(--fl-faint)] text-[11px] tabular-nums"
|
||||
>
|
||||
{Math.round(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{points.map((point, index) => {
|
||||
const x = padding.left + index * band + (band - barWidth) / 2;
|
||||
const episodeHeight = scale(point.episodes);
|
||||
const movieHeight = scale(point.movies);
|
||||
const baseline = padding.top + plotHeight;
|
||||
const isActive = active === index;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={point.label + index}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
onFocus={() => setActive(index)}
|
||||
tabIndex={0}
|
||||
className="outline-none"
|
||||
>
|
||||
{/* Großzügige Trefferfläche — der Balken selbst ist schmal. */}
|
||||
<rect
|
||||
x={padding.left + index * band}
|
||||
y={padding.top}
|
||||
width={band}
|
||||
height={plotHeight}
|
||||
fill={isActive ? "var(--fl-surface-2)" : "transparent"}
|
||||
opacity={isActive ? 0.6 : 0}
|
||||
/>
|
||||
|
||||
{point.episodes > 0 && (
|
||||
<rect
|
||||
x={x}
|
||||
y={baseline - episodeHeight}
|
||||
width={barWidth}
|
||||
height={episodeHeight}
|
||||
rx={point.movies > 0 ? 0 : 4}
|
||||
fill={SERIES_COLORS.episodes}
|
||||
/>
|
||||
)}
|
||||
{point.movies > 0 && (
|
||||
<rect
|
||||
x={x}
|
||||
// 2 px Fuge in Untergrundfarbe zwischen den Segmenten
|
||||
y={baseline - episodeHeight - movieHeight - (point.episodes > 0 ? 2 : 0)}
|
||||
width={barWidth}
|
||||
height={movieHeight}
|
||||
rx={4}
|
||||
fill={SERIES_COLORS.movies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<text
|
||||
x={padding.left + index * band + band / 2}
|
||||
y={height - 8}
|
||||
textAnchor="middle"
|
||||
className={clsx(
|
||||
"text-[11px]",
|
||||
isActive ? "fill-[var(--fl-text)]" : "fill-[var(--fl-faint)]",
|
||||
)}
|
||||
>
|
||||
{point.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{active !== null && points[active] && (
|
||||
<Tooltip
|
||||
title={points[active].label}
|
||||
rows={[
|
||||
{ label: "Episoden", value: String(points[active].episodes), color: SERIES_COLORS.episodes },
|
||||
{ label: "Filme", value: String(points[active].movies), color: SERIES_COLORS.movies },
|
||||
{ label: "Schauzeit", value: formatMinutes(points[active].minutes) },
|
||||
]}
|
||||
position={active / Math.max(1, points.length - 1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<figcaption className="sr-only">
|
||||
<table>
|
||||
<caption>Gesehene Episoden und Filme je Monat</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Monat</th>
|
||||
<th scope="col">Episoden</th>
|
||||
<th scope="col">Filme</th>
|
||||
<th scope="col">Schauzeit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((point, index) => (
|
||||
<tr key={point.label + index}>
|
||||
<th scope="row">{point.label}</th>
|
||||
<td>{point.episodes}</td>
|
||||
<td>{point.movies}</td>
|
||||
<td>{formatMinutes(point.minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
title,
|
||||
rows,
|
||||
position,
|
||||
}: {
|
||||
title: string;
|
||||
rows: { label: string; value: string; color?: string }[];
|
||||
position: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="pointer-events-none absolute top-0 z-10 min-w-[9rem] -translate-x-1/2 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-solid)] px-3 py-2 text-xs shadow-[var(--fl-shadow)]"
|
||||
style={{ left: `${12 + position * 76}%` }}
|
||||
>
|
||||
<p className="mb-1 font-semibold">{title}</p>
|
||||
{rows.map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[var(--fl-muted)]">
|
||||
{row.color && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1">{row.label}</span>
|
||||
<span className="font-medium tabular-nums text-[var(--fl-text)]">{row.value}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ items }: { items: { label: string; color: string }[] }) {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-4 text-xs text-[var(--fl-muted)]">
|
||||
{items.map((item) => (
|
||||
<li key={item.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Liegende Balken für Verteilungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DistributionRow = {
|
||||
label: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
/** Markenfarbe eines Dienstes; wird nur genutzt, wenn sie kontrastreich genug ist. */
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
/** Relative Leuchtdichte nach WCAG — für die Kontrastprüfung der Markenfarben. */
|
||||
function luminance(hex: string): number | null {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match?.[1]) return null;
|
||||
const value = Number.parseInt(match[1], 16);
|
||||
const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((raw) => {
|
||||
const c = raw / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
|
||||
}
|
||||
|
||||
function contrast(hex: string, surfaceLuminance: number): number | null {
|
||||
const value = luminance(hex);
|
||||
if (value === null) return null;
|
||||
const lighter = Math.max(value, surfaceLuminance);
|
||||
const darker = Math.min(value, surfaceLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** Leuchtdichte der beiden Untergründe (--fl-bg in dunkel und hell). */
|
||||
const SURFACE_DARK = 0.0055;
|
||||
const SURFACE_LIGHT = 0.9;
|
||||
|
||||
/**
|
||||
* Eine Markenfarbe wird nur benutzt, wenn sie auf **beiden** Untergründen
|
||||
* mindestens 3:1 erreicht. So bleibt die Wahl unabhängig vom eingestellten
|
||||
* Farbschema — sonst müsste die Komponente den Theme-Wechsel mitverfolgen und
|
||||
* liefe Gefahr, beim ersten Rendern etwas anderes zu zeigen als der Server.
|
||||
*/
|
||||
function safeBrandColor(hex: string | null | undefined): string | null {
|
||||
if (!hex) return null;
|
||||
const dark = contrast(hex, SURFACE_DARK);
|
||||
const light = contrast(hex, SURFACE_LIGHT);
|
||||
if (dark === null || light === null) return null;
|
||||
return dark >= 3 && light >= 3 ? hex : null;
|
||||
}
|
||||
|
||||
export function DistributionBars({
|
||||
rows,
|
||||
emptyText,
|
||||
useBrandColors = false,
|
||||
}: {
|
||||
rows: DistributionRow[];
|
||||
emptyText: string;
|
||||
/**
|
||||
* Streamingdienste tragen ihre Markenfarbe — das ist ihre Identität und
|
||||
* zieht sich durchs ganze UI. Zu dunkle oder zu blasse Marken fallen
|
||||
* automatisch auf den geprüften Standardton zurück, sonst verschwände der
|
||||
* Balken je nach Farbschema.
|
||||
*/
|
||||
useBrandColors?: boolean;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-6 text-center text-sm text-[var(--fl-faint)]">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((row) => row.minutes), 1);
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const brand = useBrandColors ? safeBrandColor(row.color) : null;
|
||||
const color = brand ?? SINGLE_HUE;
|
||||
|
||||
return (
|
||||
<li key={row.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate text-[var(--fl-text)]">{row.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-[var(--fl-muted)]">
|
||||
{formatMinutes(row.minutes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-[var(--fl-surface-2)]">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${Math.max(2, (row.minutes / max) * 100)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
import { reorderAction, removeFromUniverseAction } from "@/app/actions/universes";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
|
||||
import type { UniverseEntry } from "@/lib/universes";
|
||||
|
||||
/**
|
||||
* Nummerierte Liste eines Universums in der gewählten Reihenfolge.
|
||||
*
|
||||
* Umsortieren geht per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop
|
||||
* wäre mit Tastatur nicht bedienbar und auf dem Handy fummelig; die Knöpfe
|
||||
* sind der verlässliche Weg, das Ziehen der bequeme.
|
||||
*
|
||||
* Die Erscheinungsreihenfolge (`sortable = false`) ergibt sich aus den Daten
|
||||
* und lässt sich deshalb gar nicht erst anfassen.
|
||||
*/
|
||||
export function OrderedList({
|
||||
universeId,
|
||||
orderId,
|
||||
entries,
|
||||
sortable,
|
||||
canWrite,
|
||||
nextUpItemId,
|
||||
}: {
|
||||
universeId: string;
|
||||
orderId: string;
|
||||
entries: UniverseEntry[];
|
||||
sortable: boolean;
|
||||
canWrite: boolean;
|
||||
nextUpItemId: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [items, setItems] = useState(entries);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => setItems(entries), [entries]);
|
||||
|
||||
const interactive = sortable && canWrite;
|
||||
|
||||
function persist(next: UniverseEntry[]) {
|
||||
setItems(next);
|
||||
startTransition(async () => {
|
||||
const result = await reorderAction(
|
||||
orderId,
|
||||
next.map((entry) => entry.itemId),
|
||||
);
|
||||
if (!result.ok) {
|
||||
report(result);
|
||||
setItems(entries);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= items.length) return;
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(index, 1);
|
||||
if (moved) next.splice(target, 0, moved);
|
||||
persist(next);
|
||||
}
|
||||
|
||||
function dropOn(targetItemId: string) {
|
||||
if (!dragging || dragging === targetItemId) return;
|
||||
const from = items.findIndex((entry) => entry.itemId === dragging);
|
||||
const to = items.findIndex((entry) => entry.itemId === targetItemId);
|
||||
if (from < 0 || to < 0) return;
|
||||
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved) next.splice(to, 0, moved);
|
||||
setDragging(null);
|
||||
persist(next);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">
|
||||
Dieses Universum ist noch leer. Nimm Filme und Serien aus deiner Bibliothek auf.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{items.map((entry, index) => (
|
||||
<li
|
||||
key={entry.itemId}
|
||||
draggable={interactive}
|
||||
onDragStart={() => setDragging(entry.itemId)}
|
||||
onDragEnd={() => setDragging(null)}
|
||||
onDragOver={(event) => {
|
||||
if (interactive) event.preventDefault();
|
||||
}}
|
||||
onDrop={() => dropOn(entry.itemId)}
|
||||
className={clsx(
|
||||
"fl-card flex items-center gap-3 p-2.5 transition-opacity sm:gap-4 sm:p-3",
|
||||
dragging === entry.itemId && "opacity-40",
|
||||
entry.itemId === nextUpItemId && "ring-1",
|
||||
)}
|
||||
style={
|
||||
entry.itemId === nextUpItemId
|
||||
? {
|
||||
boxShadow: "var(--fl-glow)",
|
||||
borderColor: "color-mix(in oklab, var(--fl-magenta) 45%, transparent)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"w-7 shrink-0 text-center font-mono text-sm",
|
||||
entry.completed ? "text-[var(--fl-success)]" : "text-[var(--fl-faint)]",
|
||||
)}
|
||||
>
|
||||
{entry.completed ? "✓" : index + 1}
|
||||
</span>
|
||||
|
||||
<Link href={`/titel/${entry.titleId}`} className="w-12 shrink-0 sm:w-14">
|
||||
<Poster path={entry.posterPath} alt={entry.title} size="w154" />
|
||||
</Link>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/titel/${entry.titleId}`}
|
||||
className="font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
{entry.itemId === nextUpItemId && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
>
|
||||
Als Nächstes dran
|
||||
</span>
|
||||
)}
|
||||
{entry.status && (
|
||||
<span
|
||||
className="text-[0.65rem] font-medium"
|
||||
style={{ color: STATUS_COLOR[entry.status as StatusKey] }}
|
||||
>
|
||||
{STATUS_LABEL[entry.status as StatusKey]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||
{entry.type === "SERIES" ? "Serie" : "Film"}
|
||||
{entry.year ? ` · ${entry.year}` : ""}
|
||||
{entry.type === "SERIES"
|
||||
? ` · ${entry.watchedEpisodes}/${entry.totalEpisodes} Episoden`
|
||||
: ""}
|
||||
{entry.remainingMinutes > 0 ? ` · noch ${formatMinutes(entry.remainingMinutes)}` : ""}
|
||||
</p>
|
||||
|
||||
{entry.type === "SERIES" && entry.totalEpisodes > 0 && (
|
||||
<div className="mt-1.5 max-w-xs">
|
||||
<ProgressBar
|
||||
percent={(entry.watchedEpisodes / entry.totalEpisodes) * 100}
|
||||
label={`${entry.watchedEpisodes} von ${entry.totalEpisodes} Episoden gesehen`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{interactive && (
|
||||
<>
|
||||
<IconButton
|
||||
label={`${entry.title} nach oben`}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<path d="m6 15 6-6 6 6" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`${entry.title} nach unten`}
|
||||
disabled={index === items.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<RemoveButton universeId={universeId} itemId={entry.itemId} title={entry.title} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-lg p-1.5 text-[var(--fl-faint)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)] disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveButton({
|
||||
universeId,
|
||||
itemId,
|
||||
title,
|
||||
}: {
|
||||
universeId: string;
|
||||
itemId: string;
|
||||
title: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
label={`${title} aus dem Universum entfernen`}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await removeFromUniverseAction(universeId, itemId));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
<path d="m6 6 12 12M18 6 6 18" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
addCollectionAction,
|
||||
addTitleToUniverseAction,
|
||||
createOrderAction,
|
||||
createUniverseAction,
|
||||
deleteOrderAction,
|
||||
shareUniverseAction,
|
||||
trashUniverseAction,
|
||||
unshareUniverseAction,
|
||||
} from "@/app/actions/universes";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import type { UniverseView } from "@/lib/universes";
|
||||
|
||||
/** Formular für ein neues Universum. */
|
||||
export function NewUniverseForm() {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
const result = await createUniverseAction(
|
||||
name,
|
||||
String(formData.get("description") ?? ""),
|
||||
);
|
||||
report(result);
|
||||
if (result.ok && result.id) router.push(`/universen/${result.id}`);
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
placeholder="MCU, Star Wars, Wizarding World …"
|
||||
className="fl-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Beschreibung <span className="text-xs text-[var(--fl-faint)]">(optional)</span>
|
||||
</label>
|
||||
<textarea id="description" name="description" rows={3} className="fl-input resize-y" />
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
{pending ? "Einen Moment …" : "Universum anlegen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Umschalter zwischen den Reihenfolgen eines Universums. */
|
||||
export function OrderTabs({
|
||||
universe,
|
||||
canWrite,
|
||||
}: {
|
||||
universe: UniverseView;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const { report } = useToasts();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
function select(orderId: string) {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
next.set("reihenfolge", orderId);
|
||||
router.replace(`/universen/${universe.id}?${next}`, { scroll: false });
|
||||
}
|
||||
|
||||
const active = universe.orders.find((order) => order.id === universe.activeOrderId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Reihenfolge">
|
||||
{universe.orders.map((order) => {
|
||||
const isActive = order.id === universe.activeOrderId;
|
||||
return (
|
||||
<button
|
||||
key={order.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => select(order.id)}
|
||||
className={clsx(
|
||||
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "border-transparent text-[var(--fl-on-accent)]"
|
||||
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
|
||||
)}
|
||||
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{order.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<>
|
||||
{adding ? (
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("orderName") ?? "").trim();
|
||||
if (!name) return;
|
||||
setAdding(false);
|
||||
startTransition(async () => {
|
||||
const result = await createOrderAction(universe.id, name);
|
||||
report(result);
|
||||
if (result.ok && result.id) select(result.id);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
name="orderName"
|
||||
autoFocus
|
||||
placeholder="z. B. Chronologisch"
|
||||
className="fl-input w-48 py-1.5 text-sm"
|
||||
/>
|
||||
<button type="submit" className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
|
||||
Anlegen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(false)}
|
||||
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="rounded-full border border-dashed border-[var(--fl-border-strong)] px-3.5 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:text-[var(--fl-text)]"
|
||||
>
|
||||
+ Reihenfolge
|
||||
</button>
|
||||
)}
|
||||
|
||||
{active && active.kind === "CUSTOM" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await deleteOrderAction(active.id));
|
||||
router.replace(`/universen/${universe.id}`);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
„{active.name}" löschen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Titel aus der eigenen Bibliothek aufnehmen — mit Tippfilter. */
|
||||
export function AddTitles({
|
||||
universeId,
|
||||
candidates,
|
||||
collections,
|
||||
}: {
|
||||
universeId: string;
|
||||
candidates: { id: string; title: string; type: string; year: number | null }[];
|
||||
collections: { id: number; name: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [query, setQuery] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const filtered = query.trim()
|
||||
? candidates
|
||||
.filter((item) => item.title.toLocaleLowerCase("de-DE").includes(query.toLocaleLowerCase("de-DE")))
|
||||
.slice(0, 8)
|
||||
: candidates.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{collections.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
|
||||
Aus einer TMDB-Reihe übernehmen
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{collections.map((collection) => (
|
||||
<button
|
||||
key={collection.id}
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await addCollectionAction(universeId, collection.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="rounded-full border border-[var(--fl-border)] px-3 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
|
||||
Aus der Bibliothek aufnehmen
|
||||
</h3>
|
||||
{candidates.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">
|
||||
Alles aus deiner Bibliothek ist schon in diesem Universum.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Titel filtern …"
|
||||
aria-label="Titel filtern"
|
||||
className="fl-input mb-2"
|
||||
/>
|
||||
<ul className="space-y-1">
|
||||
{filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await addTitleToUniverseAction(universeId, item.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--fl-surface-2)]"
|
||||
>
|
||||
<span className="min-w-0 truncate">{item.title}</span>
|
||||
<span className="ml-3 shrink-0 text-xs text-[var(--fl-faint)]">
|
||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||
{item.year ? ` · ${item.year}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<li className="px-3 py-2 text-sm text-[var(--fl-faint)]">Kein Treffer.</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Freigabe an andere Konten der Instanz. */
|
||||
export function SharePanel({ universe }: { universe: UniverseView }) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [permission, setPermission] = useState<"READ" | "WRITE">("READ");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
action={(formData) => {
|
||||
const email = String(formData.get("email") ?? "").trim();
|
||||
if (!email) return;
|
||||
startTransition(async () => {
|
||||
report(await shareUniverseAction(universe.id, email, permission));
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="space-y-2"
|
||||
>
|
||||
<label htmlFor="share-email" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Mit jemandem auf dieser Instanz teilen
|
||||
</label>
|
||||
<input
|
||||
id="share-email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="person@beispiel.de"
|
||||
className="fl-input"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value as "READ" | "WRITE")}
|
||||
aria-label="Recht"
|
||||
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm"
|
||||
>
|
||||
<option value="READ">Nur ansehen</option>
|
||||
<option value="WRITE">Gemeinsam pflegen</option>
|
||||
</select>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
|
||||
Teilen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{universe.members.length > 0 && (
|
||||
<ul className="space-y-1 border-t border-[var(--fl-border)] pt-3">
|
||||
{universe.members.map((member) => (
|
||||
<li key={member.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{member.name}
|
||||
<span className="ml-1.5 text-xs text-[var(--fl-faint)]">
|
||||
{member.permission === "WRITE" ? "pflegt mit" : "liest mit"}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await unshareUniverseAction(universe.id, member.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-xs text-[var(--fl-faint)] underline-offset-2 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Universum löschen — mit Rückfrage. */
|
||||
export function DeleteUniverse({ universeId, name }: { universeId: string; name: string }) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
if (!confirming) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Universum löschen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-[var(--fl-muted)]">„{name}" wirklich löschen?</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await trashUniverseAction(universeId));
|
||||
router.push("/universen");
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
|
||||
>
|
||||
Ja, löschen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Wird von Next beim Start des Servers einmal ausgeführt.
|
||||
*
|
||||
* Hier hängt der eingebaute Zeitplan für den täglichen Abgleich. Er läuft nur
|
||||
* in der Node-Laufzeit (nicht im Edge-Runtime) und nur, wenn ENABLE_SCHEDULER
|
||||
* gesetzt ist — bei mehreren App-Instanzen soll ihn nur eine übernehmen.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const { startScheduler } = await import("./lib/sync");
|
||||
startScheduler();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import "server-only";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getApiUser, type SessionUser } from "./auth";
|
||||
import { LibraryError } from "./library";
|
||||
import { UniverseError } from "./universes";
|
||||
|
||||
/**
|
||||
* Gemeinsames Gerüst der REST-API.
|
||||
*
|
||||
* Zwei Dinge macht jeder Endpunkt gleich: die Anmeldung prüfen (Bearer-Token
|
||||
* oder Sitzungscookie — dieselben Endpunkte bedienen Skripte und das eigene
|
||||
* Frontend) und Fehler in eine einheitliche Form bringen.
|
||||
*/
|
||||
|
||||
export type ApiHandler<T = unknown> = (
|
||||
request: Request,
|
||||
user: SessionUser,
|
||||
context: { params: Promise<Record<string, string>> },
|
||||
) => Promise<T>;
|
||||
|
||||
export function apiError(message: string, status = 400, details?: unknown) {
|
||||
return NextResponse.json({ error: message, ...(details ? { details } : {}) }, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Umhüllt einen Endpunkt: Anmeldung, Fehlerbehandlung, JSON-Antwort.
|
||||
* Gibt der Handler bereits eine Response zurück, wird sie durchgereicht.
|
||||
*/
|
||||
export function withApi<T>(handler: ApiHandler<T>) {
|
||||
return async (request: Request, context: { params: Promise<Record<string, string>> }) => {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nicht angemeldet. Schicke einen API-Schlüssel im Kopf: Authorization: Bearer <schlüssel>",
|
||||
},
|
||||
{ status: 401, headers: { "www-authenticate": "Bearer" } },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(request, user, context);
|
||||
if (result instanceof NextResponse) return result;
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return apiError("Die Anfrage ist unvollständig oder falsch aufgebaut.", 422, error.issues);
|
||||
}
|
||||
if (error instanceof LibraryError || error instanceof UniverseError) {
|
||||
return apiError(error.message, 400);
|
||||
}
|
||||
console.error("API-Fehler:", error);
|
||||
return apiError("Interner Fehler.", 500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest und prüft den JSON-Rumpf einer Anfrage. */
|
||||
export async function readJson<S extends z.ZodTypeAny>(
|
||||
request: Request,
|
||||
schema: S,
|
||||
): Promise<z.infer<S>> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
throw new z.ZodError([
|
||||
{
|
||||
code: "custom",
|
||||
path: [],
|
||||
message: "Der Rumpf der Anfrage ist kein gültiges JSON.",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return schema.parse(raw);
|
||||
}
|
||||
|
||||
/** Seitenweise Abfrage: `?limit=50&offset=0` */
|
||||
export function readPaging(request: Request, defaultLimit = 50, maxLimit = 200) {
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
maxLimit,
|
||||
Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "", 10) || defaultLimit),
|
||||
);
|
||||
const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "", 10) || 0);
|
||||
return { limit, offset };
|
||||
}
|
||||