+
- {T.passwordTitle}
+ {T.passwordTitle}
setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
placeholder={T.passwordPlaceholder}
- className="w-full rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-teal-500/40"
+ className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent/40"
/>
{error &&
{error}
}
{T.watch}
@@ -171,7 +171,7 @@ export default function WatchPage() {
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */}
-
+
{meta.uploader}
) : (
- {meta.uploader}
+ {meta.uploader}
))}
{meta.allow_download && (
{T.download}
@@ -217,7 +217,7 @@ export default function WatchPage() {
target="_blank"
rel="noopener noreferrer"
title={url}
- className="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-teal-400 hover:underline min-w-0"
+ className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0"
>
{displayUrl(url)}
@@ -228,7 +228,7 @@ export default function WatchPage() {
)}
-
{T.brand}
+
{T.brand}
);
}
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ät0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags1> an, um den Feed zu filtern, oder <2>blende2> einen Kanal <2>aus2>, 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 7e22b54..0000000
--- a/frontend/src/i18n/locales/de/header.json
+++ /dev/null
@@ -1,55 +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.",
- "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“0>-Bildschirm — das ist zu erwarten, da Siftlode die vollständige Überprüfung von Google nicht durchlaufen hat. Es ist sicher fortzufahren: klicke auf <1>Erweitert1> → <2>Weiter zu Siftlode2>. Du kannst den Zugriff jederzeit in deinem <3>Google-Konto3> widerrufen.",
- "read": {
- "title": "Verbinde deine YouTube-Abos",
- "body": "Du bist angemeldet. Um deinen Feed aufzubauen, benötigt Siftlode <0>schreibgeschützten0> 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>abzubestellen0> — 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 706522b..0000000
--- a/frontend/src/i18n/locales/de/playlists.json
+++ /dev/null
@@ -1,59 +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…",
- "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 77b9755..0000000
--- a/frontend/src/i18n/locales/de/settings.json
+++ /dev/null
@@ -1,152 +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",
- "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/header.json b/frontend/src/i18n/locales/en/header.json
index d5f29f5..94bc154 100644
--- a/frontend/src/i18n/locales/en/header.json
+++ b/frontend/src/i18n/locales/en/header.json
@@ -4,6 +4,10 @@
"clearSearch": "Clear search",
"searchYoutube": "Search YouTube",
"searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.",
+ "go": "Go",
+ "goTip": "Search (or press Enter)",
+ "prevModule": "Previous module",
+ "nextModule": "Next module",
"channelManager": "Channel manager",
"usageStats": "Usage & stats",
"scheduler": "Scheduler",
diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json
index 179101e..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",
@@ -7,6 +9,8 @@
"ytReadonly": "Synced from YouTube — edits sync back",
"noneYet": "No playlists yet",
"newPlaylist": "New playlist…",
+ "searchPlaceholder": "Search playlists…",
+ "noMatches": "No playlists match.",
"itemCount_one": "{{count}} video",
"itemCount_other": "{{count}} videos",
"pickOne": "Pick a playlist on the left.",
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index 6b97891..3214ab3 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -33,6 +33,9 @@
"colorScheme": "Color scheme",
"display": "Display",
"darkMode": "Dark mode",
+ "backgroundImage": "Background image",
+ "backgroundImageHint": "Show a subtle per-scheme backdrop image behind the app so the glass surfaces have something to refract. Off = flat colour.",
+ "backgroundFade": "Image fade",
"listView": "List view",
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
"performanceMode": "Performance mode",
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/header.json b/frontend/src/i18n/locales/hu/header.json
index 8aa29c4..6aea6c4 100644
--- a/frontend/src/i18n/locales/hu/header.json
+++ b/frontend/src/i18n/locales/hu/header.json
@@ -4,6 +4,10 @@
"clearSearch": "Keresés törlése",
"searchYoutube": "Keresés a YouTube-on",
"searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.",
+ "go": "Mehet",
+ "goTip": "Keresés (vagy Enter)",
+ "prevModule": "Előző modul",
+ "nextModule": "Következő modul",
"channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika",
"scheduler": "Ütemező",
diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json
index c7104c9..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",
@@ -7,6 +9,8 @@
"ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók",
"noneYet": "Még nincs lejátszási lista",
"newPlaylist": "Új lista…",
+ "searchPlaceholder": "Listák keresése…",
+ "noMatches": "Nincs találó lista.",
"itemCount_one": "{{count}} videó",
"itemCount_other": "{{count}} videó",
"pickOne": "Válassz egy listát a bal oldalon.",
diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json
index 99bd459..ed8d5cf 100644
--- a/frontend/src/i18n/locales/hu/settings.json
+++ b/frontend/src/i18n/locales/hu/settings.json
@@ -33,6 +33,9 @@
"colorScheme": "Színséma",
"display": "Megjelenítés",
"darkMode": "Sötét mód",
+ "backgroundImage": "Háttérkép",
+ "backgroundImageHint": "Halvány, sémánkénti háttérkép az app mögött, hogy az üveges felületeknek legyen mit megtörniük. Kikapcsolva sima szín.",
+ "backgroundFade": "Kép elhalványítása",
"listView": "Listanézet",
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
"performanceMode": "Teljesítmény mód",
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": {
diff --git a/frontend/src/index.css b/frontend/src/index.css
index d66cc3e..4b5a5ec 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -4,6 +4,86 @@
:root {
--font-scale: 1.06;
+
+ /* Vertical space the floating top header needs to clear (top offset 12px + pill height 44px +
+ bottom breathing room ~12px). Module content pads its top by this so nothing hides under the
+ header; kept in one place since App and Playlists both reference it. */
+ --hdr-h: 68px;
+
+ /* ===== Glass tunables — every value that materially drives the look lives here so it can be
+ tuned in ONE place. ===== */
+ /* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
+ (nav/header/dialogs/filters/settings — where there's nothing behind the glass to refract).
+ Rich-backdrop surfaces override these via `.glass-media` below. */
+ --glass-blur: 20px; /* backdrop blur radius */
+ --glass-saturate: 1.8; /* backdrop saturation */
+ --glass-dark-bright: 1.25; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */
+ --glass-surface-alpha: 94%; /* .glass panel translucency (higher = more solid = "adaptive") */
+ --glass-card-alpha: 94%; /* .glass-card translucency */
+ --glass-menu-alpha: 98%; /* .glass-menu translucency */
+ --glass-border-alpha: 78%; /* .glass border opacity */
+ --glass-inset: 16%; /* top specular highlight strength (#fff N%) */
+ --glass-edge: 10%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */
+ --glass-scrim: 14%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */
+ --ambient: 1.1; /* multiplier on the ambient accent backdrop pools */
+
+ /* Per-scheme background image dim (see frontend/public/backdrops/): how strongly the faded --bg
+ overlay hides the image — higher = fainter. Only in effect while data-backdrop="on". */
+ --bg-fade: 60%;
+}
+
+/* ===== "Glass over image" — the translucent tier. Applies whenever a
+ real image sits behind the surfaces: app-wide when the per-scheme background image is on
+ (`html[data-backdrop="on"]`), and on the Plex art-backed detail views (`.glass-media`, which
+ have their own art backdrop even when the global image is off). When there's nothing behind the
+ glass (image off / perf), the :root defaults keep it solid & readable. ===== */
+html[data-backdrop="on"],
+.glass-media {
+ --glass-blur: 10px;
+ --glass-saturate: 1.6;
+ --glass-dark-bright: 1.2;
+ --glass-surface-alpha: 50%;
+ --glass-card-alpha: 60%;
+ --glass-menu-alpha: 66%;
+ --glass-border-alpha: 80%;
+ --glass-edge: 25%;
+ --glass-inset: 15%;
+ --glass-scrim: 30%;
+}
+
+/* Light theme carries no per-scheme background image, so the near-opaque :root (adaptive) glass
+ reads as flat white and every panel becomes an opaque island. Give light its OWN translucent tier
+ — one central override, every .glass* surface — so the ambient tint (and content) shows through
+ and glass stays glassy in light too. (Perf mode below still forces solid + no blur.) */
+html[data-theme="light"] {
+ --glass-surface-alpha: 62%;
+ --glass-card-alpha: 74%;
+ --glass-menu-alpha: 86%;
+}
+
+/* Per-scheme background image, painted on (the app root is transparent so it shows through
+ the whole app and the glass refracts it). A faded --bg overlay keeps it subtle; `fixed` so it
+ doesn't scroll. Dark theme uses the dark backdrops, light theme the lighter set in /light/. Only
+ when the backdrop is on (the "Background image" setting; off in perf). The [data-theme] qualifier
+ also out-specifies the ambient `background` shorthand (which would else reset background-size). */
+html[data-backdrop="on"][data-theme="dark"][data-scheme="midnight"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/midnight.svg"); }
+html[data-backdrop="on"][data-theme="dark"][data-scheme="forest"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/forest.svg"); }
+html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/slate.svg"); }
+html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/youtube.svg"); }
+html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/starship.svg"); }
+html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/matrix.svg"); }
+html[data-backdrop="on"][data-theme="light"][data-scheme="midnight"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/midnight.svg"); }
+html[data-backdrop="on"][data-theme="light"][data-scheme="forest"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/forest.svg"); }
+html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/slate.svg"); }
+html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/youtube.svg"); }
+html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/starship.svg"); }
+html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/matrix.svg"); }
+html[data-backdrop="on"][data-theme="dark"] body,
+html[data-backdrop="on"][data-theme="light"] body {
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-attachment: fixed;
}
/* Make native controls (date picker, scrollbars) follow the theme. */
@@ -26,45 +106,65 @@ body {
subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav,
header and dialogs are all frosted glass and refract it. */
background:
- radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 60%),
- radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 55%),
- radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 11%, transparent), transparent 60%),
+ radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent), transparent 60%),
+ radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent), transparent 55%),
+ radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60%),
var(--bg);
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
through (the glassy look), opaque enough that the background isn't distracting.
- The strong blur turns whatever is behind into soft colour, not a sharp image. */
- background: color-mix(in srgb, var(--surface) 78%, transparent);
- backdrop-filter: blur(30px) saturate(1.8);
- -webkit-backdrop-filter: blur(30px) saturate(1.8);
- border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
+ The strong blur turns whatever is behind into soft colour, not a sharp image.
+ Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
+ background:
+ linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
+ color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+ border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
+ border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent));
box-shadow:
- inset 0 1px 0 color-mix(in srgb, #fff 16%, transparent),
+ inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
- background: color-mix(in srgb, var(--card) 84%, transparent);
+ background:
+ linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
+ color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
+ border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 65%, transparent));
box-shadow:
- inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent),
+ inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
}
.glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable — only a hint of translucency + the blur. */
- background: color-mix(in srgb, var(--surface) 92%, transparent);
- backdrop-filter: blur(30px) saturate(1.8);
- -webkit-backdrop-filter: blur(30px) saturate(1.8);
+ background:
+ linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
+ color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
+ border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent));
box-shadow:
- inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent),
+ inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-hover:hover {
@@ -80,9 +180,9 @@ body {
(The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */
html[data-theme="dark"] body {
background:
- radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 60%),
- radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 24%, transparent), transparent 55%),
- radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
+ radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent), transparent 60%),
+ radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent), transparent 55%),
+ radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60%),
var(--bg);
background-attachment: fixed;
}
@@ -90,8 +190,8 @@ html[data-theme="dark"] body {
the frosted sheen shows even over dark UI (over colourful content it just looks richer). */
html[data-theme="dark"] .glass,
html[data-theme="dark"] .glass-menu {
- backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
- -webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright));
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright));
}
/* Toasts surface bottom-left (by the nav's notification bell), off the conventional
top-right; a brighter rim draws the eye there. Most needed in dark mode, where the
@@ -252,6 +352,50 @@ html[data-scheme="youtube"][data-theme="light"] {
--accent-fg: #ffffff;
}
+/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */
+html[data-scheme="starship"][data-theme="dark"] {
+ --bg: #050b16;
+ --surface: #0b1728;
+ --card: #101f36;
+ --border: #1f3557;
+ --fg: #dfeaff;
+ --muted: #8aa6cc;
+ --accent: #38b6ff;
+ --accent-fg: #04101f;
+}
+html[data-scheme="starship"][data-theme="light"] {
+ --bg: #eef4fb;
+ --surface: #ffffff;
+ --card: #ffffff;
+ --border: #d3e0f0;
+ --fg: #0b1b30;
+ --muted: #4a5f7d;
+ --accent: #0b6bd6;
+ --accent-fg: #ffffff;
+}
+
+/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */
+html[data-scheme="matrix"][data-theme="dark"] {
+ --bg: #030a06;
+ --surface: #08130d;
+ --card: #0b1c12;
+ --border: #163d27;
+ --fg: #cfe8d6;
+ --muted: #6fae86;
+ --accent: #46b87c;
+ --accent-fg: #04170c;
+}
+html[data-scheme="matrix"][data-theme="light"] {
+ --bg: #f0f7f1;
+ --surface: #ffffff;
+ --card: #ffffff;
+ --border: #cfe6d6;
+ --fg: #08130b;
+ --muted: #4c6b56;
+ --accent: #12a150;
+ --accent-fg: #ffffff;
+}
+
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@keyframes toastbar {
from {
diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts
new file mode 100644
index 0000000..05ef101
--- /dev/null
+++ b/frontend/src/lib/modules.ts
@@ -0,0 +1,50 @@
+import type { Me } from "./api";
+import type { Page } from "./urlState";
+
+// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
+export const SYSTEM_PAGES: readonly Page[] = ["scheduler", "config", "users", "audit"];
+
+// The i18n key for each module's SHORT display name — the same label the nav rail shows. Single
+// source so the nav rail and the header's ModuleName pill always read identically (the longer,
+// descriptive `pageTitleKey` names are kept only for the browser tab title).
+export const moduleLabelKey: Record = {
+ feed: "header.account.feed",
+ channels: "header.account.channels",
+ playlists: "header.account.playlists",
+ plex: "plex.navLabel",
+ notifications: "inbox.navLabel",
+ messages: "messages.navLabel",
+ downloads: "downloads.navLabel",
+ stats: "header.account.stats",
+ scheduler: "header.account.scheduler",
+ config: "header.account.config",
+ users: "header.account.users",
+ audit: "header.account.audit",
+ settings: "header.account.settings",
+};
+
+// The ordered list of primary module pages available to THIS user, in nav-rail order. Single
+// source of truth for both the nav rail (NavSidebar) and the header's ◀/▶ module stepper, so a
+// new module (or a newly-gated one) shows up in both at once — nothing hard-codes the sequence.
+// Availability is derived from the account (plex toggle, demo restrictions, admin role), so the
+// reachable set changes with the language-independent account state, not the UI.
+export function moduleOrder(me: Me): Page[] {
+ const pages: Page[] = ["feed", "channels", "playlists"];
+ if (me.plex_enabled) pages.push("plex");
+ pages.push("notifications");
+ if (!me.is_demo) pages.push("messages", "downloads");
+ pages.push("stats");
+ if (me.role === "admin") pages.push(...SYSTEM_PAGES);
+ return pages;
+}
+
+// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
+// If the current page isn't a primary module (e.g. Settings), forward lands on the first module
+// and back on the last.
+export function stepModule(me: Me, current: Page, dir: 1 | -1): Page {
+ const order = moduleOrder(me);
+ if (order.length === 0) return current;
+ const i = order.indexOf(current);
+ if (i === -1) return dir === 1 ? order[0] : order[order.length - 1];
+ return order[(i + dir + order.length) % order.length];
+}
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
index 3c991df..c798e4b 100644
--- a/frontend/src/lib/notifications.ts
+++ b/frontend/src/lib/notifications.ts
@@ -59,6 +59,11 @@ export interface Notification {
// timer) that the producer removes itself when the condition resolves. Never persisted
// to history, so a reload can't orphan it with no live handle to clear it.
transient: boolean;
+ // How many times an identical notification (same level+title+message) fired in quick
+ // succession and was coalesced into this one entry (see notify). 1 = fired once; shown as
+ // "×N" in the toast/center when >1. Guards against a burst (e.g. an ErrorBoundary catching
+ // the same crash on every retry) spamming the center with dozens of copies.
+ repeat: number;
}
export interface NotifyInput {
@@ -136,7 +141,10 @@ function load(): Notification[] {
// resurrect as active toasts on reload — their auto-dismiss timers aren't
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization.
- return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification);
+ return raw.map(
+ (n) =>
+ ({ repeat: 1, ...(n as object), action: undefined, dismissed: true, transient: false }) as Notification
+ );
} catch {
return [];
}
@@ -184,10 +192,13 @@ export function removeByMetaKind(kind: NotifMeta["kind"]): void {
if (items.length !== before) emit();
}
+// Window within which an identical notification is coalesced into the prior one (see below).
+const COALESCE_WINDOW_MS = 5000;
+
export function notify(input: NotifyInput): number {
- const id = counter++;
const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info";
+ const now = Date.now();
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
// A transient status stays put until its producer clears it (no auto-dismiss timer),
// same as an interaction-awaiting notice.
@@ -198,6 +209,38 @@ export function notify(input: NotifyInput): number {
: config.durationMs > 0
? config.durationMs
: undefined;
+
+ // Coalesce a rapid burst of identical notifications (same level+title+message within the
+ // window) into one entry with a bumped repeat count, instead of appending a fresh copy each
+ // time. This tames spam like an ErrorBoundary catching the same crash on every render retry,
+ // or a poll that keeps failing the same way. Skipped when the input carries a live `action`
+ // (its callback identity matters) or is a transient status (those are singleton-guarded by
+ // their producer). Distinct events — any difference in level/title/message — are never merged.
+ if (!input.action && !input.transient) {
+ const prior = items.find(
+ (n) =>
+ !n.transient &&
+ !n.action &&
+ now - n.ts <= COALESCE_WINDOW_MS &&
+ n.level === level &&
+ (n.title ?? "") === (input.title ?? "") &&
+ n.message === input.message
+ );
+ if (prior) {
+ // Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
+ items = items.map((n) =>
+ n.id === prior.id
+ ? { ...n, ts: now, repeat: n.repeat + 1, dismissed: false, read: false, duration }
+ : n
+ );
+ emit();
+ if (duration) setTimeout(() => dismiss(prior.id), duration);
+ // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
+ return prior.id;
+ }
+ }
+
+ const id = counter++;
items = [
...items,
{
@@ -209,10 +252,11 @@ export function notify(input: NotifyInput): number {
meta: input.meta,
requiresInteraction,
duration,
- ts: Date.now(),
+ ts: now,
read: false,
dismissed: false,
transient: input.transient ?? false,
+ repeat: 1,
},
];
emit();
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/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index e035467..821dca2 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,26 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.42.0",
+ 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.",
+ ],
+ },
{
version: "0.41.0",
date: "2026-07-12",
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/theme.ts b/frontend/src/lib/theme.ts
index db8caac..12a7324 100644
--- a/frontend/src/lib/theme.ts
+++ b/frontend/src/lib/theme.ts
@@ -1,25 +1,33 @@
import { LS, readAccountMerged, writeAccount } from "./storage";
type Mode = "dark" | "light";
-export type Scheme = "midnight" | "forest" | "slate" | "youtube";
+export type Scheme = "midnight" | "forest" | "slate" | "youtube" | "starship" | "matrix";
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
{ id: "forest", name: "Forest", swatch: "#2dd4bf" },
{ id: "slate", name: "Slate", swatch: "#e8913c" },
{ id: "youtube", name: "YouTube", swatch: "#ff3b46" },
+ { id: "starship", name: "Starship", swatch: "#38b6ff" },
+ { id: "matrix", name: "Matrix", swatch: "#46b87c" },
];
export interface ThemePrefs {
mode: Mode;
scheme: Scheme;
fontScale: number;
+ /** Show the per-scheme background image (dark theme only); off = flat colour. */
+ bgImage: boolean;
+ /** How strongly the background image is faded out, 0–100% (higher = fainter). */
+ bgFade: number;
}
export const DEFAULT_THEME: ThemePrefs = {
mode: "dark",
scheme: "midnight",
fontScale: 1.06,
+ bgImage: true,
+ bgFade: 60,
};
export function applyTheme(t: ThemePrefs): void {
@@ -27,6 +35,7 @@ export function applyTheme(t: ThemePrefs): void {
el.dataset.theme = t.mode;
el.dataset.scheme = t.scheme;
el.style.setProperty("--font-scale", String(t.fontScale));
+ if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`);
}
export function loadLocalTheme(): ThemePrefs {
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 } };
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 6bd188b..869717b 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -20,6 +20,13 @@ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
});
+// Baseline theme so the token CSS vars (--bg/--surface/--accent/…) resolve on first paint — including
+// the standalone public pages (/watch, /privacy, /terms) that render outside and never call
+// applyTheme. The app overrides this with the user's saved theme once it mounts.
+const de = document.documentElement.dataset;
+de.theme ||= "dark";
+de.scheme ||= "midnight";
+
const path = window.location.pathname;
const root =
path === "/privacy" ? :