From e4dcfb3845476c3fc2e60cd8e9fa1c04fbdbc0da Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 13 Jul 2026 05:13:08 +0200 Subject: [PATCH 1/4] feat(i18n): retire the German interface language Only English and Hungarian remain. Drops the de locale files, the LANGUAGES entry, the Google-locale mapping in auth, and the German welcome-message template. A legacy "de" preference falls back to English gracefully. Content- language detection and subtitle languages are untouched (media, not UI). --- README.md | 4 +- backend/app/auth.py | 2 +- backend/app/routes/messages.py | 8 - frontend/src/components/LanguageSwitcher.tsx | 2 +- frontend/src/i18n/index.ts | 1 - frontend/src/i18n/locales/de/about.json | 12 - frontend/src/i18n/locales/de/audit.json | 18 -- .../src/i18n/locales/de/auditActions.json | 21 -- frontend/src/i18n/locales/de/card.json | 20 -- frontend/src/i18n/locales/de/channel.json | 27 --- frontend/src/i18n/locales/de/channels.json | 124 ---------- frontend/src/i18n/locales/de/common.json | 21 -- frontend/src/i18n/locales/de/config.json | 204 ---------------- frontend/src/i18n/locales/de/datatable.json | 15 -- frontend/src/i18n/locales/de/downloads.json | 201 --------------- frontend/src/i18n/locales/de/editor.json | 45 ---- frontend/src/i18n/locales/de/errors.json | 17 -- frontend/src/i18n/locales/de/feed.json | 60 ----- frontend/src/i18n/locales/de/header.json | 59 ----- frontend/src/i18n/locales/de/inbox.json | 24 -- frontend/src/i18n/locales/de/login.json | 12 - frontend/src/i18n/locales/de/messages.json | 35 --- frontend/src/i18n/locales/de/nav.json | 11 - .../src/i18n/locales/de/notifications.json | 15 -- frontend/src/i18n/locales/de/onboarding.json | 29 --- frontend/src/i18n/locales/de/player.json | 40 --- frontend/src/i18n/locales/de/playlists.json | 61 ----- frontend/src/i18n/locales/de/plex.json | 229 ------------------ .../src/i18n/locales/de/quotaActions.json | 18 -- frontend/src/i18n/locales/de/scheduler.json | 110 --------- frontend/src/i18n/locales/de/settings.json | 155 ------------ frontend/src/i18n/locales/de/setup.json | 50 ---- frontend/src/i18n/locales/de/sidebar.json | 76 ------ frontend/src/i18n/locales/de/stats.json | 54 ----- frontend/src/i18n/locales/de/tagManager.json | 12 - frontend/src/i18n/locales/de/time.json | 19 -- frontend/src/i18n/locales/de/users.json | 46 ---- frontend/src/i18n/locales/de/views.json | 16 -- frontend/src/i18n/locales/de/welcome.json | 80 ------ frontend/src/i18n/locales/en/welcome.json | 2 +- frontend/src/i18n/locales/hu/welcome.json | 2 +- 41 files changed, 6 insertions(+), 1951 deletions(-) delete mode 100644 frontend/src/i18n/locales/de/about.json delete mode 100644 frontend/src/i18n/locales/de/audit.json delete mode 100644 frontend/src/i18n/locales/de/auditActions.json delete mode 100644 frontend/src/i18n/locales/de/card.json delete mode 100644 frontend/src/i18n/locales/de/channel.json delete mode 100644 frontend/src/i18n/locales/de/channels.json delete mode 100644 frontend/src/i18n/locales/de/common.json delete mode 100644 frontend/src/i18n/locales/de/config.json delete mode 100644 frontend/src/i18n/locales/de/datatable.json delete mode 100644 frontend/src/i18n/locales/de/downloads.json delete mode 100644 frontend/src/i18n/locales/de/editor.json delete mode 100644 frontend/src/i18n/locales/de/errors.json delete mode 100644 frontend/src/i18n/locales/de/feed.json delete mode 100644 frontend/src/i18n/locales/de/header.json delete mode 100644 frontend/src/i18n/locales/de/inbox.json delete mode 100644 frontend/src/i18n/locales/de/login.json delete mode 100644 frontend/src/i18n/locales/de/messages.json delete mode 100644 frontend/src/i18n/locales/de/nav.json delete mode 100644 frontend/src/i18n/locales/de/notifications.json delete mode 100644 frontend/src/i18n/locales/de/onboarding.json delete mode 100644 frontend/src/i18n/locales/de/player.json delete mode 100644 frontend/src/i18n/locales/de/playlists.json delete mode 100644 frontend/src/i18n/locales/de/plex.json delete mode 100644 frontend/src/i18n/locales/de/quotaActions.json delete mode 100644 frontend/src/i18n/locales/de/scheduler.json delete mode 100644 frontend/src/i18n/locales/de/settings.json delete mode 100644 frontend/src/i18n/locales/de/setup.json delete mode 100644 frontend/src/i18n/locales/de/sidebar.json delete mode 100644 frontend/src/i18n/locales/de/stats.json delete mode 100644 frontend/src/i18n/locales/de/tagManager.json delete mode 100644 frontend/src/i18n/locales/de/time.json delete mode 100644 frontend/src/i18n/locales/de/users.json delete mode 100644 frontend/src/i18n/locales/de/views.json delete mode 100644 frontend/src/i18n/locales/de/welcome.json diff --git a/README.md b/README.md index 22ef321..ebc3b11 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ blocker and SponsorBlock keep working. presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save to your device, share with another user, or hand out a public watch link. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard. -- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English, - Hungarian and German**. +- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English + and Hungarian**. ## Quick start (self-hosting) diff --git a/backend/app/auth.py b/backend/app/auth.py index 5f814d6..cdd8490 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -364,7 +364,7 @@ async def callback( prefs = dict(user.preferences or {}) if not prefs.get("language"): loc = (userinfo.get("locale") or "").split("-")[0].lower() - prefs["language"] = loc if loc in {"en", "hu", "de"} else "en" + prefs["language"] = loc if loc in {"en", "hu"} else "en" user.preferences = prefs db.flush() diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index de41d13..d40d3e7 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -51,14 +51,6 @@ WELCOME = { "jelszóval.\n\n" "Jó böngészést!" ), - "de": ( - "Willkommen bei Siftlode! 👋\n\n" - "Das ist dein privater Nachrichten-Posteingang. Unterhaltungen mit anderen Mitgliedern " - "sind Ende-zu-Ende-verschlüsselt — nur du und dein Gegenüber könnt sie lesen, nicht " - "einmal ein Admin. Um jemandem zu schreiben, richte zuerst die sichere " - "Nachrichtenübermittlung mit einem Passwort ein.\n\n" - "Viel Spaß mit deinem Feed!" - ), } diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index eaaf244..3aee33a 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -95,7 +95,7 @@ export default function LanguageSwitcher({ className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" > - {/* Current-language badge (HU/EN/DE) so the active language reads at a glance without + {/* Current-language badge (HU/EN) so the active language reads at a glance without opening the menu. The ring matches the page background to detach it from the icon. */} {current.code.toUpperCase()} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 1e07905..2d7d503 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -6,7 +6,6 @@ import { LS } from "../lib/storage"; export const LANGUAGES = [ { code: "en", label: "English" }, { code: "hu", label: "Magyar" }, - { code: "de", label: "Deutsch" }, ] as const; export type LangCode = (typeof LANGUAGES)[number]["code"]; const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[]; diff --git a/frontend/src/i18n/locales/de/about.json b/frontend/src/i18n/locales/de/about.json deleted file mode 100644 index c529a67..0000000 --- a/frontend/src/i18n/locales/de/about.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "title": "Über", - "tagline": "Selbstgehosteter, mehrbenutzerfähiger Reader für deine YouTube-Abos.", - "frontend": "Frontend", - "backend": "Backend", - "database": "Datenbank", - "buildDate": "Build-Datum", - "whatsNew": "Neuigkeiten", - "releaseNotes": "Versionshinweise", - "new": "Neu", - "fixes": "Korrekturen" -} diff --git a/frontend/src/i18n/locales/de/audit.json b/frontend/src/i18n/locales/de/audit.json deleted file mode 100644 index cabffc3..0000000 --- a/frontend/src/i18n/locales/de/audit.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "Audit-Protokoll", - "intro": "Ein reines Anhänge-Protokoll der Admin-Aktionen und Scheduler-Laufzusammenfassungen — wer was wann getan hat. Auf Aktionsebene protokolliert, nie pro Element.", - "empty": "Noch keine Protokolleinträge.", - "system": "System", - "clear": "Protokoll leeren", - "clearTitle": "Audit-Protokoll leeren?", - "clearConfirm": "Alle Protokolleinträge dauerhaft löschen? Das kann nicht rückgängig gemacht werden. (Ein Eintrag, der festhält, dass du es geleert hast, bleibt erhalten.)", - "cleared": "{{count}} Protokolleinträge gelöscht", - "clearFailed": "Das Protokoll konnte nicht geleert werden.", - "truncated": "Die {{returned}} neuesten von {{total}} Einträgen werden angezeigt.", - "cols": { - "when": "Wann", - "actor": "Wer", - "action": "Aktion", - "details": "Details" - } -} diff --git a/frontend/src/i18n/locales/de/auditActions.json b/frontend/src/i18n/locales/de/auditActions.json deleted file mode 100644 index 4705cfe..0000000 --- a/frontend/src/i18n/locales/de/auditActions.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "user_role_change": "Rolle geändert", - "user_suspend": "Benutzer gesperrt", - "user_unsuspend": "Benutzer reaktiviert", - "user_delete": "Benutzer gelöscht", - "invite_approve": "Zugriffsanfrage genehmigt", - "invite_deny": "Zugriffsanfrage abgelehnt", - "invite_add": "E-Mail auf Whitelist", - "demo_add": "Demo-E-Mail hinzugefügt", - "demo_remove": "Demo-E-Mail entfernt", - "demo_reset": "Demo zurückgesetzt", - "discovery_purge": "Entdeckung bereinigt", - "config_set": "Einstellung geändert", - "config_reset": "Einstellung zurückgesetzt", - "scheduler_interval": "Job-Intervall geändert", - "maintenance_tune": "Wartung angepasst", - "sync_pause": "Sync pausiert", - "sync_resume": "Sync fortgesetzt", - "job_run": "Scheduler-Job ausgeführt", - "audit_clear": "Audit-Protokoll geleert" -} diff --git a/frontend/src/i18n/locales/de/card.json b/frontend/src/i18n/locales/de/card.json deleted file mode 100644 index ff3e4e7..0000000 --- a/frontend/src/i18n/locales/de/card.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "live": "live", - "upcoming": "demnächst", - "watchedUnmark": "Angesehen — zum Aufheben klicken", - "markWatched": "Als angesehen markieren", - "savedRemove": "Gespeichert — zum Entfernen klicken", - "saveForLater": "Für später speichern", - "unhide": "Einblenden", - "hide": "Ausblenden", - "onlyThisChannel": "Nur dieser Kanal", - "resetState": "Zurücksetzen — Fortschritt/Status löschen", - "thisChannel": "Dieser Kanal", - "continueTitle": "Dort fortsetzen, wo du aufgehört hast", - "continue": "Fortsetzen", - "restartTitle": "Von vorne abspielen", - "restart": "Von vorne", - "play": "Abspielen", - "stream": "Stream", - "views": "Aufrufe" -} diff --git a/frontend/src/i18n/locales/de/channel.json b/frontend/src/i18n/locales/de/channel.json deleted file mode 100644 index a5cfe78..0000000 --- a/frontend/src/i18n/locales/de/channel.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "back": "Zurück", - "exploringBadge": "Erkunden", - "subscribe": "Abonnieren", - "subscribedState": "Abonniert", - "unsubscribe": "Abbestellen", - "unsubTitle": "Abbestellen?", - "unsubBody": "{{name}} auf YouTube nicht mehr folgen?", - "subscribed": "{{name}} abonniert", - "subscribers": "{{formatted}} Abonnenten", - "videoCount_one": "{{count}} Video", - "videoCount_other": "{{count}} Videos", - "totalViews": "{{formatted}} Aufrufe", - "joined": "Beigetreten {{date}}", - "loadingVideos": "Videos dieses Kanals werden geladen…", - "tabVideos": "Videos", - "tabAbout": "Info", - "loadMore": "Mehr von YouTube laden", - "noDescription": "Dieser Kanal hat keine Beschreibung.", - "block": "Kanal blockieren", - "unblock": "Blockierung aufheben", - "blockedBadge": "Blockiert", - "country": "Land", - "language": "Sprache", - "topics": "Themen", - "keywords": "Schlagwörter" -} diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json deleted file mode 100644 index a1cc7fc..0000000 --- a/frontend/src/i18n/locales/de/channels.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "intro": "Lege die <0>Priorität eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags an, um den Feed zu filtern, oder <2>blende einen Kanal <2>aus, um ihn ohne Abbestellung aus dem Feed zu entfernen.", - "syncSubscriptions": "Abos von YouTube lesen", - "syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.", - "backfillEverything": "Alles nachladen", - "backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.", - "tabs": { - "subscribed": "Abonnements", - "discovery": "Aus Playlists entdecken" - }, - "discovery": { - "intro": "Kanäle, die in deinen Playlists vorkommen, die du aber nicht abonniert hast. Abonniere sie, um ihnen zu folgen — ihre neuen Uploads erscheinen dann in deinem Feed (das Abonnieren kostet etwas YouTube-Kontingent; vorhandene Videos werden nicht erneut geladen).", - "loading": "Kanäle werden gesucht…", - "empty": "Keine neuen Kanäle — jeden Kanal in deinen Playlists abonnierst du bereits.", - "subscribe": "Abonnieren", - "subscribeHint": "Diesen Kanal auf YouTube abonnieren (ändert dein echtes Konto; verbraucht etwas API-Kontingent). Seine Videos kommen bei der nächsten Hintergrund-Synchronisierung.", - "needWriteHint": "Aktiviere das Bearbeiten von Playlists in den Einstellungen, um auf YouTube zu abonnieren.", - "subscribedTitle": "Auf YouTube abonniert", - "subscribedBody": "Du folgst jetzt {{name}} — neue Uploads erscheinen in deinem Feed.", - "subscribeFailed": "Abonnieren fehlgeschlagen", - "confirmSubscribe": "„{{name}}“ auf YouTube abonnieren? Das ändert dein echtes YouTube-Konto und verbraucht etwas API-Kontingent.", - "cols": { - "totalVideos": "Videos", - "inPlaylists": "In Playlists", - "inPlaylistsHint": "{{videos}} Video(s) von diesem Kanal in {{playlists}} deiner Playlist(s)." - } - }, - "filters": { - "all": "Alle", - "needsFull": "Verlauf unvollständig", - "fullySynced": "Vollständig synchronisiert", - "hidden": "Ausgeblendet" - }, - "tags": { - "yourTags": "Deine Tags", - "manage": "Tags verwalten", - "yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)" - }, - "loading": "Kanäle werden geladen…", - "empty": "Keine Kanäle.", - "pager": { - "prev": "Zurück", - "next": "Weiter", - "page": "Seite {{page}} von {{total}}" - }, - "cols": { - "priority": "Prio", - "channel": "Kanal", - "stored": "Gespeichert", - "subs": "Abonnenten", - "lastUpload": "Letzter Upload", - "length": "Länge", - "types": "N/S/L", - "typesHint": "Videos nach Typ: Normale / Shorts / Live", - "sync": "Sync", - "tags": "Tags", - "actions": "Aktionen" - }, - "stats": { - "channels": "Kanäle", - "channelsHint": "Kanäle, die du abonniert hast.", - "recentSynced": "Aktuelle synchronisiert", - "recentSyncedHint": "Kanäle, deren neueste Uploads in der lokalen Datenbank sind und in deinem Feed erscheinen.", - "fullHistory": "Vollständiger Verlauf", - "fullHistoryHint": "Kanäle, deren gesamter Katalog geladen ist, von denen, für die du den vollständigen Verlauf angefordert hast.", - "left": "übrig", - "leftHint": "Grobe Schätzung zum Abschluss des Nachladens des vollständigen Verlaufs für {{count}} ausstehende(n) Kanal/Kanäle, basierend auf dem gemeinsamen Tageskontingent.", - "myVideos": "Meine Videos", - "myVideosHint": "Insgesamt verfügbare Videos über all deine Kanäle.", - "quotaLeft": "Kontingent übrig", - "quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)." - }, - "row": { - "openOnYouTube": "Auf YouTube öffnen", - "editTags": "Tags bearbeiten", - "filterFeedByTag": "Feed nach „{{name}}“ filtern", - "priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.", - "raisePriority": "Priorität erhöhen", - "lowerPriority": "Priorität senken", - "recent": "aktuell", - "recentSyncedHint": "Neueste Uploads synchronisiert.", - "recentNotSyncedHint": "Neueste Uploads noch nicht geladen.", - "full": "vollständig", - "fullHint": "Vollständiger Verlauf geladen.", - "fullNotFetchedHint": "Das vollständige Archiv ist noch nicht geladen — nutze die Backfill-Aktion in der Spalte Aktionen.", - "fullySynced": "vollständig synchron", - "fullySyncedHint": "Aktuelle Uploads und das vollständige Archiv sind beide vorhanden.", - "backfillThis": "Diesen Kanal vollständig laden", - "backfillThisHint": "Das vollständige Archiv dieses Kanals laden (ältere Videos + komplette Suche). Erneut klicken, um die Anfrage zurückzuziehen.", - "resetBackfill": "Kanal zurücksetzen & neu laden", - "resetHint": "Admin: diesen Kanal von Grund auf neu laden (aktuelle + vollständiges Archiv), unabhängig vom aktuellen Sync-Status.", - "fullHistoryQueued": "vollständiger Verlauf in Warteschlange", - "fullHistoryComing": "vollständiger Verlauf unterwegs", - "queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.", - "queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.", - "hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.", - "hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.", - "unhide": "Einblenden", - "hideFromFeed": "Aus Feed ausblenden", - "unsubscribeHint": "Bestelle diesen Kanal bei YouTube ab (ändert dein echtes Konto). Im schreibgeschützten Modus ist dies ausgeblendet — nutze stattdessen Ausblenden.", - "unsubscribeOnYoutube": "Bei YouTube abbestellen", - "thisChannel": "Dieser Kanal" - }, - "notify": { - "synced": "{{count}} Abos synchronisiert", - "syncFailed": "Abo-Synchronisierung fehlgeschlagen", - "unsubscribed": "{{name}} bei YouTube abbestellt", - "unsubscribeFailed": "Abbestellen fehlgeschlagen", - "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", - "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", - "needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.", - "connect": "Verbinden", - "filteredByTag": "Feed nach Tag gefiltert: {{name}}", - "resetDone": "{{name}} zurückgesetzt — wird neu geladen", - "resetFailed": "Kanal konnte nicht zurückgesetzt werden" - }, - "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.", - "confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.", - "searchPlaceholder": "Kanäle suchen…", - "blocked": { - "title": "Blockierte Kanäle", - "hint": "Ihre Videos sind in deinem Feed, der Bibliothek und der Live-Suche ausgeblendet." - } -} diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json deleted file mode 100644 index 854bffd..0000000 --- a/frontend/src/i18n/locales/de/common.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "privacyPolicy": "Datenschutzerklärung", - "termsOfService": "Nutzungsbedingungen", - "sourceCode": "Quellcode", - "close": "Schließen", - "cancel": "Abbrechen", - "confirm": "Bestätigen", - "confirmTitle": "Bist du sicher?", - "save": "Speichern", - "undo": "Rückgängig", - "redo": "Wiederholen", - "loading": "Wird geladen…", - "language": "Sprache", - "somethingWrong": "Etwas ist schiefgelaufen.", - "accessRequestsTitle": "Zugangsanfragen", - "accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.", - "accessRequestsReview": "Überprüfen", - "demoTitle": "Demo-Konto", - "demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden.", - "backToTop": "Nach oben" -} diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json deleted file mode 100644 index 42a0eee..0000000 --- a/frontend/src/i18n/locales/de/config.json +++ /dev/null @@ -1,204 +0,0 @@ -{ - "intro": "In der Datenbank gespeicherte Betriebseinstellungen — sie überschreiben die Datei-/Env-Standardwerte und wirken ohne erneutes Deployment. Lass ein Feld leer (oder setze es zurück), um auf den Standard zurückzufallen.", - "loading": "Konfiguration wird geladen…", - "groups": { - "access": "Zugang", - "email": "E-Mail / SMTP", - "youtube": "YouTube-API", - "google": "Google-Anmeldung", - "quota": "Kontingent", - "backfill": "Nachladen (Backfill)", - "shorts": "Shorts-Prüfung", - "batch": "Stapelgrößen", - "explore": "Kanal erkunden", - "downloads": "Downloads", - "plex": "Plex", - "audit": "Audit-Protokoll" - }, - "fields": { - "smtp_host": { - "label": "SMTP-Host", - "hint": "z. B. smtp.gmail.com" - }, - "smtp_port": { - "label": "SMTP-Port", - "hint": "Üblicherweise 587 (STARTTLS)." - }, - "smtp_user": { - "label": "SMTP-Benutzername", - "hint": "Das sendende Konto / der Login." - }, - "smtp_from": { - "label": "Absenderadresse", - "hint": "z. B. Siftlode . Standard ist der Benutzername." - }, - "smtp_password": { - "label": "SMTP-Passwort", - "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." - }, - "quota_daily_budget": { - "label": "Tägliches Kontingentbudget", - "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." - }, - "backfill_quota_reserve": { - "label": "Nachlade-Kontingentreserve", - "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." - }, - "search_daily_limit_per_user": { - "label": "Live-Suche — Tageslimit pro Nutzer", - "hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert." - }, - "search_source": { - "label": "Quelle der Live-Suche", - "hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird." - }, - "backfill_recent_max_videos": { - "label": "Aktuelles Nachladen — max. Videos", - "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." - }, - "backfill_recent_max_days": { - "label": "Aktuelles Nachladen — max. Alter (Tage)", - "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." - }, - "shorts_probe_max_seconds": { - "label": "Shorts-Prüfung — max. Dauer (s)", - "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." - }, - "shorts_probe_batch": { - "label": "Shorts-Prüfung — Stapelgröße", - "hint": "Wie viele Videos pro Lauf klassifiziert werden." - }, - "enrich_batch_size": { - "label": "Anreicherungs-Stapelgröße", - "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." - }, - "autotag_title_sample": { - "label": "Auto-Tag-Titelstichprobe", - "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." - }, - "youtube_api_key": { - "label": "YouTube-API-Schlüssel", - "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." - }, - "youtube_api_proxy": { - "label": "YouTube-API-Egress-Proxy", - "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." - }, - "google_client_id": { - "label": "Google-Client-ID", - "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." - }, - "google_client_secret": { - "label": "Google-Client-Secret", - "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." - }, - "allow_registration": { - "label": "Registrierung erlauben", - "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." - }, - "explore_grace_days": { - "label": "Erkunden — Karenzzeit (Tage)", - "hint": "Wie lange ein erkundeter, nicht abonnierter Kanal (und seine flüchtigen Videos) behalten wird, bevor die Aufräumaufgabe ihn entfernt. Ein Abo behält ihn dauerhaft." - }, - "search_grace_days": { - "label": "Suchergebnisse — Karenzzeit (Tage)", - "hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt." - }, - "download_total_max_bytes": { - "label": "Gesamt-Cache-Limit (Bytes)", - "hint": "Maximale Gesamtgröße über die Downloads aller Nutzer. 0 = unbegrenzt (die Festplatte ist die Grenze); bei Überschreitung entfernt die GC die am längsten ungenutzten Dateien." - }, - "download_default_max_bytes": { - "label": "Standard-Kontingent pro Nutzer (Bytes)", - "hint": "Wie viel Speicher ein Nutzer belegen darf, bevor neue Downloads blockiert werden. Eine Überschreibung pro Nutzer hat Vorrang. Standard 5 GiB." - }, - "download_default_max_concurrent": { - "label": "Gleichzeitige Downloads / Nutzer", - "hint": "Wie viele Downloads ein Nutzer gleichzeitig ausführen darf." - }, - "download_default_max_jobs": { - "label": "Job-Limit / Nutzer", - "hint": "Maximale Anzahl an Downloads, die ein Nutzer in seiner Bibliothek behalten darf." - }, - "download_retention_days": { - "label": "Aufbewahrung (Tage)", - "hint": "Ein Download läuft so viele Tage nach dem letzten Zugriff ab; die Aufräumaufgabe entfernt ihn dann (sobald nichts mehr darauf verweist) nach der Karenzzeit." - }, - "download_gc_grace_days": { - "label": "Aufräum-Karenzzeit (Tage)", - "hint": "Zusätzliche Tage, die ein abgelaufener Download vor dem Löschen behalten wird." - }, - "download_layout": { - "label": "Ablage-Layout", - "hint": "„plex“ = ein Kanal/Staffel JJJJ/…-Baum mit .nfo + Poster (in Plex indexierbar); „flat“ = ein einzelner Ordner." - }, - "download_worker_concurrency": { - "label": "Worker-Parallelität", - "hint": "Wie viele Downloads der Worker parallel verarbeitet." - }, - "sponsorblock_default": { - "label": "SponsorBlock standardmäßig", - "hint": "SponsorBlock (Sponsor-Segmente überspringen) für neu erstellte benutzerdefinierte Download-Profile aktivieren." - }, - "plex_enabled": { - "label": "Plex aktivieren", - "hint": "Zeigt die Plex-Bibliothek als Feed-Quelle mit einem umfangreichen Player. Erfordert, dass der Plex-Server von diesem Host erreichbar ist und die Medien auf einem lokalen Mount liegen (siehe Pfad-Zuordnung)." - }, - "plex_server_url": { - "label": "Plex-Server-URL", - "hint": "Von diesem Host erreichbare Basis-URL, z. B. http://192.168.1.100:32400." - }, - "plex_server_token": { - "label": "Plex-Server-Token", - "hint": "Der Admin-X-Plex-Token des Servers. Nur serverseitig genutzt (Metadaten + Bilder); wird nie an den Browser gesendet. Verschlüsselt gespeichert; nur schreibbar." - }, - "plex_path_map": { - "label": "Medien-Pfad-Zuordnung", - "hint": "Ordnet die Plex-Dateipfade dem lokalen Mount dieses Hosts zu, damit Siftlode die physische Datei abspielen kann. Durch Zeilenumbruch/Komma getrennte plexPrefix=localPrefix-Paare, z. B. /data=/plex-media. Leer = auf beiden Seiten identisch." - }, - "plex_libraries": { - "label": "Angezeigte Bibliotheken", - "hint": "Durch Kommas getrennte Plex-Sektionsschlüssel. Leer = alle Sektionen. Nutze nach dem Verbindungstest die Bibliotheksauswahl unten." - }, - "plex_sync_interval_min": { - "label": "Sync-Intervall (Minuten)", - "hint": "Wie oft die Katalog-Sync-Aufgabe die Plex-Metadaten spiegelt." - }, - "plex_max_transcodes": { - "label": "Max. gleichzeitige Transcodes", - "hint": "Obergrenze für gleichzeitige CPU-Transcodes inkompatibler Dateien (späterer Fallback). Direkt abspielbare Dateien sind nicht begrenzt." - } - }, - "save": "Speichern", - "saving": "Speichern…", - "saved": "Gespeichert", - "saveFailed": "Speichern fehlgeschlagen", - "unsaved": "Nicht gespeicherte Änderungen", - "discard": "Verwerfen", - "willReset": "wird beim Speichern auf den Standard zurückgesetzt", - "reset": "Zurücksetzen", - "resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.", - "resetDone": "Auf Standard zurückgesetzt", - "usingDefault": "Standard wird verwendet", - "overridden": "in der DB überschrieben", - "secretSet": "•••••••• gespeichert", - "secretEnv": "Env-Wert wird verwendet", - "secretUnset": "nicht gesetzt", - "secretsDisabled": "Setze TOKEN_ENCRYPTION_KEY, um Secrets in der Datenbank zu speichern.", - "testEmail": "Test-E-Mail senden", - "testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.", - "testSent": "Test-E-Mail gesendet an {{to}}", - "testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen", - "plexTest": "Verbindung testen", - "plexTestHint": "Prüft die Plex-Server-URL + den Token und lädt die Bibliotheksliste. Speichere zuerst deine Änderungen.", - "plexTestDirty": "Speichere deine Änderungen vor dem Verbindungstest.", - "plexTestOk": "Verbunden mit {{name}}", - "plexTestFailed": "Plex-Verbindung fehlgeschlagen — prüfe URL und Token", - "plexConnected": "Verbunden mit {{name}} {{version}}", - "plexMediaOk": "Medieneinbindung OK — eine Beispieldatei wurde auf der Festplatte gefunden.", - "plexMediaMissing": "Medien unter {{path}} nicht lesbar — prüfe, ob die Plex-Medien in den Container eingebunden sind und die Pfadzuordnung stimmt. Die Wiedergabe schlägt fehl, bis dies behoben ist.", - "plexPickLibraries": "Anzuzeigende Bibliotheken:", - "plexPickHint": "Keine auszuwählen entspricht dem Anzeigen aller Bibliotheken. Nicht vergessen zu speichern.", - "plexMovies": "Filme", - "plexShows": "Serien" -} diff --git a/frontend/src/i18n/locales/de/datatable.json b/frontend/src/i18n/locales/de/datatable.json deleted file mode 100644 index 5e67f41..0000000 --- a/frontend/src/i18n/locales/de/datatable.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "filter": "Filtern", - "clear": "Löschen", - "all": "Alle", - "rowsPerPage": "Zeilen pro Seite", - "noOptions": "Keine Optionen", - "empty": "Nichts anzuzeigen.", - "pager": { - "prev": "Zurück", - "next": "Weiter", - "page": "Seite {{page}} von {{total}}", - "pageLabel": "Seite", - "ofTotal": "von {{total}}" - } -} diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json deleted file mode 100644 index 001f96e..0000000 --- a/frontend/src/i18n/locales/de/downloads.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "navLabel": "Downloads", - "button": { - "label": "Herunterladen", - "queued": "In Warteschlange", - "downloaded": "Heruntergeladen" - }, - "dialog": { - "title": "Video herunterladen", - "profile": "Format", - "nameLabel": "Dateiname (optional)", - "namePlaceholder": "Leer lassen, um den Videotitel zu verwenden", - "submit": "Zu Downloads hinzufügen", - "added": "Zu Downloads hinzugefügt", - "view": "Öffnen", - "manage": "Formate verwalten" - }, - "page": { - "title": "Downloads", - "subtitle": "Videos auf den Server laden und dann auf dein Gerät speichern.", - "addUrl": "YouTube-Link oder Video-ID einfügen…", - "add": "Hinzufügen", - "manage": "Formate verwalten" - }, - "tabs": { - "queue": "Warteschlange", - "done": "Bibliothek", - "shared": "Mit mir geteilt", - "system": "System" - }, - "status": { - "queued": "Wartet", - "running": "Lädt", - "paused": "Pausiert", - "done": "Fertig", - "error": "Fehlgeschlagen", - "canceled": "Abgebrochen", - "expired": "Abgelaufen" - }, - "phase": { - "video": "Video wird geladen", - "audio": "Audio wird geladen", - "merging": "Zusammenführen", - "audio_extract": "Audio wird extrahiert", - "thumbnail": "Vorschaubild einbetten", - "sponsorblock": "Sponsoren entfernen", - "metadata": "Metadaten schreiben", - "processing": "Verarbeitung", - "editing": "Bearbeitung" - }, - "actions": { - "pause": "Pause", - "resume": "Fortsetzen", - "cancel": "Abbrechen", - "delete": "Entfernen", - "retry": "Erneut", - "download": "Auf mein Gerät speichern", - "rename": "Umbenennen", - "editDetails": "Details bearbeiten", - "share": "Teilen", - "edit": "Bearbeiten / schneiden", - "removeShared": "Aus meiner Liste entfernen" - }, - "empty": { - "queue": "Die Warteschlange ist leer.", - "done": "Noch keine Downloads.", - "shared": "Es wurde nichts mit dir geteilt.", - "admin": "Auf dieser Instanz gibt es noch keine Downloads." - }, - "usage": { - "title": "Dein Speicher", - "items": "{{used}} / {{max}} Elemente", - "unlimited": "Unbegrenzt" - }, - "source": { - "open": "Originalseite in neuem Tab öffnen", - "copy": "Quell-Link kopieren", - "copied": "Quell-Link in die Zwischenablage kopiert", - "copyFailed": "Link konnte nicht kopiert werden" - }, - "edit": { - "title": "Details bearbeiten", - "name": "Titel", - "channel": "Kanal", - "channelUrl": "Kanal-Link", - "extraLinks": "Weitere Links", - "addLink": "Link hinzufügen", - "removeLink": "Link entfernen", - "hint": "Nur Anzeige — die gespeicherte Datei behält ihren Namen. Kanal und Links erscheinen auch auf der Karte und auf geteilten Wiedergabeseiten.", - "save": "Speichern" - }, - "share": { - "title": "Download teilen", - "label": "E-Mail des Empfängers", - "placeholder": "user@example.com", - "submit": "Teilen", - "done": "Geteilt mit {{email}}", - "failed": "Teilen fehlgeschlagen.", - "userSection": "Mit einem Nutzer teilen", - "userHint": "Erscheint bei ihm unter „Mit mir geteilt”.", - "pickPlaceholder": "Nutzer suchen…", - "noUsers": "Keine weiteren Nutzer.", - "linkSection": "Per Link teilen", - "linkHint": "Jeder mit dem Link kann zusehen — ohne Konto.", - "noLinks": "Noch keine Links.", - "newLink": "Link erstellen", - "createLink": "Erstellen", - "allowDownload": "Download erlauben", - "expiry": "Läuft ab", - "expiryNever": "Nie", - "days": "{{n}} Tage", - "password": "Passwort (optional)", - "copy": "Link kopieren", - "copyFailed": "Kopieren fehlgeschlagen.", - "revoke": "Widerrufen", - "revokeConfirm": "Der Link funktioniert sofort nicht mehr.", - "views": "{{n}} Aufrufe", - "expiresOn": "läuft ab {{date}}", - "hasPassword": "Passwort", - "downloadable": "herunterladbar" - }, - "confirm": { - "cancelTitle": "Download abbrechen?", - "cancelBody": "Dies stoppt den Download und entfernt ihn aus deiner Warteschlange.", - "deleteTitle": "Download entfernen?", - "deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben.", - "removeSharedTitle": "Aus deiner Liste entfernen?", - "removeSharedBody": "Entfernt es nur aus deiner geteilten Liste — die Datei des Besitzers wird nicht gelöscht." - }, - "profiles": { - "manage": "Formate verwalten", - "title": "Download-Formate", - "builtin": "Integriert", - "yours": "Deine Formate", - "new": "Neues Format", - "name": "Name", - "namePlaceholder": "Mein Format", - "mode": "Inhalt", - "modeAv": "Video + Audio", - "modeV": "Nur Video", - "modeA": "Nur Audio", - "quality": "Max. Qualität", - "best": "Beste", - "container": "Container", - "audioFormat": "Audioformat", - "vcodec": "Video-Codec", - "any": "Beliebig", - "embedSubs": "Untertitel einbetten", - "embedChapters": "Kapitel einbetten", - "embedThumbnail": "Vorschaubild einbetten", - "sponsorblock": "Sponsor-Segmente überspringen (SponsorBlock)", - "save": "Speichern", - "create": "Erstellen", - "delete": "Löschen", - "deleteConfirm": "Das Format „{{name}}“ löschen?" - }, - "admin": { - "storageTitle": "Speicher", - "readyFiles": "Fertige Dateien", - "totalSize": "Gesamtgröße", - "cap": "Cache-Limit", - "noCap": "kein Limit", - "perUser": "Speicher pro Nutzer", - "user": "Nutzer", - "footprint": "Belegung", - "editQuota": "Kontingent bearbeiten", - "quotaTitle": "Download-Kontingent — {{email}}", - "maxBytes": "Speicherlimit (GB)", - "maxJobs": "Max. Downloads", - "maxConcurrent": "Max. gleichzeitig", - "unlimited": "Unbegrenzt (Speicherlimit umgehen)", - "reset": "Auf Standard zurücksetzen", - "save": "Speichern", - "custom": "individuell", - "default": "Standard", - "quotaPickPlaceholder": "Kontingent für einen Nutzer festlegen…", - "noFootprint": "Noch keine Downloads." - }, - "cols": { - "title": "Titel", - "channel": "Kanal", - "format": "Format", - "size": "Größe", - "status": "Status", - "added": "Hinzugefügt", - "user": "Nutzer" - }, - "clipBadge": "Clip", - "errors": { - "quota": { - "max_bytes": "Dein Download-Speicherkontingent ist aufgebraucht ({{size}} GB). Gib zuerst Speicher frei.", - "max_jobs": "Du hast dein Download-Limit erreicht ({{limit}} Objekte). Entferne zuerst einige.", - "generic": "Download-Kontingent überschritten." - }, - "edit": { - "empty_edit": "Wähle zuerst einen Zuschneidebereich oder Ausschnitt.", - "source_not_ready": "Der Quell-Download ist noch nicht bearbeitbar.", - "generic": "Dieser Schnitt konnte nicht erstellt werden." - } - } -} diff --git a/frontend/src/i18n/locales/de/editor.json b/frontend/src/i18n/locales/de/editor.json deleted file mode 100644 index e82903c..0000000 --- a/frontend/src/i18n/locales/de/editor.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "title": "Bearbeiten: „{{name}}“", - "playPause": "Wiedergabe / Pause", - "setIn": "Start hier", - "setOut": "Ende hier", - "in": "Start", - "out": "Ende", - "selected": "Auswahl", - "cropOff": "Zuschneiden", - "cropOn": "Zuschneiden aktiv", - "cropReencode": "Zuschneiden kodiert immer neu (bildgenau).", - "split": "Aufteilen in", - "accurate": { - "label": "Genauer Schnitt", - "hint": "Bildgenau. Kodiert den Clip neu." - }, - "fast": { - "label": "Schneller Schnitt", - "hint": "Sofort, schneidet aber an Keyframes (±1–2 s)." - }, - "nameLabel": "Clip-Name (optional)", - "willReencode": "Wird neu kodiert", - "willCopy": "Sofort (Stream-Kopie)", - "create": "Clip erstellen", - "createN": "{{count}} Clips erstellen", - "created_one": "{{count}} Clip zu deinen Downloads hinzugefügt", - "created_other": "{{count}} Clips zu deinen Downloads hinzugefügt", - "failed": "Clip konnte nicht erstellt werden.", - "splitHere": "Hier teilen", - "output": { - "label": "Ausgabe", - "separate": "Einzelne Dateien", - "join": "Zu einer zusammenfügen" - }, - "segment": "Segment {{n}}", - "keep": "Behalten", - "drop": "Verwerfen", - "kept": "Behalten", - "dropped": "Verworfen", - "keptSeg": "Behaltenes Segment", - "droppedSeg": "Verworfenes Segment", - "removeCut": "Schnitt entfernen", - "keptSummary": "{{n}} behalten · {{dur}}", - "createJoin": "Zusammengefügten Clip erstellen" -} diff --git a/frontend/src/i18n/locales/de/errors.json b/frontend/src/i18n/locales/de/errors.json deleted file mode 100644 index 8b09923..0000000 --- a/frontend/src/i18n/locales/de/errors.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "title": "Nicht möglich", - "generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.", - "server": "Serverfehler", - "ok": "OK", - "offline": { - "title": "Verbindung verloren", - "body": "Server nicht erreichbar — er startet möglicherweise gerade neu. Dieser Hinweis verschwindet, sobald die Verbindung wieder steht." - }, - "boundary": { - "title": "Etwas ist schiefgelaufen.", - "subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.", - "reload": "Neu laden", - "notifTitle": "Etwas ist kaputtgegangen", - "notifMessage": "Unerwarteter Fehler" - } -} diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json deleted file mode 100644 index 8008e5a..0000000 --- a/frontend/src/i18n/locales/de/feed.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "loading": "Feed wird geladen…", - "loadError": "Feed konnte nicht geladen werden.", - "emptyTitle": "Dein Feed ist leer", - "emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.", - "setUp": "Meinen Feed einrichten", - "browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen", - "demoEmptyTitle": "Hier in der Demo ist nichts", - "demoEmptyBody": "Das Demo-Konto hat keine eigenen Abos — aber die gesamte gemeinsame Bibliothek steht dir offen: durchsuchen, filtern und sortieren.", - "browseLibrary": "Bibliothek durchsuchen", - "noMatches": "Keine Videos entsprechen diesen Filtern.", - "videoCount_one": "{{formattedCount}} Video", - "videoCount_other": "{{formattedCount}} Videos", - "sortLabel": "Sortierung", - "dirAsc": "Aufsteigend", - "dirDesc": "Absteigend", - "sortKey": { - "date": "Datum", - "popular": "Beliebt", - "duration": "Dauer", - "title": "Name", - "subscribers": "Kanal-Abonnenten", - "priority": "Kanal-Priorität", - "shuffle": "Überraschung", - "relevance": "Relevanz" - }, - "loadingMore": "Mehr wird geladen…", - "hiddenNamed": "Ausgeblendet: „{{title}}”", - "hidden": "Video ausgeblendet", - "undo": "Rückgängig", - "markedWatchedNamed": "Als angesehen markiert: „{{title}}”", - "markedWatched": "Als angesehen markiert", - "unwatch": "Nicht mehr als angesehen", - "source": { - "label": "Quelle", - "tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.", - "organic": "Ohne Suchergebnisse", - "all": "Mit Suchergebnissen", - "only": "Nur Suchergebnisse", - "plex": "Plex" - }, - "yt": { - "searchFor": "Auf YouTube suchen nach „{{query}}”", - "resultsFor": "YouTube-Ergebnisse für „{{query}}”", - "quotaNote": "Live-Ergebnisse — verbraucht gemeinsames API-Kontingent", - "freeNote": "Live-Ergebnisse — kein Suchkontingent verbraucht", - "back": "Zurück zum Feed", - "searching": "Suche auf YouTube…", - "noResults": "Keine YouTube-Videos gefunden.", - "error": "YouTube-Suche fehlgeschlagen.", - "loadMore": "Mehr laden (verbraucht Kontingent)", - "loadMoreFree": "Mehr laden", - "show": "Zeigen", - "ephemeralNote": "Diese Ergebnisse sind temporär — sie verschwinden automatisch, außer du siehst dir eines an oder speicherst es.", - "clearNow": "Jetzt löschen", - "cleared_one": "{{count}} Ergebnis gelöscht", - "cleared_other": "{{count}} Ergebnisse gelöscht", - "showHint": "Wie viele Ergebnisse auf einmal — Mehr laden holt einen weiteren Block dieser Größe." - } -} diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json deleted file mode 100644 index a8987bd..0000000 --- a/frontend/src/i18n/locales/de/header.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "feed": "Feed", - "searchPlaceholder": "Deine Abos durchsuchen…", - "clearSearch": "Suche löschen", - "searchYoutube": "Auf YouTube suchen", - "searchYoutubeTip": "Live auf YouTube nach diesem Begriff suchen (verbraucht gemeinsames API-Kontingent). Du kannst auch Enter drücken.", - "go": "Los", - "goTip": "Suchen (oder Enter drücken)", - "prevModule": "Vorheriges Modul", - "nextModule": "Nächstes Modul", - "channelManager": "Kanalverwaltung", - "usageStats": "Nutzung & Statistik", - "scheduler": "Planer", - "configuration": "Konfiguration", - "users": "Benutzer", - "scope": { - "label": "Feed-Quelle", - "my": "Meine", - "myTip": "Nur Videos aus deinen eigenen Abos", - "all": "Bibliothek", - "allTip": "Alle Videos im gemeinsamen Katalog" - }, - "account": { - "admin": "Admin", - "feed": "Feed", - "channels": "Kanäle", - "playlists": "Wiedergabelisten", - "stats": "Statistik", - "scheduler": "Planer", - "config": "Konfiguration", - "users": "Benutzer", - "settings": "Einstellungen", - "about": "Über", - "signOut": "Abmelden", - "addAccount": "Weiteres Konto hinzufügen", - "switchTo": "Zu {{name}} wechseln", - "audit": "Audit-Protokoll" - }, - "sync": { - "yours": "deine", - "total": "gesamt", - "countTooltip": "Videos aus deinen Abos im Vergleich zur Gesamtzahl im gemeinsamen Katalog, den die Abos aller Nutzer gefüllt haben.", - "paused": "pausiert", - "syncing": "{{count}} werden synchronisiert", - "backfillingHistory": "Verlauf wird geladen", - "working": "Synchronisierung…", - "recentQueued": "{{count}} in Warteschlange", - "allSynced": "alles synchronisiert", - "withoutFullHistory": "{{count}} ohne vollständigen Verlauf", - "fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.", - "pause": "Hintergrund-Synchronisierung pausieren", - "resume": "Hintergrund-Synchronisierung fortsetzen" - }, - "banner": { - "updated": "Aktualisiert auf v{{version}} — sieh, was sich geändert hat.", - "releaseNotes": "Versionshinweise", - "dismiss": "Schließen" - } -} diff --git a/frontend/src/i18n/locales/de/inbox.json b/frontend/src/i18n/locales/de/inbox.json deleted file mode 100644 index 00df770..0000000 --- a/frontend/src/i18n/locales/de/inbox.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "navLabel": "Benachrichtigungen", - "title": "Benachrichtigungen", - "subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.", - "tabInbox": "Benachrichtigungen", - "empty": "Alles erledigt.", - "markAllRead": "Alle als gelesen markieren", - "clearAll": "Alle löschen", - "markRead": "Als gelesen markieren", - "dismiss": "Verwerfen", - "sectionSystem": "System", - "sectionActivity": "Aktivität", - "andMore": "und {{count}} weitere", - "openScheduler": "Zeitplaner öffnen", - "maintenance": { - "title": "Videos entfernt", - "body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.", - "body_other": "{{count}} gespeicherte oder in Playlists befindliche Videos wurden entfernt, weil sie auf YouTube nicht mehr verfügbar sind." - }, - "jobDone": { - "titleOk": "{{job}} abgeschlossen", - "titleError": "{{job}} fehlgeschlagen" - } -} diff --git a/frontend/src/i18n/locales/de/login.json b/frontend/src/i18n/locales/de/login.json deleted file mode 100644 index c92df28..0000000 --- a/frontend/src/i18n/locales/de/login.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "tagline": "Ein selbstgehosteter, filterbarer Feed deiner YouTube-Abos — sortieren, taggen und den Lärm ausblenden. Melde dich mit Google an, um loszulegen; der YouTube-Zugriff ist ein optionaler, separater Schritt, den du steuerst.", - "signIn": "Mit Google anmelden", - "approved": "Du bist bereits freigeschaltet — melde dich einfach oben mit Google an.", - "requested": "Danke — deine Anfrage ist eingegangen. Wir benachrichtigen dich per E-Mail, sobald sie genehmigt ist.", - "noAccessYet": "Noch keinen Zugang?", - "denied": "Dieses Google-Konto ist nicht freigeschaltet. Fordere unten Zugang an.", - "emailPlaceholder": "du@gmail.com", - "sending": "Wird gesendet…", - "requestAccess": "Zugang anfordern", - "submitError": "Konnte nicht gesendet werden — prüfe die Adresse und versuche es erneut." -} diff --git a/frontend/src/i18n/locales/de/messages.json b/frontend/src/i18n/locales/de/messages.json deleted file mode 100644 index 2ffaf4d..0000000 --- a/frontend/src/i18n/locales/de/messages.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "tab": "Nachrichten", - "navLabel": "Nachrichten", - "popOut": "In Pop-out öffnen", - "expand": "Aufklappen", - "minimize": "Minimieren", - "close": "Schließen", - "conversations": "Unterhaltungen", - "new": "Neue Nachricht", - "newTitle": "Neue Nachricht", - "empty": "Noch keine Unterhaltungen. Starte eine über die Schaltfläche „Neue Nachricht“.", - "searchPeople": "Personen suchen…", - "noPeople": "Noch niemand zum Schreiben.", - "threadEmpty": "Noch keine Nachrichten — sag Hallo.", - "writePlaceholder": "Nachricht schreiben…", - "send": "Senden", - "encrypted": "Verschlüsselte Nachricht", - "encryptedHint": "Ende-zu-Ende-verschlüsselt", - "cantDecrypt": "Kann nicht entschlüsselt werden", - "sendFailed": "Nachricht konnte nicht gesendet werden. Bitte erneut versuchen.", - "systemReadOnly": "Dies ist eine automatische Nachricht.", - "setupTitle": "Sichere Nachrichten einrichten", - "setupBody": "Nachrichten sind Ende-zu-Ende-verschlüsselt. Wähle ein Passwort zum Schutz deines Schlüssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.", - "setupWarn": "Wenn du dieses Passwort vergisst, kann dein Nachrichtenverlauf nicht wiederhergestellt werden.", - "setUp": "Einrichten", - "unlockTitle": "Nachrichten entsperren", - "unlockBody": "Gib dein Nachrichten-Passwort ein, um deine Unterhaltungen auf diesem Gerät zu entsperren.", - "unlock": "Entsperren", - "passphrase": "Passwort", - "passphraseConfirm": "Passwort bestätigen", - "passTooShort": "Mindestens 6 Zeichen verwenden.", - "passMismatch": "Die Passwörter stimmen nicht überein.", - "wrongPass": "Falsches Passwort.", - "setupFailed": "Sichere Nachrichten konnten nicht eingerichtet werden. Bitte erneut versuchen." -} diff --git a/frontend/src/i18n/locales/de/nav.json b/frontend/src/i18n/locales/de/nav.json deleted file mode 100644 index c3135ba..0000000 --- a/frontend/src/i18n/locales/de/nav.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "primary": "Hauptnavigation", - "collapse": "Seitenleiste einklappen", - "expand": "Seitenleiste ausklappen", - "adminSection": "Admin", - "role": { - "admin": "Admin", - "user": "Benutzer", - "demo": "Demo" - } -} diff --git a/frontend/src/i18n/locales/de/notifications.json b/frontend/src/i18n/locales/de/notifications.json deleted file mode 100644 index 5c7fdb3..0000000 --- a/frontend/src/i18n/locales/de/notifications.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "title": "Benachrichtigungen", - "clearAll": "Alle löschen", - "clear": "Löschen", - "empty": "Noch keine Benachrichtigungen.", - "actionNeeded": "Aktion erforderlich", - "dismiss": "Schließen", - "findInFeed": "Im Feed finden", - "unhide": "Einblenden", - "unwatch": "Nicht angesehen", - "openInManager": "Kanalverwaltung", - "openOnYouTube": "Auf YouTube öffnen", - "unhidden": "Eingeblendet „{{title}}”", - "unwatched": "Als nicht angesehen markiert „{{title}}”" -} diff --git a/frontend/src/i18n/locales/de/onboarding.json b/frontend/src/i18n/locales/de/onboarding.json deleted file mode 100644 index ab0238d..0000000 --- a/frontend/src/i18n/locales/de/onboarding.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "close": "Schließen", - "consent": "Google zeigt einen <0>„Google hat diese App nicht überprüft“-Bildschirm — das ist zu erwarten, da Siftlode die vollständige Überprüfung von Google nicht durchlaufen hat. Es ist sicher fortzufahren: klicke auf <1>Erweitert → <2>Weiter zu Siftlode. Du kannst den Zugriff jederzeit in deinem <3>Google-Konto widerrufen.", - "read": { - "title": "Verbinde deine YouTube-Abos", - "body": "Du bist angemeldet. Um deinen Feed aufzubauen, benötigt Siftlode <0>schreibgeschützten Zugriff auf die Kanäle, die du auf YouTube abonniert hast. Es postet, löscht oder ändert damit niemals etwas.", - "connect": "Verbinden (schreibgeschützt)", - "skip": "Vorerst überspringen" - }, - "importing": { - "title": "Dein Feed wird aufgebaut…", - "body": "Deine YouTube-Abos werden importiert. Bereits in Siftlode vorhandene Kanäle erscheinen sofort; neue werden automatisch im Hintergrund ergänzt." - }, - "write": { - "title": "Du bist verbunden", - "importFailed": "Dein YouTube-Konto ist verbunden, aber der Import der Abos ist fehlgeschlagen — du kannst es unter Einstellungen → Synchronisierung erneut versuchen. ", - "feedReady": "Dein Feed ist bereit{{channels}}. Optional kannst du Siftlode erlauben, ", - "feedReadyChannels": " ({{count}} Kanäle)", - "rationale": "Kanäle auf YouTube für dich <0>abzubestellen — dafür ist eine zusätzliche Schreibberechtigung nötig. Überspringe es, um schreibgeschützt zu bleiben; du kannst es später jederzeit in den Einstellungen aktivieren.", - "retryImport": "Import erneut versuchen", - "enableUnsubscribe": "Abbestellen aktivieren", - "keepReadOnly": "Nein danke — schreibgeschützt bleiben" - }, - "done": { - "title": "Alles erledigt", - "body": "Lese- und Abbestell-Zugriff sind beide erteilt. Du kannst beide jederzeit unter Einstellungen → Konto oder in deinem Google-Konto überprüfen oder widerrufen.", - "done": "Fertig" - } -} diff --git a/frontend/src/i18n/locales/de/player.json b/frontend/src/i18n/locales/de/player.json deleted file mode 100644 index 26ef23f..0000000 --- a/frontend/src/i18n/locales/de/player.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "loading": "Wird geladen…", - "backToOriginal": "Zurück zum ursprünglichen Video", - "back": "Zurück", - "previous": "Vorheriges", - "next": "Nächstes", - "autoAdvance": { - "label": "Auto", - "hint": "Was am Videoende folgt. Klicken zum Wechseln: Aus, Nächstes, Vorheriges, Zufall. In deinem Konto gespeichert.", - "off": "Aus", - "next": "Nächstes", - "prev": "Vorheriges", - "random": "Zufall" - }, - "loop": { - "label": "Wiederholen", - "hint": "Aktuelles Video wiederholen (Eins), ganze Liste wiederholen (Alle) oder keines (Aus). In deinem Konto gespeichert.", - "off": "Aus", - "one": "Eins", - "all": "Alle" - }, - "close": "Schließen", - "closeEsc": "Schließen (Esc)", - "description": "Beschreibung", - "noDescription": "Keine Beschreibung.", - "channel": "Kanal", - "views": "{{formattedCount}} Aufrufe", - "stream": "Stream", - "watched": "Angesehen", - "markWatched": "Als angesehen markieren", - "watchedUnmark": "Angesehen — zum Aufheben klicken", - "jumpToTime": "Zu dieser Stelle springen", - "playInApp": "Im integrierten Player abspielen", - "unavailableTitle": "Hier nicht abspielbar", - "unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.", - "embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.", - "openOnYoutube": "Auf YouTube öffnen", - "shortcutsHint": "Klick: Wiedergabe/Pause · Scrollen: Lautstärke · F: Vollbild", - "nativeControls": "YouTube-Steuerung aktiv" -} diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json deleted file mode 100644 index 859bb1e..0000000 --- a/frontend/src/i18n/locales/de/playlists.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "title": "Wiedergabelisten", - "watchLater": "Später ansehen", - "syncYoutube": "Von YouTube synchronisieren", - "syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert", - "syncFailed": "Synchronisierung von YouTube fehlgeschlagen", - "ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert", - "noneYet": "Noch keine Wiedergabelisten", - "newPlaylist": "Neue Liste…", - "searchPlaceholder": "Wiedergabelisten suchen…", - "noMatches": "Keine passende Wiedergabeliste.", - "itemCount_one": "{{count}} Video", - "itemCount_other": "{{count}} Videos", - "pickOne": "Wähle links eine Liste.", - "loading": "Wird geladen…", - "empty": "Diese Liste ist leer — füge Videos aus dem Feed hinzu.", - "playAll": "Alle abspielen", - "delete": "Löschen", - "rename": "Umbenennen", - "confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.", - "addToPlaylist": "Zur Wiedergabeliste hinzufügen", - "exportToYoutube": "Zu YouTube exportieren", - "pushToYoutube": "Mit YouTube synchronisieren", - "unsynced": "Nicht synchronisiert", - "unsyncedHint": "Du hast lokale Änderungen, die noch nicht mit YouTube synchronisiert sind.", - "openOnYoutube": "Auf YouTube öffnen", - "revert": "Von YouTube zurücksetzen", - "revertTitle": "Lokale Änderungen verwerfen?", - "revertMsg": "Dies lädt die Wiedergabeliste von YouTube neu und verwirft deine nicht synchronisierten lokalen Änderungen (Reihenfolge, Hinzufügungen, Entfernungen, Umbenennung). Fortfahren?", - "revertConfirm": "Verwerfen & neu laden", - "revertDone": "„{{name}}“ von YouTube neu geladen ✓", - "revertFailed": "Neuladen von YouTube fehlgeschlagen.", - "pushTitle": "Mit YouTube synchronisieren", - "pushConfirm": "Synchronisieren", - "pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", - "pushPlanUpdate": "Auf YouTube aktualisieren — {{insert}} hinzufügen, {{del}} entfernen, {{reorder}} neu anordnen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", - "pushDiverged": "{{count}} Video(s), die derzeit auf YouTube sind, werden entfernt.", - "pushNoQuota": "Nicht genug YouTube-Kontingent für heute ({{left}}) für diese Synchronisierung (~{{units}} benötigt). Versuche es morgen erneut.", - "pushUpToDate": "„{{name}}“ ist bereits mit YouTube synchronisiert.", - "pushDone": "„{{name}}“ mit YouTube synchronisiert ✓", - "pushPartial": "Synchronisiert, aber {{count}} Element(e) wurden übersprungen.", - "pushFailed": "Synchronisierung mit YouTube fehlgeschlagen.", - "deleteOnYoutubeTitle": "Auch auf YouTube löschen?", - "deleteOnYoutubeMsg": "„{{name}}“ auch aus deinem YouTube-Konto löschen? Wähle „Nur hier“, um sie auf YouTube zu behalten.", - "deleteOnYoutubeConfirm": "Auf YouTube löschen", - "deleteHereOnly": "Nur hier", - "deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt.", - "sortLabel": "Sortierung", - "sortManual": "Eigene Reihenfolge", - "sortTitle": "Titel", - "sortDuration": "Dauer", - "sortChannel": "Kanal", - "dirAsc": "Aufsteigend", - "dirDesc": "Absteigend", - "groupByChannel": "Nach Kanal gruppieren", - "railSortCustom": "Eigene Reihenfolge", - "railSortName": "Name", - "railSortCount": "Anzahl", - "railSortDuration": "Gesamtlänge", - "dirtyFirst": "Nicht synchronisierte zuerst" -} diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json deleted file mode 100644 index c239936..0000000 --- a/frontend/src/i18n/locales/de/plex.json +++ /dev/null @@ -1,229 +0,0 @@ -{ - "navLabel": "Plex", - "backToFeed": "YouTube-Feed", - "backToLibrary": "Zurück zur Bibliothek", - "searchPlaceholder": "Plex durchsuchen…", - "sort": { - "added": "Kürzlich hinzugefügt", - "title": "Titel", - "year": "Jahr", - "rating": "IMDb-Wertung", - "duration": "Länge", - "release": "Erscheinungsdatum" - }, - "filter": { - "scope": "Bibliothek", - "scopeOpt": { - "both": "Alle", - "movie": "Filme", - "show": "Serien" - }, - "show": "Anzeige", - "showOpt": { - "all": "Alle", - "unwatched": "Ungesehen", - "in_progress": "Läuft", - "watched": "Gesehen" - }, - "sort": "Sortierung", - "clearAll": "Filter zurücksetzen", - "active": "Aktiv", - "any": "Alle", - "rating": "IMDb-Wertung", - "genre": "Genre", - "year": "Jahr", - "duration": "Länge", - "durationOpt": { - "short": "Unter 90 Min.", - "mid": "90–120 Min.", - "long": "Über 2 Std." - }, - "added": "Zu Plex hinzugefügt", - "addedOpt": { - "24h": "24 Std.", - "1w": "1 Woche", - "1m": "1 Monat", - "6m": "6 Monate", - "1y": "1 Jahr" - }, - "contentRating": "Altersfreigabe", - "match": { - "any": "Beliebig", - "all": "Alle" - }, - "dir": { - "desc": "↓ Absteigend", - "asc": "↑ Aufsteigend" - }, - "collection": "Sammlung", - "collectionSearch": "Sammlungen suchen…", - "collectionEmpty": "Keine Sammlungen" - }, - "count": "{{count}} Titel", - "searchCount": "{{count}} Treffer", - "noMatches": "Keine Treffer.", - "noMatchesFiltered_one": "Keine Treffer — ein Filter schränkt die Ergebnisse zusätzlich ein.", - "noMatchesFiltered_other": "Keine Treffer — {{count}} Filter schränken die Ergebnisse zusätzlich ein.", - "loading": "Wird geladen…", - "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", - "watched": "Angesehen", - "inProgress": "Läuft", - "play": "Abspielen", - "resume": "Fortsetzen", - "openShow": "Serie öffnen", - "markWatched": "Als gesehen markieren", - "markUnwatched": "Als ungesehen markieren", - "seasons": "{{count}} Staffeln", - "unified": { - "titles": "Titel", - "episodes": "Folgen" - }, - "series": { - "seasons": "Staffeln", - "backToShow": "Zurück zur Serie", - "episodeCount": "{{count}} Folgen", - "playFromStart": "Von Anfang an", - "related": "Ähnliche Serien", - "markShowWatched": "Serie als gesehen", - "markShowUnwatched": "Serie als ungesehen", - "markSeasonWatched": "Staffel als gesehen", - "markSeasonUnwatched": "Staffel als ungesehen", - "addShowCollection": "Zur Sammlung" - }, - "collEditor": { - "manage": "Sammlungen", - "title": "Sammlungen — {{title}}", - "newPlaceholder": "Name der neuen Sammlung…", - "create": "Erstellen", - "search": "Sammlungen filtern…", - "none": "Noch keine bearbeitbaren Sammlungen. Erstelle oben eine.", - "count": "{{count}} Titel", - "delete": "Löschen", - "deleteConfirm": "Sammlung „{{title}}“ aus Plex löschen? Betrifft alle Benutzer.", - "note": "Änderungen werden in Plex geschrieben und für alle Clients/Benutzer sichtbar.", - "others": "{{count}} vorhandene Sammlungen verwalten…", - "makeEditable": "Verwalten" - }, - "player": { - "loading": "Wird geladen…", - "back": "Zurück", - "skipIntro": "Intro überspringen", - "skipCredits": "Abspann überspringen", - "playPause": "Wiedergabe / Pause (Leertaste)", - "restart": "Von vorn beginnen", - "prev": "Vorherige Folge", - "next": "Nächste Folge", - "mute": "Stumm (m)", - "fullscreen": "Vollbild (f)", - "download": "Originaldatei herunterladen", - "tracks": "Audio & Untertitel", - "audio": "Audio", - "subtitles": "Untertitel", - "subOff": "Aus", - "errNotFound": "Diese Datei kann nicht abgespielt werden — die Medien sind auf dem Server nicht erreichbar (die Plex-Medieneinbindung fehlt oder die Pfadzuordnung ist falsch). Bitte den Administrator, die Plex-Konfiguration zu prüfen.", - "errTranscode": "Das Format dieser Datei erfordert eine Transkodierung, die noch nicht unterstützt wird.", - "errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.", - "stop": "Stopp", - "help": "Tastenkürzel", - "infoBtn": "Medieninfo", - "keys": { - "playPause": "Wiedergabe / Pause", - "seek": "10 Sekunden spulen", - "episode": "Vorherige / nächste Folge", - "volume": "Lautstärke lauter / leiser", - "mute": "Stumm", - "fullscreen": "Vollbild", - "info": "Medieninfo", - "back": "Stopp & zurück zum Feed", - "help": "Diese Hilfe anzeigen", - "audio": "Audiospur wechseln", - "subtitle": "Untertitel wechseln", - "fineSeek": "Feines Springen" - }, - "settings": "Einstellungen", - "sync": "Offsets", - "subOffset": "Untertitel-Verzögerung", - "audioOffset": "Audio-Verzögerung", - "playback": "Wiedergabe", - "seekStep": "Sprungweite", - "fineSeek": "Feines Springen (Strg)", - "autoHide": "Steuerung ausblenden", - "subStyle": "Untertitel", - "subSize": "Schriftgröße", - "subPos": "Position", - "subColor": "Textfarbe", - "subBg": "Hintergrund", - "subBgNone": "Keiner", - "clock": "Uhr", - "clockShow": "Uhr anzeigen", - "clock24h": "24-Stunden", - "clockSize": "Größe", - "clockColor": "Farbe", - "clockDate": "Datum anzeigen", - "subShadow": "Schatten", - "subShadowColor": "Schattenfarbe", - "autoSkipIntro": "Intro autom. überspringen", - "autoSkipCredits": "Abspann autom. überspringen", - "autoSkipDelay": "Auto-Skip-Verzögerung" - }, - "info": { - "title": "Medieninfo", - "customize": "Ansicht anpassen", - "prefArtBg": "Dezenter Hintergrund", - "prefCast": "Besetzung", - "prefRelated": "Ähnliche Serien", - "stripSource": { - "collection": "Sammlungen", - "imdb": "IMDb", - "tmdb": "TMDb", - "tvdb": "TVDb", - "trakt": "Trakt", - "smart": "Smart-Listen" - }, - "openImdb": "Bei IMDb öffnen", - "director": "Regie", - "cast": "Besetzung & Crew", - "markWatched": "Als gesehen markieren", - "markUnwatched": "Gesehen zurücknehmen", - "clearResume": "Fortsetzungsposition löschen", - "filterYear": "Titel aus {{year}}", - "filterRating": "Titel mit {{n}}+", - "filterActor": "Titel mit {{name}}", - "browseCollection": "Sammlung durchsuchen →" - }, - "playable": { - "direct": "Spielt direkt im Browser", - "remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)", - "transcode": "Braucht Transcoding (aufwendiger)" - }, - "playlistAdd": { - "manage": "Zur Playlist", - "title": "Zur Playlist — {{title}}", - "newPlaceholder": "Name der neuen Playlist…", - "create": "Erstellen", - "none": "Noch keine Playlists. Erstelle oben eine.", - "count": "{{count}} Titel", - "groupProgress": "{{in}} / {{size}}" - }, - "playlist": { - "section": "Playlists", - "newPlaceholder": "Neue Playlist…", - "create": "Playlist erstellen", - "none": "Noch keine Playlists.", - "deleteConfirm": "Playlist „{{title}}“ löschen?", - "rename": "Umbenennen", - "playAll": "Alle abspielen", - "delete": "Playlist löschen", - "empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.", - "layoutAccordion": "Akkordeon-Ansicht", - "layoutTree": "Baum-Ansicht", - "removeShow": "Ganze Serie entfernen", - "removeSeason": "Ganze Staffel entfernen", - "episodes": "{{count}} Folgen", - "season": "Staffel {{n}}", - "seasonUnknown": "Folgen", - "addShow": "Ganze Serie zu einer Playlist", - "addSeason": "Staffel zu einer Playlist", - "addEpisode": "Folge zu einer Playlist" - } -} diff --git a/frontend/src/i18n/locales/de/quotaActions.json b/frontend/src/i18n/locales/de/quotaActions.json deleted file mode 100644 index 7896ec4..0000000 --- a/frontend/src/i18n/locales/de/quotaActions.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "subscriptions_pull": "Abonnements einlesen", - "subscriptions_resync": "Automatische Abo-Resynchronisierung", - "playlists_pull": "Playlists einlesen", - "playlists_push": "Playlists hochladen", - "playlists_delete": "Playlist löschen", - "videos_backfill_recent": "Aktuelle Uploads laden", - "videos_backfill_full": "Vollständigen Verlauf laden", - "videos_enrich": "Video-Anreicherung", - "videos_lookup": "Video-Abfrage", - "videos_search": "Live-Suche", - "channels_discover": "Kanal-Entdeckung", - "channels_subscribe": "Abonnieren", - "channels_unsubscribe": "Abo beenden", - "maintenance_revalidate": "Wartungs-Neuvalidierung", - "other": "Sonstiges", - "channels_explore": "Kanal erkunden" -} diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json deleted file mode 100644 index f7618d3..0000000 --- a/frontend/src/i18n/locales/de/scheduler.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "title": "Hintergrund-Planer", - "loading": "Planer wird geladen…", - "running": "Läuft", - "paused": "Pausiert", - "notHere": "Läuft in dieser Instanz nicht", - "notHereNote": "Der Planer läuft auf dem Server. Diese Instanz zeigt die gemeinsame Warteschlange und das Kontingent, aber die Live-Job-Aktivität erscheint nur dort, wo der Planer läuft.", - "updating": "wird aktualisiert…", - "pause": "Pausieren", - "resume": "Fortsetzen", - "pauseHint": "Die gesamte Hintergrund-Synchronisierung der App pausieren oder fortsetzen (für alle Nutzer).", - "toggleFailed": "Der Planer-Status konnte nicht geändert werden", - "jobsTitle": "Jobs", - "queueTitle": "Wartende Arbeit", - "quotaTitle": "Gemeinsames API-Kontingent", - "everyMin": "alle {{count}} Min", - "runningNow": "Läuft gerade…", - "lastRun": "Letzter Lauf {{time}}", - "neverRun": "In dieser Sitzung noch nicht gelaufen", - "active": "aktiv", - "nextRun": "nächster", - "quotaUsed": "{{used}} / {{budget}} Einheiten heute verbraucht", - "quotaRemaining": "{{formatted}} übrig", - "quotaNote": "Gemeinsames YouTube-Data-API-Budget; Reset um Mitternacht US-Pazifik. Backfill tritt zurück, um Spielraum für interaktive Nutzung zu lassen.", - "minutes": "Min", - "editInterval": "Klicken, um das Intervall zu ändern", - "intervalFailed": "Intervall konnte nicht geändert werden", - "runNow": "Jetzt ausführen", - "runAll": "Alle jetzt starten", - "runAllHint": "Alle Jobs jetzt ausführen, zusätzlich zu ihren Zeitplänen.", - "runAllPausedHint": "Setze den Planer fort, um Jobs auszuführen (pausiert werden sie übersprungen).", - "triggered": "{{job}} gestartet", - "triggeredAll_one": "{{count}} Job gestartet", - "triggeredAll_other": "{{count}} Jobs gestartet", - "phase": { - "recheck": "Markierte Videos erneut prüfen", - "revalidate": "Videos erneut validieren", - "enrich": "Videos anreichern", - "backfill_recent": "Neue Uploads nachladen", - "backfill_deep": "Ganze Historie nachladen", - "probe": "Shorts klassifizieren", - "rss_poll": "Auf neue Uploads prüfen", - "subscriptions": "Abos synchronisieren", - "autotag": "Kanäle automatisch taggen", - "playlist_sync": "Playlists synchronisieren", - "working": "Arbeitet…" - }, - "maintenance": { - "title": "Wartung", - "batchLabel": "Pro Lauf erneut geprüfte Videos", - "batchHint": "Wie viele am längsten nicht geprüfte Videos der Wartungs-Job pro Lauf erneut validiert. Höher = schnellerer Gesamtzyklus, aber mehr API-Kontingent (1 Einheit je 50 Videos).", - "batchNote": "Standard: {{default}}. ~233k Videos ÷ dieser Wert = Tage für einen vollen Prüfzyklus." - }, - "dot": { - "running": "läuft gerade", - "ok": "letzter Lauf OK", - "idle": "untätig (in dieser Sitzung noch nicht gelaufen)", - "error": "letzter Lauf fehlgeschlagen", - "skipped": "übersprungen (Sync pausiert)" - }, - "jobDesc": { - "rss_poll": "Prüft den RSS-Feed jedes Kanals auf neue Uploads (kostenlos, kein Kontingent). Fällt er aus, erscheinen neue Videos erst später, wenn ein Backfill sie erfasst — der Feed hinkt YouTube hinterher.", - "enrich": "Holt Videodetails (Dauer, Aufrufe, Live-/Shorts-Flags) und prüft Live-Videos erneut, bis sie enden. Fällt er aus, bleiben Videos ohne Dauer/Statistik und beendete Streams hängen als „live“ fest.", - "backfill": "Holt neue Uploads und dann den gesamten Verlauf vorgemerkter Kanäle, innerhalb des gemeinsamen Kontingents. Fällt er aus, werden ältere Videos und der volle Verlauf nicht ergänzt.", - "autotag": "Erkennt Sprache/Themen jedes Kanals und vergibt automatische Tags. Fällt er aus, bekommen neue Kanäle keine Auto-Tags, sodass Sprach-/Themenfilter sie verfehlen.", - "shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.", - "subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.", - "playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.", - "maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.", - "explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.", - "download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.", - "plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync.", - "plex_watch_sync": "Holt kürzliche Plex-Wiedergabeänderungen (beendete Episoden, Fortsetzungspositionen) nach Siftlode und schiebt lokale Wiedergabeänderungen nach, die Plex nicht erreicht haben. Der günstige Zwei-Wege-Abgleich zwischen den vollen Reconciles. Fällt er aus, erscheinen in der Plex-App gesetzte Gesehen-/Fortsetzungsstände hier später.", - "plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt.", - "audit_gc": "Entfernt Audit-Protokolleinträge, die älter als das eingestellte Aufbewahrungsfenster sind (Konfiguration → Audit-Protokoll). Wenn er stoppt, wächst das Protokoll unbegrenzt — harmlos, aber unbeschränkt." - }, - "jobs": { - "rss_poll": "RSS-Abfrage (neue Uploads)", - "enrich": "Anreicherung + Live-Aktualisierung", - "backfill": "Backfill (aktuell + ganze Historie)", - "autotag": "Automatisches Tagging", - "shorts": "Shorts-Klassifizierung", - "subscriptions": "Abo-Resync", - "playlist_sync": "YouTube-Wiedergabelisten-Sync", - "maintenance": "Wartung + Validierung", - "demo_reset": "Demo-Konto-Reset", - "explore_cleanup": "Bereinigung erkundeter Kanäle", - "download_gc": "Download-Bereinigung", - "plex_sync": "Plex-Bibliothek-Sync", - "plex_watch_sync": "Plex-Wiedergabe-Sync (inkrementell)", - "plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)", - "audit_gc": "Audit-Protokoll-Bereinigung" - }, - "queue": { - "recentPending": "Zu synchronisierende Kanäle", - "recentPendingHint": "Kanäle, deren neueste Uploads noch nicht abgerufen wurden.", - "deepPending": "Ganze Historie ausstehend", - "deepPendingHint": "Für vollständigen Verlauf vorgemerkte Kanäle, die noch nicht fertig sind.", - "enrichPending": "Zu anreichernde Videos", - "enrichPendingHint": "Videos, die noch auf Details warten (Dauer, Statistiken, Live-/Short-Klassifizierung).", - "shortsPending": "Zu klassifizierende Shorts", - "shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.", - "liveRefresh": "Live zu aktualisieren", - "liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren." - }, - "purgeDiscovery": "Entdeckung leeren", - "purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.", - "purgeConfirmTitle": "Entdeckungsinhalte löschen?", - "purgedDiscovery": "{{count}} Entdeckungselemente entfernt" -} diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json deleted file mode 100644 index 314bcc1..0000000 --- a/frontend/src/i18n/locales/de/settings.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "title": "Einstellungen", - "tabs": { - "appearance": "Darstellung", - "notifications": "Benachrichtigungen", - "account": "Konto" - }, - "plexSync": { - "title": "Plex-Wiedergabesync", - "intro": "Halte den Status „gesehen / wird gerade angesehen“ zwischen Plex und Siftlode synchron. Beim Einschalten wird dein vorhandener Plex-Verlauf einmalig importiert (Plex gewinnt); die laufende Zwei-Wege-Synchronisierung folgt.", - "toggle": "Zwei-Wege-Sync mit Plex", - "toggleHint": "Nutzt das Plex-Konto dieses Servers. Beim Aktivieren wird dein Plex-Wiedergabeverlauf einmalig importiert.", - "importNow": "Jetzt aus Plex importieren", - "imported": "Aus Plex importiert: {{watched}} gesehen, {{in_progress}} laufend.", - "lastSync": "Letzter Import: {{when}}", - "never": "Noch nicht importiert", - "importing": "Plex-Wiedergabeverlauf wird importiert…" - }, - "save": { - "unsaved": "Nicht gespeicherte Änderungen", - "saving": "Speichern…", - "saved": "Einstellungen gespeichert", - "failed": "Speichern fehlgeschlagen", - "save": "Speichern", - "discard": "Verwerfen" - }, - "unsaved": { - "title": "Nicht gespeicherte Änderungen", - "message": "Du hast nicht gespeicherte Einstellungen. Verwerfen und verlassen?", - "discard": "Änderungen verwerfen" - }, - "appearance": { - "colorScheme": "Farbschema", - "display": "Anzeige", - "darkMode": "Dunkler Modus", - "backgroundImage": "Hintergrundbild", - "backgroundImageHint": "Zeigt ein dezentes, themenbezogenes Hintergrundbild hinter der App, damit die Glasflächen etwas zum Brechen haben. Aus = einfarbig.", - "backgroundFade": "Bildabblendung", - "listView": "Listenansicht", - "listViewHint": "Zeigt den Feed als kompakte Liste statt als Kartenraster an.", - "performanceMode": "Leistungsmodus", - "performanceModeHint": "Schaltet den durchscheinenden Glaseffekt und die weichen Schatten aus, für flüssigere Darstellung auf langsameren Geräten.", - "showHints": "Hinweise anzeigen", - "showHintsHint": "Diese kleinen Hover-Erklärungen. Schalte sie aus, sobald du dich auskennst.", - "textSize": "Schriftgröße" - }, - "notifications": { - "title": "Benachrichtigungen", - "sound": "Ton bei Hinweisen", - "soundHint": "Spielt einen kurzen Ton ab, wenn eine Benachrichtigung deine Aufmerksamkeit braucht (z. B. Fehler, Abfragen).", - "autoDismiss": "Toasts automatisch ausblenden", - "autoDismissHint": "Ausgeschaltet bleiben Pop-up-Toasts, bis du sie schließt. Eingeschaltet blenden sie sich nach der eingestellten Zeit aus.", - "dismissAfter": "Ausblenden nach", - "sendTest": "Testbenachrichtigung senden", - "testTitle": "Testbenachrichtigung", - "testMessage": "So sieht eine Benachrichtigung aus." - }, - "account": { - "title": "Konto", - "youtubeAccess": "YouTube-Zugriff", - "youtubeAccessIntro": "Die Anmeldung teilt nur deinen Namen und deine E-Mail-Adresse. Der YouTube-Zugriff wird separat erteilt, unten — jeder ist optional und jederzeit in deinem Google-Konto widerrufbar.", - "readTitle": "Abos lesen (dein Feed)", - "readGrantedHint": "Erteilt. Siftlode liest die Kanäle, denen du folgst, um deinen Feed aufzubauen. Damit ändert es nie dein YouTube-Konto.", - "readEnableHint": "Nur-Lese-Zugriff auf deine Abos — erforderlich, um deinen Feed aufzubauen. Siftlode kann damit nichts ändern.", - "writeTitle": "Abbestellen (Schreiben)", - "writeGrantedHint": "Erteilt. Du kannst Kanäle auf YouTube von innerhalb Siftlode abbestellen. Es schreibt nur, wenn du es anforderst.", - "writeEnableHint": "Ermöglicht Siftlode, Kanäle auf YouTube für dich abzubestellen. Optional — überspringe es, um im Nur-Lese-Modus zu bleiben.", - "granted": "Erteilt", - "enable": "Aktivieren", - "enableHint": "Leitet zu Google weiter, um die Berechtigung zu erteilen. Google zeigt einen Hinweis „nicht verifizierte App“ — das ist zu erwarten; klicke auf Erweitert → Weiter zu Siftlode.", - "walkMeThrough": "Führe mich durch die Einrichtung", - "dangerZone": "Gefahrenzone", - "deleteHint": "Löscht dein Konto und alle deine Daten dauerhaft — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen. Kann nicht rückgängig gemacht werden.", - "deleteAccount": "Mein Konto löschen", - "deleteTitle": "Konto löschen?", - "deleteConfirm": "Dies löscht dein Konto und alle deine Daten (Abos, Tags, angesehen/gespeichert/ausgeblendet, Playlists, Einstellungen) dauerhaft. Es kann nicht rückgängig gemacht werden.", - "deleteConfirmButton": "Alles löschen", - "demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto.", - "signInMethods": "Anmeldemethoden", - "googleLink": { - "title": "Google-Konto", - "connected": "Verbunden", - "connectedHint": "Dein Google-Konto ist verknüpft. Du kannst dich mit Google anmelden und unten den YouTube-Zugriff erteilen.", - "connectHint": "Verknüpfe ein Google-Konto, um dich mit Google anzumelden und den YouTube-Zugriff für deinen Feed zu aktivieren.", - "connect": "Google verknüpfen", - "linked": "Google-Konto verknüpft.", - "conflict": "Dieses Google-Konto ist bereits mit einem anderen Siftlode-Konto verknüpft.", - "mismatch": "Das ist ein anderes Google-Konto als das bereits hier verknüpfte. Melde dich mit dem verknüpften an.", - "error": "Das Google-Konto konnte nicht verknüpft werden. Bitte versuche es erneut." - }, - "password": { - "title": "Passwort", - "setHint": "Ein Passwort ist gesetzt. Du kannst dich mit E-Mail und Passwort anmelden.", - "unsetHint": "Noch kein Passwort — lege eines fest, um dich mit deiner E-Mail anzumelden (zusätzlich zu oder anstelle von Google).", - "set": "Passwort festlegen", - "change": "Passwort ändern", - "current": "Aktuelles Passwort", - "new": "Neues Passwort (min. 10 Zeichen)", - "save": "Passwort speichern", - "saving": "Speichern…", - "saved": "Passwort für {{email}} aktualisiert.", - "failed": "Das Passwort konnte nicht aktualisiert werden." - }, - "sessions": { - "title": "Aktive Sitzungen", - "hint": "Diese Kontoanmeldung überall sonst abmelden — praktisch, wenn du auf einem geteilten oder verlorenen Gerät angemeldet geblieben bist. Hier bleibst du angemeldet.", - "action": "Andere Sitzungen abmelden", - "confirmTitle": "Andere Sitzungen abmelden?", - "confirmBody": "Damit wirst du in allen anderen Browsern und auf allen anderen Geräten abgemeldet. Hier bleibst du angemeldet.", - "done": "Von allen anderen Sitzungen abgemeldet.", - "failed": "Andere Sitzungen konnten nicht abgemeldet werden. Bitte versuche es erneut." - } - }, - "demo": { - "title": "Demo-Zugang", - "intro": "E-Mails hier können das gemeinsame Demo-Konto direkt von der Anmeldeseite aus betreten — ohne Google-Anmeldung. Sie tippen die Adresse einfach ins Feld und werden nach einem Moment eingelassen.", - "addPlaceholder": "E-Mail für die Demo auf die Whitelist…", - "add": "Hinzufügen", - "added": "{{email}} zur Demo-Whitelist hinzugefügt", - "addFailed": "Diese E-Mail konnte nicht hinzugefügt werden", - "removeFailed": "Diese E-Mail konnte nicht entfernt werden", - "remove": "Entfernen", - "removeHint": "Diese E-Mail von der Demo-Whitelist entfernen.", - "addedBy": "hinzugefügt von {{who}}", - "reset": "Demo-Zustand zurücksetzen", - "resetHint": "Löscht die Playlists, ausgeblendeten/angesehenen/gespeicherten Videos und Einstellungen des Demo-Kontos und legt dann ein paar Beispiel-Playlists neu an.", - "resetTitle": "Demo-Zustand zurücksetzen?", - "resetConfirm": "Dies löscht alles, was das Demo-Konto angesammelt hat — Playlists, ausgeblendete/angesehene/gespeicherte Videos und Einstellungen — und legt ein paar Beispiel-Playlists neu an. Es kann nicht rückgängig gemacht werden.", - "resetDone": "Demo zurückgesetzt — {{count}} Beispiel-Playlist(s) angelegt", - "resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden", - "removed": "Von der Demo-Whitelist entfernt", - "removeTitle": "Demo-Zugang entfernen?", - "removeConfirm": "{{email}} von der Demo-Whitelist entfernen? Der Zugang zum Demo-Konto ist dann nicht mehr möglich." - }, - "invites": { - "title": "Zugriffsanfragen", - "noPending": "Keine ausstehenden Anfragen.", - "addPlaceholder": "E-Mail direkt hinzufügen…", - "add": "Hinzufügen", - "decided": "{{count}} entschieden", - "approved": "{{email}} genehmigt — sie können sich jetzt anmelden", - "approveFailed": "Genehmigung fehlgeschlagen", - "denyFailed": "Ablehnung fehlgeschlagen", - "addedToWhitelist": "{{email}} zur Whitelist hinzugefügt", - "addFailed": "Diese E-Mail konnte nicht hinzugefügt werden", - "requested": "angefragt {{time}}", - "approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.", - "approve": "Genehmigen", - "denyHint": "Diese Anfrage ablehnen.", - "deny": "Ablehnen", - "denied": "Anfrage abgelehnt", - "denyTitle": "Zugriffsanfrage ablehnen?", - "denyConfirm": "Die Zugriffsanfrage von {{email}} ablehnen?" - } -} diff --git a/frontend/src/i18n/locales/de/setup.json b/frontend/src/i18n/locales/de/setup.json deleted file mode 100644 index c869997..0000000 --- a/frontend/src/i18n/locales/de/setup.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "intro": { - "title": "Willkommen — richten wir Siftlode ein", - "body": "Ein paar schnelle Schritte, und deine Instanz ist bereit: Erstelle dein Admin-Konto und verbinde optional die Google-Anmeldung und E-Mail. All das kannst du später in den Admin-Einstellungen ändern." - }, - "admin": { - "title": "Admin-Konto erstellen", - "desc": "Dies ist das erste Konto und ein Administrator. Nach der Einrichtung meldest du dich mit dieser E-Mail und diesem Passwort an.", - "email": "E-Mail-Adresse", - "password": "Passwort (min. {{n}} Zeichen)", - "invalidEmail": "Gib eine gültige E-Mail-Adresse ein.", - "shortPassword": "Das Passwort muss mindestens {{n}} Zeichen lang sein." - }, - "google": { - "title": "Mit Google anmelden (optional)", - "desc": "Füge deine Google-OAuth-Client-ID und das Secret ein, um die Google-Anmeldung und den YouTube-Zugriff zu aktivieren. Beide leer lassen zum Überspringen — E-Mail+Passwort funktioniert auch ohne.", - "clientId": "Google-Client-ID", - "clientSecret": "Google-Client-Secret", - "bothRequired": "Gib Client-ID und Secret ein oder lass beide leer zum Überspringen.", - "skipHint": "Leer lassen, um dies später auf der Admin-Konfigurationsseite einzurichten." - }, - "smtp": { - "title": "E-Mail / SMTP (optional)", - "desc": "Richte einen SMTP-Server ein, damit Siftlode Bestätigungs-, Reset- und Benachrichtigungs-E-Mails senden kann. Host leer lassen zum Überspringen — ohne E-Mail werden Registrierungen stattdessen vom Admin automatisch freigegeben.", - "host": "SMTP-Host (z. B. smtp.gmail.com)", - "port": "Port", - "user": "Benutzername", - "from": "Absenderadresse (optional)", - "password": "SMTP-Passwort", - "testTo": "Test-E-Mail senden an…", - "test": "Test senden", - "testOk": "Test-E-Mail gesendet — sieh im Postfach nach.", - "testFail": "Senden fehlgeschlagen — prüfe die Einstellungen.", - "skipHint": "Leer lassen, um dies später auf der Admin-Konfigurationsseite einzurichten." - }, - "finish": { - "title": "Alles bereit!", - "body": "Klicke auf Fertig, um die Einrichtung abzuschließen. Der Assistent verschwindet und du gelangst zur Anmeldeseite — melde dich mit dem soeben erstellten Admin-Konto an." - }, - "nav": { - "back": "Zurück", - "next": "Weiter", - "finish": "Fertig" - }, - "token": { - "title": "Setup-Link erforderlich", - "body": "Öffne den Assistenten über den Link, der beim ersten Start in den Container-Logs ausgegeben wird (sieht aus wie …/setup?token=…). Er stellt sicher, dass nur der Betreiber diese Instanz einrichten kann." - }, - "genericError": "Etwas ist schiefgelaufen — bitte versuche es erneut." -} diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json deleted file mode 100644 index 7ca94de..0000000 --- a/frontend/src/i18n/locales/de/sidebar.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "filters": "Filter", - "activeCount": "{{count}} aktiv", - "clearAll": "Alle löschen", - "resetDefaults": "Auf Standard zurücksetzen", - "customize": "Seitenleiste anpassen", - "done": "Fertig", - "editHint": "Zum Umordnen ziehen · Auge zum Ein-/Ausblenden", - "channel": "Kanal", - "loading": "Wird geladen…", - "thisChannel": "Dieser Kanal", - "channelCount": "{{count}} Kanal", - "channelCount_other": "{{count}} Kanäle", - "dragToReorder": "Zum Umordnen ziehen", - "showWidget": "Widget einblenden", - "hideWidget": "Widget ausblenden", - "expand": "Ausklappen", - "collapse": "Einklappen", - "collapsePanel": "Filter einklappen", - "expandPanel": "Filter ausklappen", - "any": "Beliebig", - "all": "Alle", - "tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen", - "match": "Treffer", - "custom": "Benutzerdefiniert", - "from": "Von", - "to": "Bis", - "clearDates": "Daten löschen", - "reshuffle": "Neu mischen", - "noMatchingTags": "Keine passenden Tags", - "manageTags": "Verwalten", - "shareView": "Link zu dieser Ansicht kopieren", - "shareCopied": "Ansichts-Link in die Zwischenablage kopiert", - "shareFailed": "Link konnte nicht kopiert werden", - "widget": { - "savedviews": "Gespeicherte Ansichten", - "show": "Anzeigen", - "sort": "Sortierung", - "date": "Upload-Datum", - "content": "Inhaltstyp", - "language": "Sprache", - "topic": "Thema", - "tags": "Deine Tags" - }, - "show": { - "unwatched": "Ungesehen", - "in_progress": "Angefangen", - "all": "Alle", - "watched": "Angesehen", - "saved": "Gespeichert", - "hidden": "Ausgeblendet" - }, - "sort": { - "newest": "Neueste", - "oldest": "Älteste", - "views": "Meistgesehen", - "duration_desc": "Längste", - "duration_asc": "Kürzeste", - "title": "Name (A–Z)", - "subscribers": "Kanal-Abonnenten", - "priority": "Kanal-Priorität", - "shuffle": "Überrasch mich" - }, - "content": { - "normal": "Normal", - "shorts": "Shorts", - "live": "Live / Demnächst" - }, - "datePreset": { - "24h": "24 Std.", - "1week": "1 Woche", - "1month": "1 Monat", - "6months": "6 Monate", - "1year": "1 Jahr" - } -} diff --git a/frontend/src/i18n/locales/de/stats.json b/frontend/src/i18n/locales/de/stats.json deleted file mode 100644 index 3abf5ce..0000000 --- a/frontend/src/i18n/locales/de/stats.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "tabs": { - "overview": "Übersicht", - "system": "System" - }, - "title": "API-Kontingentnutzung", - "rangeDays": "{{count}} T", - "introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ", - "introSystem": "System", - "introAfter": " ist gemeinsame Hintergrundarbeit (geplantes Nachladen, Anreicherung, erneute Synchronisierung), die nicht an eine Person gebunden ist.", - "loading": "Wird geladen…", - "noData": "Keine Daten.", - "dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)", - "noUsageInRange": "Keine Nutzung in diesem Zeitraum.", - "dailyTooltip": "{{day}}: {{units}} Einheiten", - "system": "System (Hintergrund)", - "sync": { - "myStatus": "Mein Synchronisierungsstatus", - "channels": "Kanäle", - "channelsHint": "Wie viele Kanäle du abonniert hast.", - "recentSynced": "Neueste synchronisiert", - "recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.", - "fullHistory": "Vollständiger Verlauf", - "fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).", - "fullHistoryEta": "Vollständiger Verlauf – Restzeit", - "fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.", - "myVideos": "Meine Videos", - "myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.", - "quotaLeft": "Heute verbleibendes Kontingent", - "quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.", - "loading": "Wird geladen…", - "apiUsage": "Deine API-Nutzung", - "today": "Heute", - "todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.", - "last7d": "Letzte 7 Tage", - "last30d": "Letzte 30 Tage", - "allTime": "Gesamt", - "byAction": "Nach Aktion (30 Tage)", - "noUsage": "Noch keine Nutzung.", - "actions": "Aktionen", - "syncSubscriptions": "Abos synchronisieren", - "syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.", - "backfillEverything": "Alles nachladen", - "backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.", - "synced": "{{count}} Abos synchronisiert", - "syncFailed": "Synchronisierung fehlgeschlagen", - "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", - "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", - "admin": "Admin", - "adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).", - "resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen", - "pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren" - } -} diff --git a/frontend/src/i18n/locales/de/tagManager.json b/frontend/src/i18n/locales/de/tagManager.json deleted file mode 100644 index 7042cf1..0000000 --- a/frontend/src/i18n/locales/de/tagManager.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "title": "Tags verwalten", - "channels": "{{count}} Kan.", - "onChannels": "Getaggte Kanäle", - "delete": "Löschen", - "deleted": "Tag „{{name}}“ gelöscht", - "deleteTitle": "Tag löschen", - "confirmDelete": "Tag „{{name}}“ löschen? Er wird von allen Kanälen entfernt, auf denen er liegt.", - "empty": "Noch keine Tags — füge unten einen hinzu.", - "newPlaceholder": "Neuer Tag-Name…", - "add": "Hinzufügen" -} diff --git a/frontend/src/i18n/locales/de/time.json b/frontend/src/i18n/locales/de/time.json deleted file mode 100644 index 80a00f7..0000000 --- a/frontend/src/i18n/locales/de/time.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "justNow": "gerade eben", - "secondsAgo": "vor {{count}} Sek.", - "minutesAgo": "vor {{count}} Min.", - "hoursAgo": "vor {{count}} Std.", - "daysAgo": "vor {{count}} T.", - "weeksAgo": "vor {{count}} Wo.", - "monthsAgo": "vor {{count}} Mon.", - "yearsAgo": "vor {{count}} J.", - "eta": { - "now": "jetzt", - "done": "fertig", - "lessThanHour": "< 1 Stunde", - "hours_one": "~{{count}} Stunde", - "hours_other": "~{{count}} Stunden", - "days_one": "~{{count}} Tag", - "days_other": "~{{count}} Tage" - } -} diff --git a/frontend/src/i18n/locales/de/users.json b/frontend/src/i18n/locales/de/users.json deleted file mode 100644 index e45c288..0000000 --- a/frontend/src/i18n/locales/de/users.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "tabs": { - "roles": "Benutzer & Rollen", - "access": "Zugriffsanfragen", - "demo": "Demo" - }, - "roles": { - "title": "Benutzer & Rollen", - "intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.", - "empty": "Noch keine Benutzer.", - "you": "du", - "admin": "Admin", - "demo": "Demo", - "suspended": "Gesperrt", - "pending": "Wartet auf Freigabe", - "unverified": "E-Mail nicht bestätigt", - "demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.", - "selfLocked": "Du kannst deine eigene Rolle nicht ändern.", - "makeAdmin": "Zum Admin machen", - "makeUser": "Zum Benutzer machen", - "updated": "Rolle aktualisiert für {{email}}", - "promoteTitle": "Zum Admin machen?", - "demoteTitle": "Admin entfernen?", - "promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?", - "demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer." - }, - "suspend": { - "action": "Sperren", - "undoAction": "Entsperren", - "title": "Konto sperren?", - "undoTitle": "Konto entsperren?", - "confirm": "{{email}} sperren? Eine Anmeldung (per Passwort oder Google) ist erst nach dem Entsperren wieder möglich, und jede aktive Sitzung endet.", - "undoConfirm": "{{email}} entsperren? Eine Anmeldung ist dann wieder möglich.", - "done": "{{email}} gesperrt", - "undone": "{{email}} entsperrt", - "demoLocked": "Das Demo-Konto kann nicht gesperrt werden.", - "selfLocked": "Du kannst dein eigenes Konto nicht sperren." - }, - "delete": { - "action": "Löschen", - "locked": "Dieses Konto kann hier nicht gelöscht werden.", - "title": "Konto löschen?", - "confirm": "{{email}} und alle zugehörigen Daten endgültig löschen — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen? Das kann nicht rückgängig gemacht werden.", - "done": "{{email}} gelöscht" - } -} diff --git a/frontend/src/i18n/locales/de/views.json b/frontend/src/i18n/locales/de/views.json deleted file mode 100644 index a176243..0000000 --- a/frontend/src/i18n/locales/de/views.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "save": "Aktuelle Ansicht speichern", - "namePlaceholder": "Name der Ansicht", - "saveAction": "Speichern", - "cancel": "Abbrechen", - "empty": "Noch keine gespeicherten Ansichten. Filtere den Feed und speichere ihn.", - "apply": "Diese Ansicht anwenden", - "makeDefault": "Als Standard festlegen (wird beim Start geladen)", - "isDefault": "Standardansicht — wird beim Start geladen", - "share": "Link zu dieser Ansicht kopieren", - "shareCopied": "Ansichtslink kopiert", - "rename": "Umbenennen", - "delete": "Löschen", - "deleteConfirm": "Die gespeicherte Ansicht „{{name}}“ löschen?", - "reorder": "Zum Neuordnen ziehen" -} diff --git a/frontend/src/i18n/locales/de/welcome.json b/frontend/src/i18n/locales/de/welcome.json deleted file mode 100644 index 191ba12..0000000 --- a/frontend/src/i18n/locales/de/welcome.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "hero": { - "title": "Deine YouTube-Abos, so wie ein Feed funktionieren sollte.", - "subtitle": "Jeder Upload der Kanäle, denen du folgst, in einem aufgeräumten, filterbaren Feed — kein Algorithmus entscheidet, was du siehst, und kein Shorts- oder Livestream-Lärm, außer du willst es.", - "trust": "Selbst gehostet und privat. Melde dich per E-Mail an oder fahre mit Google fort." - }, - "openSource": { - "title": "Kostenlos und quelloffen", - "body": "Der Code von Siftlode ist öffentlich — sieh nach, wie es funktioniert, betreibe deine eigene private Instanz oder melde ein Problem.", - "link": "Quellcode ansehen" - }, - "preview": { - "feed": "Der Feed — filtern nach Kanal, Sprache, Thema, Länge und Datum.", - "channels": "Kanal-Verwaltung", - "playlists": "Wiedergabelisten" - }, - "features": { - "heading": "Warum Siftlode", - "readable": { - "title": "Deine Abos, endlich lesbar", - "body": "Sortiere und filtere nach Kanal, Tag, Sprache, Thema, Länge oder Datum und speichere deine Einstellungen als Ansichten. Du entscheidest, was erscheint, nicht ein Algorithmus." - }, - "neverMiss": { - "title": "Verpasse keinen Upload", - "body": "Jedes Video jedes Kanals landet in einem Feed; Shorts und Livestreams bleiben standardmäßig ausgeblendet." - }, - "search": { - "title": "Durchsuche ganz YouTube", - "body": "Durchsuche YouTube direkt aus deinem Feed — Ergebnisse lassen sich abspielen, speichern und wie jedes andere Video zu Playlists hinzufügen." - }, - "playlists": { - "title": "Playlists, die in beide Richtungen synchronisieren", - "body": "Erstelle Playlists lokal und halte sie in beidseitiger Synchronisation mit YouTube." - }, - "player": { - "title": "Schau in der App, mach überall weiter", - "body": "Ein In-App-Player setzt jedes Video genau dort fort, wo du aufgehört hast — kein Empfehlungs-Strudel." - }, - "private": { - "title": "Selbst gehostet und privat", - "body": "Betreibe es selbst, mehrbenutzerfähig, auf Englisch, Ungarisch und Deutsch. Deine Daten bleiben deine." - } - }, - "auth": { - "signinTitle": "Anmelden", - "createTitle": "Konto erstellen", - "forgotTitle": "Passwort zurücksetzen", - "resetTitle": "Neues Passwort festlegen", - "email": "E-Mail", - "password": "Passwort", - "newPassword": "Neues Passwort (min. {{min}} Zeichen)", - "invalidEmail": "Gib eine gültige E-Mail-Adresse ein.", - "weakPassword": "Das Passwort muss mindestens {{min}} Zeichen lang sein.", - "genericError": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", - "working": "Einen Moment…", - "signinButton": "Anmelden", - "createButton": "Konto erstellen", - "forgotButton": "Reset-Link senden", - "resetButton": "Passwort festlegen", - "createLink": "Konto erstellen", - "forgotLink": "Passwort vergessen?", - "backToSignin": "Zurück zur Anmeldung", - "or": "oder", - "google": "Mit Google fortfahren", - "tryDemo": "Demo ausprobieren", - "demoHint": "Gib eine E-Mail ein, die für das Demo-Konto freigeschaltet ist.", - "demoEmail": "Demo-E-Mail", - "demoEnter": "Eintreten", - "demoDenied": "Diese E-Mail ist nicht für die Demo freigeschaltet. Bitte den Admin, sie hinzuzufügen.", - "registerDone": "Fast geschafft — prüfe deine E-Mails auf den Bestätigungslink. Nach der Bestätigung gibt ein Admin dein Konto frei, bevor du dich anmelden kannst.", - "forgotDone": "Falls zu dieser E-Mail ein Konto gehört, haben wir einen Reset-Link gesendet. Prüfe dein Postfach.", - "resetDone": "Dein Passwort wurde festgelegt — du kannst dich jetzt anmelden.", - "verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.", - "verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.", - "accessRequested": "Danke — wir haben deine Anfrage erfasst. Ein Admin wird sie prüfen.", - "accessDenied": "Dieses Konto ist für diese Instanz noch nicht freigegeben — registriere dich unten oder bitte den Admin um Zugang.", - "suspended": "Dieses Konto wurde gesperrt und kann sich nicht anmelden. Falls das ein Irrtum ist, wende dich an den Betreiber (Details in deiner E-Mail).", - "deleted": "Dein Konto und alle Daten wurden endgültig gelöscht. Eine Bestätigung haben wir dir per E-Mail geschickt." - } -} diff --git a/frontend/src/i18n/locales/en/welcome.json b/frontend/src/i18n/locales/en/welcome.json index 9c8f6a9..40daa11 100644 --- a/frontend/src/i18n/locales/en/welcome.json +++ b/frontend/src/i18n/locales/en/welcome.json @@ -38,7 +38,7 @@ }, "private": { "title": "Self-hosted and private", - "body": "Run it yourself, multi-user, in English, Hungarian and German. Your data stays yours." + "body": "Run it yourself, multi-user, in English and Hungarian. Your data stays yours." } }, "auth": { diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json index 216e5dd..03a4689 100644 --- a/frontend/src/i18n/locales/hu/welcome.json +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -38,7 +38,7 @@ }, "private": { "title": "Self-hosted és privát", - "body": "Futtasd magad, többfelhasználós, angolul, magyarul és németül. Az adataid a tieid maradnak." + "body": "Futtasd magad, többfelhasználós, angolul és magyarul. Az adataid a tieid maradnak." } }, "auth": { From 7f358f63e3d13bb118837b6d52c1d8dbf8ef4549 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 13 Jul 2026 05:13:18 +0200 Subject: [PATCH 2/4] feat(notifications): coalesce identical notifications with a repeat count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold a burst of identical notifications (same level+title+message within a short window) into one entry with a ×N count instead of stacking copies — tames a crash-retry loop or a repeatedly-failing poll. Shown in the toast and centre. --- .../src/components/NotificationsPanel.tsx | 9 +++- frontend/src/components/Toaster.tsx | 9 +++- frontend/src/lib/notifications.ts | 50 +++++++++++++++++-- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 07fcf0e..4c14a12 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -320,7 +320,14 @@ function ClientActivityRow({
{n.title &&
{n.title}
} -
{n.message}
+
+ {n.message} + {n.repeat > 1 && ( + + ×{n.repeat} + + )} +
{relativeFromMs(n.ts)} {needsAction && ( diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index 0b94953..4f4af84 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -42,7 +42,14 @@ export default function Toaster() {
{toast.title &&
{toast.title}
} -
{toast.message}
+
+ {toast.message} + {toast.repeat > 1 && ( + + ×{toast.repeat} + + )} +
{toast.action && ( - - - ); -} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9ee22d1..57d7d20 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -103,7 +103,9 @@ export default function Header({ } return ( -
+ // Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) + + // filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change. +
s !== "done").length; + // Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook, + // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. + const listFade = useScrollFade(); + type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift @@ -268,7 +273,7 @@ export default function NavSidebar({ return (
-
+
{userItems.map(renderItem)} {systemItems.length > 0 && (collapsed ? ( diff --git a/frontend/src/components/PanelGroup.tsx b/frontend/src/components/PanelGroup.tsx new file mode 100644 index 0000000..56cb359 --- /dev/null +++ b/frontend/src/components/PanelGroup.tsx @@ -0,0 +1,84 @@ +import { type ReactNode, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse, +// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from +// the feed sidebar's WidgetCard so all three panels share the same island look and behavior. +export default function PanelGroup({ + id, + title, + editing, + collapsed, + hidden, + onToggleCollapse, + onToggleHidden, + children, +}: { + id: string; + title: string; + editing: boolean; + collapsed: boolean; + hidden: boolean; + onToggleCollapse: () => void; + onToggleHidden: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled: !editing, + }); + const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition }; + const showBody = !editing && !collapsed; + + return ( +
+
+ {editing && ( + + )} + + {editing ? ( + + ) : ( + + )} +
+ {showBody &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/PanelGroups.tsx new file mode 100644 index 0000000..be4d270 --- /dev/null +++ b/frontend/src/components/PanelGroups.tsx @@ -0,0 +1,80 @@ +import { type ReactNode } from "react"; +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import type { PanelLayout } from "../lib/panelLayout"; +import PanelGroup from "./PanelGroup"; + +export type PanelItem = { id: string; title: string; body: ReactNode }; + +// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group +// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE +// groups (a caller drops facet-gated / empty ones); the layout order is filtered to them so a +// stored order that references a now-absent group is tolerated. Reordering/hiding is active only +// in `editing` mode. Shared by all three side panels. +export default function PanelGroups({ + items, + layout, + setLayout, + editing, +}: { + items: PanelItem[]; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + editing: boolean; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const byId = new Map(items.map((it) => [it.id, it])); + + const orderedAvailable = layout.order.filter((id) => byId.has(id)); + // Append any available id missing from the stored order (e.g. a group that became available + // after the layout was saved) so it never silently disappears. + for (const it of items) if (!orderedAvailable.includes(it.id)) orderedAvailable.push(it.id); + const renderedIds = editing + ? orderedAvailable + : orderedAvailable.filter((id) => !layout.hidden[id]); + + function toggleCollapse(id: string) { + setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); + } + function toggleHidden(id: string) { + setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); + } + function onDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + const from = layout.order.indexOf(active.id as string); + const to = layout.order.indexOf(over.id as string); + if (from < 0 || to < 0) return; + setLayout({ ...layout, order: arrayMove(layout.order, from, to) }); + } + + return ( + + +
+ {renderedIds.map((id) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index ee7f9b3..bb48586 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,6 +1,6 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { DndContext, PointerSensor, @@ -23,9 +23,7 @@ import { GripVertical, ListPlus, Pencil, - Pin, Play, - Plus, RefreshCw, RotateCcw, Trash2, @@ -33,11 +31,10 @@ import { X, Youtube, } from "lucide-react"; -import { api, type Playlist, type Video } from "../lib/api"; +import { api, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; @@ -95,9 +92,6 @@ const idsKey = (items: Video[]) => .sort() .join(","); -type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean }; -const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; - function Row({ video, index, @@ -185,31 +179,18 @@ function Row({ export default function Playlists({ canWrite, - search, + selectedId, + setSelectedId, }: { canWrite: boolean; - // The playlist-name filter, from the shared top SearchBar (App-owned state). - search: string; + // The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the + // list + its rail controls). This detail pane reacts to the selection. + selectedId: number | null; + setSelectedId: (id: number | null) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). - const [selectedId, setSelectedId] = useState(() => { - const s = getAccountRaw(LS.playlist); - return s ? Number(s) : null; - }); - useEffect(() => { - if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId)); - }, [selectedId]); - const selectedRef = useRef(null); - const scrolledRef = useRef(false); - // How the left playlist rail is ordered (persisted). - const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); - useEffect(() => { - writeAccount(LS.plSort, plSort); - }, [plSort]); - const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); // The item order is undoable: drag, sort and group all go through `order.set`, so each is @@ -244,21 +225,6 @@ export default function Playlists({ }); const playlists = listQuery.data ?? []; - // Keep the selection valid: default to the first playlist, and if the selected one - // disappears (e.g. just deleted) drop to the next available, or clear when none remain. - useEffect(() => { - // Wait for the first load before adjusting the selection — otherwise the empty - // placeholder list would null the restored selection and we'd fall back to the first. - if (!listQuery.data) return; - if (!playlists.length) { - if (selectedId != null) setSelectedId(null); - return; - } - if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { - setSelectedId(playlists[0].id); - } - }, [playlists, selectedId, listQuery.data]); - const detailQuery = useQuery({ queryKey: ["playlist", selectedId], queryFn: () => api.playlist(selectedId as number), @@ -308,48 +274,11 @@ export default function Playlists({ const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; - // The left rail in the chosen order. "custom" keeps the server position order (Array.sort - // is stable); "dirty first" floats playlists with unpushed edits to the top. - const sortedPlaylists = useMemo(() => { - const sign = plSort.dir === "asc" ? 1 : -1; - return [...playlists].sort((a, b) => { - if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; - switch (plSort.key) { - case "name": - return sign * plName(a).localeCompare(plName(b)); - case "count": - return sign * (a.item_count - b.item_count); - case "duration": - return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); - default: - return 0; // custom: server order - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playlists, plSort]); - - // Client-side name filter driven by the shared top SearchBar. - const q = search.trim().toLowerCase(); - const visiblePlaylists = useMemo( - () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), - // eslint-disable-next-line react-hooks/exhaustive-deps - [sortedPlaylists, q] - ); - - // Scroll the restored selection into view once after the rail first renders. - useEffect(() => { - if (!scrolledRef.current && selectedRef.current) { - selectedRef.current.scrollIntoView({ block: "nearest" }); - scrolledRef.current = true; - } - }, [sortedPlaylists]); - const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin. const editable = !builtin; const linked = editable && !!detail?.yt_playlist_id; - const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); const [reverting, setReverting] = useState(false); @@ -428,30 +357,6 @@ export default function Playlists({ } } - async function syncYoutube() { - if (syncing) return; - setSyncing(true); - try { - const r = await api.syncYoutubePlaylists(); - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist"] }); - notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); - } catch (e) { - notifyYouTubeActionError(e, t("playlists.syncFailed")); - } finally { - setSyncing(false); - } - } - - const createMut = useMutation({ - mutationFn: (name: string) => api.createPlaylist(name), - onSuccess: (pl: Playlist) => { - setNewName(""); - qc.invalidateQueries({ queryKey: ["playlists"] }); - setSelectedId(pl.id); - }, - }); - function refreshAll() { qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); @@ -538,123 +443,7 @@ export default function Playlists({ } return ( -
- - -
+
{selectedId == null || !detail ? (
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} @@ -869,7 +658,6 @@ export default function Playlists({ )} )} -
{playingIndex != null && items[playingIndex] && ( diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx new file mode 100644 index 0000000..534f35e --- /dev/null +++ b/frontend/src/components/PlaylistsRail.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; +import { api, type Playlist } from "../lib/api"; +import { formatDuration } from "../lib/format"; +import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; +import { LS, readAccountMerged, writeAccount } from "../lib/storage"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; + +type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean }; +const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; + +// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the +// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail +// controls (sort / new / sync); the selected playlist is App state so the module's detail pane +// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. +export default function PlaylistsRail({ + canWrite, + search, + selectedId, + setSelectedId, + layout, + setLayout, + collapsed, + onToggleCollapse, +}: { + canWrite: boolean; + search: string; + selectedId: number | null; + setSelectedId: (id: number | null) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); + useEffect(() => { + writeAccount(LS.plSort, plSort); + }, [plSort]); + const [newName, setNewName] = useState(""); + const [syncing, setSyncing] = useState(false); + const [editing, setEditing] = useState(false); + const selectedRef = useRef(null); + const scrolledRef = useRef(false); + + const listQuery = useQuery({ + queryKey: ["playlists"], + queryFn: () => api.playlists(), + refetchOnWindowFocus: true, + }); + const playlists = listQuery.data ?? []; + + // Keep the selection valid: default to the first playlist, and if the selected one disappears + // drop to the next available (or clear when none remain). Waits for the first load. + useEffect(() => { + if (!listQuery.data) return; + if (!playlists.length) { + if (selectedId != null) setSelectedId(null); + return; + } + if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { + setSelectedId(playlists[0].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, selectedId, listQuery.data]); + + const plName = (p: { kind: string; name: string }) => + p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + + const sortedPlaylists = useMemo(() => { + const sign = plSort.dir === "asc" ? 1 : -1; + return [...playlists].sort((a, b) => { + if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; + switch (plSort.key) { + case "name": + return sign * plName(a).localeCompare(plName(b)); + case "count": + return sign * (a.item_count - b.item_count); + case "duration": + return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); + default: + return 0; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, plSort]); + + const q = search.trim().toLowerCase(); + const visiblePlaylists = useMemo( + () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sortedPlaylists, q] + ); + + useEffect(() => { + if (!scrolledRef.current && selectedRef.current) { + selectedRef.current.scrollIntoView({ block: "nearest" }); + scrolledRef.current = true; + } + }, [sortedPlaylists]); + + async function syncYoutube() { + if (syncing) return; + setSyncing(true); + try { + const r = await api.syncYoutubePlaylists(); + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); + } catch (e) { + notifyYouTubeActionError(e, t("playlists.syncFailed")); + } finally { + setSyncing(false); + } + } + + const createMut = useMutation({ + mutationFn: (name: string) => api.createPlaylist(name), + onSuccess: (pl: Playlist) => { + setNewName(""); + qc.invalidateQueries({ queryKey: ["playlists"] }); + setSelectedId(pl.id); + }, + }); + + const items: PanelItem[] = [ + { + id: "manage", + title: t("playlists.manageGroup"), + body: ( +
+ + {playlists.length > 1 && ( +
+ + + +
+ )} +
{ + e.preventDefault(); + if (newName.trim()) createMut.mutate(newName.trim()); + }} + className="flex items-center gap-1.5" + > + + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + disabled={!canWrite} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent disabled:opacity-50" + /> + +
+ ), + }, + { + id: "list", + title: t("playlists.listGroup"), + body: ( + <> + {playlists.length === 0 && !listQuery.isLoading && ( +
{t("playlists.noneYet")}
+ )} + {playlists.length > 0 && visiblePlaylists.length === 0 && ( +
{t("playlists.noMatches")}
+ )} +
+ {visiblePlaylists.map((pl) => ( + + ))} +
+ + ), + }, + ]; + + return ( + } + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("playlists"))} + editHint={t("sidebar.editHint")} + > + + + ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index bdd8f47..40c0117 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,16 +1,17 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; -// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the -// full metadata filter set (genre / rating / year / duration / added / content rating + the -// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State -// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the -// backend so the sidebar only offers what the library actually contains. +// The Plex module's left filter column — now the shared floating SidePanel with its filter groups +// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata +// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from +// the backend so the panel only offers what the library actually contains. type Props = { scope: string; // movie | show | both (unified cross-library scope) @@ -22,17 +23,17 @@ type Props = { filters: PlexFilters; setFilters: (f: PlexFilters) => void; onOpenPlaylist: (id: number) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; -// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime). const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const; const RATING_STEPS = [5, 6, 7, 8, 9]; const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; -// Duration buckets → [minSeconds|null, maxSeconds|null]. const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [ { key: "short", min: null, max: 90 * 60 }, { key: "mid", min: 90 * 60, max: 120 * 60 }, @@ -49,12 +50,15 @@ export default function PlexSidebar({ filters, setFilters, onOpenPlaylist, + layout, + setLayout, collapsed, onToggleCollapse, }: Props) { const { t } = useTranslation(); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); + const [editing, setEditing] = useState(false); const qc = useQueryClient(); async function createPlaylist() { const title = newPlaylist.trim(); @@ -64,29 +68,18 @@ export default function PlexSidebar({ qc.invalidateQueries({ queryKey: ["plex-playlists"] }); onOpenPlaylist(pl.id); } - // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters - // (faceted search) — so picking one filter narrows what the others offer. Refetches on any change. - // Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets, - // so keying on the whole `filters` object would re-run the heavy facet computation on every - // sort-direction toggle (or chip-title change) for an identical request. const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ queryKey: ["plex-facets", scope, show, facetKey], queryFn: () => api.plexFacets(scope, filters, show), enabled: !!scope, - placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker) + placeholderData: (prev) => prev, }); const facets = facetsQ.data; - // Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the - // unified scope has no single library). Only fetched while no collection is active (else the chip - // shows instead). Applying one sets the collection filter; the actual filtering already flows through - // /library + /facets by rating_key. const [collSearch, setCollSearch] = useState(""); const collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ - // "union" disambiguates from the editor's per-library ["plex-collections", ] key (a search - // term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working. queryKey: ["plex-collections", "union", collSearchDeb], queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), enabled: !filters.collection, @@ -106,112 +99,83 @@ export default function PlexSidebar({ setSort("title"); }; - // Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime). - // Ordered alphabetically by their localized label. const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) => t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), ); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; + const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0; - if (collapsed) { - return ; - } - - return ( -
+ ), + }, + { + id: "rating", + title: t("plex.filter.rating"), + body: ( + + patch({ ratingMin: null })}> + {t("plex.filter.any")} + + {RATING_STEPS.map((r) => ( + patch({ ratingMin: r })}> + {r}+ + + ))} + + ), + }, + ...(facets && facets.genres.length > 0 + ? [ + { + id: "genre", + title: t("plex.filter.genre"), + body: ( + <> + {filters.genres.length > 1 && ( +
+ {(["any", "all"] as const).map((m) => ( + + ))} +
+ )} + + {facets.genres.map((g) => ( + patch({ genres: toggleIn(filters.genres, g.value) })} > - {t(`plex.filter.match.${m}`)} - + {g.value} + ))} -
- )} - - {facets.genres.map((g) => ( - patch({ genres: toggleIn(filters.genres, g.value) })} - > - {g.value} - - ))} - - - )} - - {/* Year range */} -
-
- patch({ yearMin: v })} - /> - - patch({ yearMax: v })} - /> -
-
- - {/* Duration buckets — movie-only (a show has no single runtime) */} - {scope === "movie" && ( -
+ + + ), + }, + ] + : []), + { + id: "year", + title: t("plex.filter.year"), + body: ( +
+ patch({ yearMin: v })} + /> + + patch({ yearMax: v })} + /> +
+ ), + }, + ...(scope === "movie" + ? [ + { + id: "duration", + title: t("plex.filter.duration"), + body: ( ))} -
- )} - - {/* Added to Plex */} -
- - patch({ addedWithin: "" })}> - {t("plex.filter.any")} - - {ADDED_OPTS.map((a) => ( - patch({ addedWithin: a })}> - {t(`plex.filter.addedOpt.${a}`)} - - ))} - -
- - {/* Content rating (from facets) */} - {facets && facets.content_ratings.length > 0 && ( -
+ ), + }, + ] + : []), + { + id: "added", + title: t("plex.filter.added"), + body: ( + + patch({ addedWithin: "" })}> + {t("plex.filter.any")} + + {ADDED_OPTS.map((a) => ( + patch({ addedWithin: a })}> + {t(`plex.filter.addedOpt.${a}`)} + + ))} + + ), + }, + ...(facets && facets.content_ratings.length > 0 + ? [ + { + id: "contentrating", + title: t("plex.filter.contentRating"), + body: ( {facets.content_ratings.map((c) => ( ))} -
- )} - - )} - - ); -} + ), + }, + ] + : []), + ]; -function Section({ label, children }: { label: string; children: ReactNode }) { return ( -
-
{label}
- {children} -
+ } + count={activeCount} + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("plex"))} + editHint={t("sidebar.editHint")} + actions={ + anyActive ? ( + + ) : undefined + } + > + + ); } diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx new file mode 100644 index 0000000..5ca0593 --- /dev/null +++ b/frontend/src/components/SidePanel.tsx @@ -0,0 +1,127 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronLeft, Pencil, RotateCcw } from "lucide-react"; +import { useScrollFade } from "../lib/useScrollFade"; + +// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read +// as one system: a rounded glass card that floats beside the nav (aligned with the header top, +// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a +// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses +// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav). +export default function SidePanel({ + title, + icon, + count = 0, + collapsed, + onToggleCollapse, + editing = false, + onToggleEditing, + onReset, + editHint, + actions, + children, +}: { + title: string; + icon: ReactNode; + count?: number; + collapsed: boolean; + onToggleCollapse: () => void; + // Customize (reorder/hide) mode — omit onToggleEditing to hide the pencil entirely. + editing?: boolean; + onToggleEditing?: () => void; + onReset?: () => void; + editHint?: string; + // Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view). + actions?: ReactNode; + children: ReactNode; +}) { + const { t } = useTranslation(); + const fade = useScrollFade(); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index eec0175..3b0ee85 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,47 +1,16 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - Check, - ChevronDown, - ChevronLeft, - Eye, - EyeOff, - GripVertical, - Library, - Pencil, - RotateCcw, - Share2, - User, - X, -} from "lucide-react"; -import { - closestCenter, - DndContext, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +import { Library, Pencil, Share2, SlidersHorizontal, User, X } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; -import { - DEFAULT_LAYOUT, - type SidebarLayout, - type WidgetId, -} from "../lib/sidebarLayout"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import TagManager from "./TagManager"; import SavedViewsWidget from "./SavedViewsWidget"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -110,12 +79,10 @@ export default function Sidebar({ }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; - layout: SidebarLayout; - setLayout: (l: SidebarLayout) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; onFocusChannel: (name: string) => void; isDemo?: boolean; - // Full-height collapse (state owned by App, persisted to the user's preferences), mirroring - // the left nav rail. collapsed: boolean; onToggleCollapse: () => void; }) { @@ -123,11 +90,8 @@ export default function Sidebar({ const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; - // Live per-tag channel counts for the current filter context (scope, channel, date, - // search, watch state, other category's tags). Lets us show contextual counts and hide - // chips that no longer match anything. Keyed on filters so it refetches as they change. - // Debounce the search term (matching the feed) so typing doesn't refire facets per - // keystroke, and keep the previous counts on screen during a refetch so chips don't flicker. + // Live per-tag channel counts for the current filter context. Debounce the search term so + // typing doesn't refire facets per keystroke, and keep previous counts during a refetch. const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetsQuery = useQuery({ queryKey: ["facets", facetFilters], @@ -137,12 +101,8 @@ export default function Sidebar({ }); const facetsReady = !!facetsQuery.data; const facetCounts = facetsQuery.data?.counts ?? {}; - // Before facets load, fall back to the static global count so chips don't flash empty. const chipCount = (tag: Tag): number => facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; - // Visible chips: hide zero-count ones once facets are in, but always keep selected ones - // so an active filter can still be cleared. Sorted by count (largest first), then name — - // so scanning top→bottom the smallest counts end up at the very bottom. const visibleChips = (list: Tag[]): Tag[] => { const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) @@ -152,10 +112,6 @@ export default function Sidebar({ ); }; - // After a page refresh the channel name isn't in the URL (only the id is), so - // filters.channelName is undefined and the chip would fall back to "This channel". - // Resolve the title from the (cached) channel list keyed by id. Only fetch when a - // channel filter is active but its name is missing. const needChannelName = !!filters.channelId && !filters.channelName; const channelsQuery = useQuery({ queryKey: ["channels"], @@ -167,7 +123,6 @@ export default function Sidebar({ filters.channelName ?? channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ?? undefined; - // Don't flash the misleading "This channel" fallback while the name is still resolving. const channelChipLabel = resolvedChannelName ?? (needChannelName && channelsQuery.isLoading @@ -175,18 +130,11 @@ export default function Sidebar({ : t("sidebar.thisChannel")); const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); - // The user's own (non-system) tags — those assigned to channels in the Channel manager. - // Facets cover them too now (the "other" category), so they're contextual like the rest: - // zero-count chips hide once facets load, counts reflect the current filter. const userTags = tags.filter((t) => !t.system); const [customDates, setCustomDates] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false); const [editing, setEditing] = useState(false); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) - ); - function toggleTag(id: number) { const has = filters.tags.includes(id); setFilters({ @@ -198,17 +146,12 @@ export default function Sidebar({ const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo; const contentChanged = !filters.includeNormal || filters.includeShorts || filters.includeLive; - // Library (vs the default "my"/Mine) and a non-default provenance Source both narrow the feed, - // so they count as active filters too (Source only applies in Library scope). The header search - // `q` deliberately stays out — it has its own clear (✕) in the search box and isn't reset here. const scopeActive = filters.scope === "all"; const sourceActive = filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic"; const activeCount = filters.tags.length + (filters.show !== "unwatched" ? 1 : 0) + - // "relevance" is auto-applied while searching (no manual dropdown option), so it's a search - // artifact, not a user-chosen sort filter — don't let typing a query inflate the count. (filters.sort !== "newest" && filters.sort !== "relevance" ? 1 : 0) + (contentChanged ? 1 : 0) + (filters.channelId ? 1 : 0) + @@ -228,15 +171,10 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - // Reset every filter dimension to its default — including scope back to "my" (Mine) and the - // provenance Source (dropped with the rest, so it falls back to "organic"). The search `q` is - // intentionally preserved (it has its own ✕ in the search box). setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" }); } - const available: Record = { - // Saved views are personal state; hide the widget for the shared demo account (the API - // also rejects the writes via require_human). + const available: Record = { savedviews: !isDemo, date: true, language: languages.length > 0, @@ -244,22 +182,7 @@ export default function Sidebar({ tags: userTags.length > 0, }; - function toggleCollapse(id: WidgetId) { - setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); - } - function toggleHidden(id: WidgetId) { - setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); - } - function onDragEnd(e: DragEndEvent) { - const { active: a, over } = e; - if (!over || a.id === over.id) return; - const oldIndex = layout.order.indexOf(a.id as WidgetId); - const newIndex = layout.order.indexOf(over.id as WidgetId); - if (oldIndex < 0 || newIndex < 0) return; - setLayout({ ...layout, order: arrayMove(layout.order, oldIndex, newIndex) }); - } - - function widgetBody(id: WidgetId): React.ReactNode { + function widgetBody(id: string): ReactNode { switch (id) { case "savedviews": return ; @@ -447,23 +370,51 @@ export default function Sidebar({
); } + default: + return null; } } - const orderedAvailable = layout.order.filter((id) => available[id]); - const renderedIds = editing - ? orderedAvailable - : orderedAvailable.filter((id) => !layout.hidden[id]); + // Group ids come from the shared default order (panelLayout) so there's one source of truth; + // the actual render order is the user's saved layout (PanelGroups orders by it). + const items: PanelItem[] = defaultLayout("feed") + .order.filter((id) => available[id]) + .map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) })); - // Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that - // carries the active-filter count so you can tell filters are on without opening it. - if (collapsed) { - return ; - } + const actions = ( + <> + + + + ); return ( - + ); } - -function WidgetCard({ - id, - title, - editing, - collapsed, - hidden, - onToggleCollapse, - onToggleHidden, - children, -}: { - id: WidgetId; - title: string; - editing: boolean; - collapsed: boolean; - hidden: boolean; - onToggleCollapse: () => void; - onToggleHidden: () => void; - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - disabled: !editing, - }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - }; - const showBody = !editing && !collapsed; - - return ( -
-
- {editing && ( - - )} -
- {title} -
- {editing ? ( - - ) : ( - - )} -
- {showBody &&
{children}
} -
- ); -} - diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index f9e827a..d4e05aa 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -1,5 +1,7 @@ { "title": "Playlists", + "manageGroup": "Manage", + "listGroup": "Your playlists", "watchLater": "Watch later", "syncYoutube": "Sync from YouTube", "syncedToast": "Synced {{count}} playlists from YouTube", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index c32abdc..57cc4a5 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -1,5 +1,7 @@ { "title": "Lejátszási listák", + "manageGroup": "Kezelés", + "listGroup": "Listáid", "watchLater": "Megnézem később", "syncYoutube": "Szinkron YouTube-ról", "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", diff --git a/frontend/src/index.css b/frontend/src/index.css index 48b913c..4b5a5ec 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -113,6 +113,16 @@ body { background-attachment: fixed; } +/* Hide the native scrollbar while keeping the element scrollable (wheel/trackpad/keyboard). + Used on the nav rail so a high browser-zoom overflow scrolls without showing a scrollbar. */ +.no-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* legacy Edge/IE */ +} +.no-scrollbar::-webkit-scrollbar { + display: none; /* Chromium/WebKit */ +} + /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */ .glass { /* Frosted overlay surface: translucent enough that the blurred backdrop shows diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts new file mode 100644 index 0000000..cc8b1e0 --- /dev/null +++ b/frontend/src/lib/panelLayout.ts @@ -0,0 +1,73 @@ +// Per-user customization of a side panel's group cards: their order, which are collapsed, and +// which are hidden — the same "customize" affordance the feed filter rail had, generalized so +// every panel (feed / plex / playlists) has its own saved layout. Persisted to localStorage and +// the server-side `preferences` blob (schemaless), so it follows the account across devices. +import { LS, readAccount, writeAccount } from "./storage"; + +export type PanelId = "feed" | "plex" | "playlists"; + +export interface PanelLayout { + order: string[]; + collapsed: Record; + hidden: Record; +} + +// The default group order per panel. Group ids must match the ids each panel feeds to PanelGroups. +// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are +// appended by normalizeLayout, so nothing silently disappears. +const PANEL_ORDER: Record = { + feed: ["savedviews", "date", "tags", "language", "topic"], + plex: [ + "scope", + "playlists", + "watch", + "sort", + "people", + "collection", + "rating", + "genre", + "year", + "duration", + "added", + "contentrating", + ], + playlists: ["manage", "list"], +}; + +// localStorage key per panel (feed keeps its original key for backward compatibility). +const LS_KEY: Record = { + feed: LS.sidebarLayout, + plex: LS.plexLayout, + playlists: LS.playlistsLayout, +}; + +// Key on the server `preferences` blob per panel (feed keeps its original key). +export const PREF_KEY: Record = { + feed: "sidebarLayout", + plex: "plexLayout", + playlists: "playlistsLayout", +}; + +export function defaultLayout(panel: PanelId): PanelLayout { + return { order: [...PANEL_ORDER[panel]], collapsed: {}, hidden: {} }; +} + +export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout { + const all = PANEL_ORDER[panel]; + const r = (raw ?? {}) as Partial; + const order = Array.isArray(r.order) ? r.order.filter((x) => all.includes(x)) : []; + for (const id of all) if (!order.includes(id)) order.push(id); + return { + order, + collapsed: { ...(r.collapsed ?? {}) }, + hidden: { ...(r.hidden ?? {}) }, + }; +} + +export function loadLayout(panel: PanelId): PanelLayout { + return normalizeLayout(panel, readAccount(LS_KEY[panel], {})); +} + +export function saveLayoutLocal(panel: PanelId, l: PanelLayout): void { + writeAccount(LS_KEY[panel], l); +} diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts deleted file mode 100644 index 146c7b1..0000000 --- a/frontend/src/lib/sidebarLayout.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Per-user customization of the filter sidebar: order of the widget cards, which are -// collapsed, and which are hidden. Persisted to localStorage and the server-side -// `preferences` blob so it follows the account. - -// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no -// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically. -import { LS, readAccount, writeAccount } from "./storage"; - -export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags"; - -const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"]; - -export interface SidebarLayout { - order: WidgetId[]; - collapsed: Partial>; - hidden: Partial>; -} - -export const DEFAULT_LAYOUT: SidebarLayout = { - order: [...ALL_WIDGETS], - collapsed: {}, - hidden: {}, -}; - -// Tolerate stale/partial data: keep only known widgets and append any that are missing -// (e.g. a widget added in a later version) so nothing silently disappears. -export function normalizeLayout(raw: unknown): SidebarLayout { - const r = (raw ?? {}) as Partial; - const order: WidgetId[] = Array.isArray(r.order) - ? (r.order.filter((x) => ALL_WIDGETS.includes(x as WidgetId)) as WidgetId[]) - : []; - for (const w of ALL_WIDGETS) if (!order.includes(w)) order.push(w); - return { - order, - collapsed: { ...(r.collapsed ?? {}) }, - hidden: { ...(r.hidden ?? {}) }, - }; -} - -export function loadLayout(): SidebarLayout { - return normalizeLayout(readAccount(LS.sidebarLayout, {})); -} - -export function saveLayoutLocal(l: SidebarLayout): void { - writeAccount(LS.sidebarLayout, l); -} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 4d4899d..335f77e 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -6,6 +6,9 @@ import { useCallback, useState } from "react"; export const LS = { theme: "siftlode.theme", sidebarLayout: "siftlode.sidebarLayout", + plexLayout: "siftlode.plexLayout", + playlistsLayout: "siftlode.playlistsLayout", + playlistsCollapsed: "siftlode.playlistsCollapsed", hints: "siftlode.hints", lang: "siftlode.lang", filters: "siftlode.filters", diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts new file mode 100644 index 0000000..9bfc5ec --- /dev/null +++ b/frontend/src/lib/useScrollFade.ts @@ -0,0 +1,39 @@ +import { useEffect, useState, type CSSProperties } from "react"; + +// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`): +// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge +// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref +// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback +// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that +// mounts collapsed and only renders its scroll body once expanded still wires up correctly. +export function useScrollFade(fadePx = 20): { + ref: (node: HTMLDivElement | null) => void; + style: CSSProperties; +} { + const [el, setEl] = useState(null); + const [fade, setFade] = useState({ up: false, down: false }); + + useEffect(() => { + if (!el) return; + const update = () => + setFade({ + up: el.scrollTop > 1, + down: el.scrollTop + el.clientHeight < el.scrollHeight - 1, + }); + update(); + el.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(el); + // Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates. + if (el.firstElementChild) ro.observe(el.firstElementChild); + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + }; + }, [el]); + + const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${ + fade.down ? fadePx : 0 + }px), transparent)`; + return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; +} From 7e40f4f77a6315dda644e99141862b3f4ae44492 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 13 Jul 2026 05:13:52 +0200 Subject: [PATCH 4/4] docs(release): expand v0.42.0 notes (header, side panels, i18n, notifications) --- frontend/src/lib/releaseNotes.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 9fb890e..821dca2 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -16,14 +16,22 @@ export interface ReleaseEntry { export const RELEASE_NOTES: ReleaseEntry[] = [ { version: "0.42.0", - date: "2026-07-12", - summary: "A refreshed “glass over image” look for dark theme, plus two new colour schemes.", + date: "2026-07-13", + summary: + "A big visual refresh: the “glass over image” dark theme, a redesigned top bar, and one unified floating panel system for filters and playlists.", features: [ "New background image: in dark theme a subtle backdrop, tuned to your colour scheme, now sits behind the app — so the frosted-glass surfaces have real depth to refract. Turn it on or off, and dial its strength, with the new controls in Settings → Appearance.", "Two new colour schemes: Starship (deep-space blue) and Matrix (terminal green).", + "Redesigned top bar: the module name sits in a floating pill with back/forward arrows to step through your modules, and each module's search moved into a matching floating search bar.", + "The Feed, Plex and Playlists side panels are now one consistent floating system — collapse any of them to a slim tab beside the navigation, reorder and hide their sections, and your arrangement is saved per panel.", + "The navigation rail is now a floating rounded panel too, matching the rest of the app; at high browser zoom its list scrolls without a scrollbar, with a soft edge fade hinting there's more.", + ], + fixes: [ + "Repeated identical notifications now fold into a single entry with a count, instead of stacking up.", ], chores: [ "Reworked the translucent “glass” surface system so the whole app shares one consistent, tunable look.", + "Retired the German interface language; Siftlode is now available in English and Hungarian.", ], }, {