diff --git a/README.md b/README.md index 22ef321..ebc3b11 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ blocker and SponsorBlock keep working. presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save to your device, share with another user, or hand out a public watch link. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard. -- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English, - Hungarian and German**. +- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English + and Hungarian**. ## Quick start (self-hosting) diff --git a/VERSION b/VERSION index ca59bb0..57c1868 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.0 \ No newline at end of file +0.42.0 \ No newline at end of file diff --git a/backend/app/auth.py b/backend/app/auth.py index 5f814d6..cdd8490 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -364,7 +364,7 @@ async def callback( prefs = dict(user.preferences or {}) if not prefs.get("language"): loc = (userinfo.get("locale") or "").split("-")[0].lower() - prefs["language"] = loc if loc in {"en", "hu", "de"} else "en" + prefs["language"] = loc if loc in {"en", "hu"} else "en" user.preferences = prefs db.flush() diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index de41d13..d40d3e7 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -51,14 +51,6 @@ WELCOME = { "jelszóval.\n\n" "Jó böngészést!" ), - "de": ( - "Willkommen bei Siftlode! 👋\n\n" - "Das ist dein privater Nachrichten-Posteingang. Unterhaltungen mit anderen Mitgliedern " - "sind Ende-zu-Ende-verschlüsselt — nur du und dein Gegenüber könnt sie lesen, nicht " - "einmal ein Admin. Um jemandem zu schreiben, richte zuerst die sichere " - "Nachrichtenübermittlung mit einem Passwort ein.\n\n" - "Viel Spaß mit deinem Feed!" - ), } diff --git a/frontend/public/backdrops/forest.svg b/frontend/public/backdrops/forest.svg new file mode 100644 index 0000000..5e8f0f4 --- /dev/null +++ b/frontend/public/backdrops/forest.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/light/forest.svg b/frontend/public/backdrops/light/forest.svg new file mode 100644 index 0000000..9ecade1 --- /dev/null +++ b/frontend/public/backdrops/light/forest.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/light/matrix.svg b/frontend/public/backdrops/light/matrix.svg new file mode 100644 index 0000000..b679c1b --- /dev/null +++ b/frontend/public/backdrops/light/matrix.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/light/midnight.svg b/frontend/public/backdrops/light/midnight.svg new file mode 100644 index 0000000..37dd1b7 --- /dev/null +++ b/frontend/public/backdrops/light/midnight.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/light/slate.svg b/frontend/public/backdrops/light/slate.svg new file mode 100644 index 0000000..83354ab --- /dev/null +++ b/frontend/public/backdrops/light/slate.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/light/starship.svg b/frontend/public/backdrops/light/starship.svg new file mode 100644 index 0000000..9ba4aeb --- /dev/null +++ b/frontend/public/backdrops/light/starship.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/light/youtube.svg b/frontend/public/backdrops/light/youtube.svg new file mode 100644 index 0000000..16cb1ff --- /dev/null +++ b/frontend/public/backdrops/light/youtube.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg new file mode 100644 index 0000000..3e699a7 --- /dev/null +++ b/frontend/public/backdrops/matrix.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/midnight.svg b/frontend/public/backdrops/midnight.svg new file mode 100644 index 0000000..217531f --- /dev/null +++ b/frontend/public/backdrops/midnight.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/slate.svg b/frontend/public/backdrops/slate.svg new file mode 100644 index 0000000..b5c4889 --- /dev/null +++ b/frontend/public/backdrops/slate.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/starship.svg b/frontend/public/backdrops/starship.svg new file mode 100644 index 0000000..2a8d704 --- /dev/null +++ b/frontend/public/backdrops/starship.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/youtube.svg b/frontend/public/backdrops/youtube.svg new file mode 100644 index 0000000..6d50059 --- /dev/null +++ b/frontend/public/backdrops/youtube.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f0892a..edaebca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,9 +23,11 @@ import { pageTitleKey } from "./lib/pageMeta"; import { loadLayout, normalizeLayout, + PREF_KEY, saveLayoutLocal, - type SidebarLayout, -} from "./lib/sidebarLayout"; + type PanelId, + type PanelLayout, +} from "./lib/panelLayout"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { @@ -49,7 +51,9 @@ import SetupWizard from "./components/SetupWizard"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; +import PlaylistsRail from "./components/PlaylistsRail"; import BackToTop from "./components/BackToTop"; +import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import Toaster from "./components/Toaster"; import ErrorDialog from "./components/ErrorDialog"; @@ -179,8 +183,18 @@ export default function App() { })); const [prefsSaveState, setPrefsSaveState] = useState("idle"); const saveMsgTimer = useRef(undefined); - const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); + // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), + // persisted per-account. One record so feed/plex/playlists share the same mechanism. + const [panelLayouts, setPanelLayouts] = useState>(() => ({ + feed: loadLayout("feed"), + plex: loadLayout("plex"), + playlists: loadLayout("playlists"), + })); const [page, setPageState] = useState(loadInitialPage); + // Client-side name filters for the Channels + Playlists lists — lifted here so the shared top + // SearchBar can drive them (the modules read/seed them via props). + const [channelsSearch, setChannelsSearch] = useState(""); + const [playlistsSearch, setPlaylistsSearch] = useState(""); // Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL, // so a reload returns to the normal feed (a search spends quota, so we never auto-replay it). // The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the @@ -346,10 +360,10 @@ export default function App() { document.title = label ? `${label} · ${brand}` : brand; }, [page, channelView, i18n.language, t]); - function setSidebarLayout(next: SidebarLayout) { - setSidebarLayoutState(next); - saveLayoutLocal(next); - api.savePrefs({ sidebarLayout: next }).catch(() => {}); + function setPanelLayout(panel: PanelId, next: PanelLayout) { + setPanelLayouts((prev) => ({ ...prev, [panel]: next })); + saveLayoutLocal(panel, next); + api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {}); } // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the @@ -361,6 +375,10 @@ export default function App() { const [filterCollapsed, setFilterCollapsedState] = useState(() => Boolean(readAccount(LS.filterCollapsed, false)) ); + // The Playlists rail has its own collapse flag (it's a list/navigator, not filters). + const [playlistsCollapsed, setPlaylistsCollapsedState] = useState(() => + Boolean(readAccount(LS.playlistsCollapsed, false)) + ); function setNavCollapsed(next: boolean) { setNavCollapsedState(next); writeAccount(LS.navCollapsed, next); @@ -371,6 +389,21 @@ export default function App() { writeAccount(LS.filterCollapsed, next); api.savePrefs({ filterCollapsed: next }).catch(() => {}); } + function setPlaylistsCollapsed(next: boolean) { + setPlaylistsCollapsedState(next); + writeAccount(LS.playlistsCollapsed, next); + api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); + } + // The selected playlist — App state so the App-level PlaylistsRail and the module's detail pane + // (Playlists) stay in sync. Persisted so a reload keeps it (it's not in the URL). + const [selectedPlaylistId, setSelectedPlaylistIdState] = useState(() => { + const s = getAccountRaw(LS.playlist); + return s ? Number(s) : null; + }); + const setSelectedPlaylistId = (id: number | null) => { + setSelectedPlaylistIdState(id); + if (id != null) setAccountRaw(LS.playlist, String(id)); + }; useEffect(() => applyTheme(theme), [theme]); @@ -380,6 +413,14 @@ export default function App() { document.documentElement.dataset.perf = perf ? "1" : ""; setAccountRaw(PERF_KEY, perf ? "1" : "0"); }, [perf]); + + // The per-scheme background image (the "glass over image" look) is on when the user hasn't opted + // out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the + // theme-appropriate image on (dark or /light/ set) and switches the glass to translucent. + useEffect(() => { + const on = theme.bgImage && !perf; + document.documentElement.dataset.backdrop = on ? "on" : "off"; + }, [theme.bgImage, perf]); useEffect(() => setHintsEnabled(hints), [hints]); useEffect(() => configureNotifications(notif), [notif]); @@ -577,11 +618,15 @@ export default function App() { setNotif(adopted.notifications); setSavedPrefs(adopted); // Out-of-scope prefs stay instant (edited outside the Settings page). - if (prefs.sidebarLayout) { - const l = normalizeLayout(prefs.sidebarLayout); - setSidebarLayoutState(l); - saveLayoutLocal(l); - } + const prefsRec = prefs as Record; + (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => { + const raw = prefsRec[PREF_KEY[p]]; + if (raw) { + const l = normalizeLayout(p, raw); + setPanelLayouts((prev) => ({ ...prev, [p]: l })); + saveLayoutLocal(p, l); + } + }); if (typeof prefs.navCollapsed === "boolean") { setNavCollapsedState(prefs.navCollapsed); writeAccount(LS.navCollapsed, prefs.navCollapsed); @@ -590,6 +635,10 @@ export default function App() { setFilterCollapsedState(prefs.filterCollapsed); writeAccount(LS.filterCollapsed, prefs.filterCollapsed); } + if (typeof prefsRec.playlistsCollapsed === "boolean") { + setPlaylistsCollapsedState(prefsRec.playlistsCollapsed); + writeAccount(LS.playlistsCollapsed, prefsRec.playlistsCollapsed); + } if (isSupported(prefs.language)) setLanguage(prefs.language); // (Per-account filters — incl. the demo account's whole-library default — are loaded by the // dedicated effect above, which also covers accounts that have no stored preferences.) @@ -725,7 +774,7 @@ export default function App() { ); return ( -
+
setPanelLayout("feed", l)} onFocusChannel={focusChannel} isDemo={meQuery.data!.is_demo} collapsed={filterCollapsed} @@ -768,11 +817,25 @@ export default function App() { filters={plexFilters} setFilters={setPlexFilters} onOpenPlaylist={setPlexPlaylistOpen} + layout={panelLayouts.plex} + setLayout={(l) => setPanelLayout("plex", l)} collapsed={filterCollapsed} onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} /> )} + {page === "playlists" && !channelView && ( + setPanelLayout("playlists", l)} + collapsed={playlistsCollapsed} + onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)} + /> + )}
{channelView ? ( @@ -794,9 +857,20 @@ export default function App() { setFilters={setFilters} plexQ={plexQ} setPlexQ={setPlexQ} + channelsSearch={channelsSearch} + setChannelsSearch={setChannelsSearch} + playlistsSearch={playlistsSearch} + setPlaylistsSearch={setPlaylistsSearch} page={page} + setPage={setPage} onYtSearch={enterYtSearch} /> + {/* The header floats (fixed) over the content, so it no longer occupies flow space; this + wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level + floating panels beside the nav — none sit inside this content column anymore.) */} +
{meQuery.data!.is_demo && } openReleaseNotes(CURRENT_VERSION)} />
@@ -805,6 +879,8 @@ export default function App() { ) : page === "playlists" ? ( - + ) : page === "notifications" ? ( ) : page === "messages" && !meQuery.data!.is_demo ? ( @@ -875,6 +955,7 @@ export default function App() { )}
+
)} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. @@ -903,6 +984,8 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} + {/* Local-only live appearance tuner (renders only on localhost; null everywhere else). */} +
); } diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/BackToTop.tsx index 26bc792..f23519c 100644 --- a/frontend/src/components/BackToTop.tsx +++ b/frontend/src/components/BackToTop.tsx @@ -34,7 +34,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr return createPortal( - )} -
+ {/* The channel-name search now lives in the shared top SearchBar (driven via props). */} {/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
diff --git a/frontend/src/components/CollapsedFilterRail.tsx b/frontend/src/components/CollapsedFilterRail.tsx deleted file mode 100644 index 41124a6..0000000 --- a/frontend/src/components/CollapsedFilterRail.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ChevronRight, SlidersHorizontal } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -// The thin 46px rail shown when a filter sidebar is collapsed (both the feed Sidebar and the Plex -// PlexSidebar rendered byte-identical copies): an expand control + a filter icon carrying the -// active-filter count so you can tell filters are on without opening the panel. -export default function CollapsedFilterRail({ - activeCount, - onToggleCollapse, -}: { - activeCount: number; - onToggleCollapse: () => void; -}) { - const { t } = useTranslation(); - return ( - - ); -} diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index a192869..ebdbab5 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -189,7 +189,7 @@ function DownloadRow({ const { t } = useTranslation(); const running = job.status === "running"; return ( -
+
@@ -338,7 +338,7 @@ function UsageBar() { if (!u) return null; const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); return ( -
+
{t("downloads.usage.title")} @@ -446,7 +446,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } className={inputCls} /> {open && filtered.length > 0 && ( -
+
{filtered.map((u) => ( + )} + {open && ( +
+
+ ◐ Glass Tuner + dev + +
+ +
+ {/* Treatment presets */} +
+
Dark treatment
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {/* Tab switch */} +
+ {(["glass", "palette"] as const).map((t) => ( + + ))} +
+ + {tab === "glass" && ( +
+ {SLIDERS.map((s) => ( + setVar(s.k, v)} /> + ))} +
+ )} + + {tab === "palette" && ( +
+

+ Overrides the live scheme (inline on <html>). Reset clears these back to the scheme. +

+ {PALETTE.map((p) => ( + + ))} +
+ )} + + {/* Export */} +
+
Export → bake in
+ +
+ + +
+
+                  {cssOut}
+                
+
+
+
+ )} +
+ ); +} + +function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) { + return ( +
+ + onChange(Number(e.target.value))} + className="w-16 text-right text-[11px] tabular-nums rounded bg-card border border-border px-1.5 py-0.5 text-accent" + /> + onChange(Number(e.target.value))} + className="col-span-2 w-full accent-accent" + /> +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 2038bbf..57d7d20 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,20 +1,28 @@ +import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Search, X, Youtube } from "lucide-react"; +import { Youtube } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; -import { pageTitleKey } from "../lib/pageMeta"; -import PageTitle from "./PageTitle"; +import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; +import ModuleName from "./ModuleName"; +import SearchBar from "./SearchBar"; -// Contextual top bar over the content column. Primary navigation, account and the per-user sync -// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar. -// The header carries just the feed search (or the current page title). +// The floating top header: a fixed-position row of glass pills over the content — a ModuleName +// pill (label + accent dot + back/forward arrows) and, where a module searches, a SearchBar pill. +// The row's left edge is a constant (as if the nav rail AND filter sidebar were both open), so it +// never shifts when either collapses or when paging between modules; the right edge leaves ~10%. export default function Header({ me, filters, setFilters, plexQ, setPlexQ, + channelsSearch, + setChannelsSearch, + playlistsSearch, + setPlaylistsSearch, page, + setPage, onYtSearch, }: { me: Me; @@ -23,78 +31,90 @@ export default function Header({ // Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not. plexQ: string; setPlexQ: (q: string) => void; + channelsSearch: string; + setChannelsSearch: (q: string) => void; + playlistsSearch: string; + setPlaylistsSearch: (q: string) => void; page: Page; - // Trigger a live YouTube search for the current term (hidden for the demo account, which - // can't spend the shared quota). + setPage: (p: Page) => void; + // Trigger a live YouTube search for the current term (feed only; hidden for the demo account, + // which can't spend the shared quota). onYtSearch: (q: string) => void; }) { const { t } = useTranslation(); - const canSearchYt = !me.is_demo; - // The search box serves both the YouTube feed and the Plex module (integrated search); the live - // YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed - // the persisted `filters.q`. - const isSearchPage = page === "feed" || page === "plex"; - const isPlex = page === "plex"; - const isYtCapable = page === "feed" && canSearchYt; - const searchValue = isPlex ? plexQ : filters.q; - const trimmedQ = searchValue.trim(); + // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so + // it stays correct as modules are added/gated). Labels of every active module feed ModuleName's + // dynamic width so the pill is sized to the widest reachable title in the current language. + const order = moduleOrder(me); + const moduleLabels = order.map((p) => t(moduleLabelKey[p])); + const multiModule = order.length > 1; + // Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube). + const blur = () => (document.activeElement as HTMLElement | null)?.blur?.(); + + // Per-page SearchBar wiring — null for modules without a search. + let search: ReactNode = null; + if (page === "feed") { + const trimmed = filters.q.trim(); + search = ( + { + // When a search first appears, rank the feed by relevance — set atomically with the + // query (race-free vs. per-keystroke updates). Only overrides the default "newest" + // sort; a custom sort the user chose is left alone. Cleared in Feed. + const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest"; + setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); + }} + onGo={() => onYtSearch(trimmed)} + goLabel={t("header.searchYoutube")} + goTitle={t("header.searchYoutubeTip")} + goIcon={} + goDisabled={!trimmed || me.is_demo} + /> + ); + } else if (page === "plex" && me.plex_enabled) { + search = ( + + ); + } else if (page === "channels") { + search = ( + + ); + } else if (page === "playlists") { + search = ( + + ); + } return ( -
- {isSearchPage ? ( -
-
- - { - const q = e.target.value; - if (isPlex) { - setPlexQ(q); - return; - } - // When a search first appears, rank the feed by relevance — set atomically with - // the query (race-free vs. per-keystroke updates). Only overrides the default - // "newest" sort; a custom sort the user chose is left alone. Cleared in Feed. - const startSearch = - !filters.q.trim() && !!q.trim() && filters.sort === "newest"; - setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); - }} - onKeyDown={(e) => { - // On the feed, Enter escalates the typed term to a live YouTube search (the box - // itself filters the local catalog / Plex library as you type). - if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ); - }} - placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")} - className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent" - /> - {searchValue && ( - - )} -
- {trimmedQ && isYtCapable && ( - - )} -
- ) : ( -
- -
- )} -
+ // Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) + + // filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change. +
+ setPage(stepModule(me, page, -1))} + onNext={() => setPage(stepModule(me, page, 1))} + canPrev={multiModule} + canNext={multiModule} + /> + {search} +
); } diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index eaaf244..3aee33a 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -95,7 +95,7 @@ export default function LanguageSwitcher({ className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" > - {/* Current-language badge (HU/EN/DE) so the active language reads at a glance without + {/* Current-language badge (HU/EN) so the active language reads at a glance without opening the menu. The ring matches the page background to detach it from the icon. */} {current.code.toUpperCase()} diff --git a/frontend/src/components/ModuleName.tsx b/frontend/src/components/ModuleName.tsx new file mode 100644 index 0000000..304e4e5 --- /dev/null +++ b/frontend/src/components/ModuleName.tsx @@ -0,0 +1,90 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +// The floating module-name pill in the top header. The label area is sized to the WIDEST +// reachable module title (measured live, in the current language only — so dropping a locale or +// gaining/losing a module just changes the input, nothing is hard-coded), which keeps the label +// column a constant width: the name never shifts and the arrows stay put when paging between +// modules. Carries the back/forward arrows that step cyclically through the active modules, plus +// the accent dot (the former PageTitle "eyebrow" look, folded in here). +export default function ModuleName({ + label, + labels, + onPrev, + onNext, + canPrev, + canNext, +}: { + label: string; + // Every reachable module's title in the current language — drives the fixed label width. + labels: string[]; + onPrev: () => void; + onNext: () => void; + canPrev: boolean; + canNext: boolean; +}) { + const { t } = useTranslation(); + const labelRef = useRef(null); + const canvasRef = useRef(null); + const [labelW, setLabelW] = useState(); + + // Measure the widest label using the label span's own rendered font (respects font-scale, + // tracking and the uppercase transform). Re-runs whenever the label set changes — i.e. on a + // language switch or an account switch that changes which modules are reachable. + useLayoutEffect(() => { + const el = labelRef.current; + if (!el || labels.length === 0) return; + const cs = getComputedStyle(el); + const canvas = canvasRef.current ?? (canvasRef.current = document.createElement("canvas")); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const ls = parseFloat(cs.letterSpacing) || 0; + const upper = cs.textTransform === "uppercase"; + let max = 0; + for (const s of labels) { + const txt = upper ? s.toUpperCase() : s; + const w = ctx.measureText(txt).width + ls * txt.length; + if (w > max) max = w; + } + setLabelW(Math.ceil(max) + 2); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [labels.join("")]); + + const arrow = + "p-1 rounded-lg text-muted transition hover:text-fg hover:bg-card disabled:opacity-25 disabled:pointer-events-none"; + return ( +
+ + + + + + + {label} + + +
+ ); +} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 6a2267f..854e4f9 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -28,6 +28,8 @@ import * as e2ee from "../lib/e2ee"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; +import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; +import { useScrollFade } from "../lib/useScrollFade"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; @@ -167,36 +169,46 @@ export default function NavSidebar({ }); const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; + // Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook, + // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. + const listFade = useScrollFade(); + type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; + // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the + // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift + // and a new/gated module updates both at once. Badges live here (nav-only concern). + // Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the + // header's ModuleName pill always read the same name. + const ICON: Record = { + feed: Home, + channels: Tv, + playlists: ListVideo, + plex: Clapperboard, + notifications: Bell, + messages: MessageSquare, + downloads: Download, + stats: BarChart3, + scheduler: Activity, + config: SlidersHorizontal, + users: Users, + audit: ScrollText, + settings: Settings, + }; + const BADGE: Partial> = { + notifications: unread, + messages: msgUnread, + downloads: dlActive, + }; + const META = (p: Page): Omit => ({ + icon: ICON[p], + label: t(moduleLabelKey[p]), + badge: BADGE[p], + }); + const sys = new Set(SYSTEM_PAGES); + const order = moduleOrder(me); // User-facing content modules vs. admin/system modules, separated by a divider in the rail. - const userItems: NavItem[] = [ - { page: "feed", icon: Home, label: t("header.account.feed") }, - { page: "channels", icon: Tv, label: t("header.account.channels") }, - { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, - // Optional Plex module — browse/play your Plex library. Shown only when the admin enabled it. - ...(me.plex_enabled ? [{ page: "plex" as Page, icon: Clapperboard, label: t("plex.navLabel") }] : []), - { page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread }, - // Direct messaging — its own module; hidden for the shared demo account. - ...(me.is_demo - ? [] - : [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]), - // Download center — YouTube → server → your device. Hidden for the shared demo account - // (spends server disk + needs a real identity). - ...(me.is_demo - ? [] - : [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]), - // Per-user sync status + your own API usage (admins get an extra system-wide tab inside). - { page: "stats", icon: BarChart3, label: t("header.account.stats") }, - ]; - const systemItems: NavItem[] = - me.role === "admin" - ? [ - { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, - { page: "config", icon: SlidersHorizontal, label: t("header.account.config") }, - { page: "users", icon: Users, label: t("header.account.users") }, - { page: "audit", icon: ScrollText, label: t("header.account.audit") }, - ] - : []; + const userItems: NavItem[] = order.filter((p) => !sys.has(p)).map((p) => ({ page: p, ...META(p) })); + const systemItems: NavItem[] = order.filter((p) => sys.has(p)).map((p) => ({ page: p, ...META(p) })); const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; @@ -261,7 +273,7 @@ export default function NavSidebar({ return (
-
+
{userItems.map(renderItem)} {systemItems.length > 0 && (collapsed ? ( diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 07fcf0e..4c14a12 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -320,7 +320,14 @@ function ClientActivityRow({
{n.title &&
{n.title}
} -
{n.message}
+
+ {n.message} + {n.repeat > 1 && ( + + ×{n.repeat} + + )} +
{relativeFromMs(n.ts)} {needsAction && ( diff --git a/frontend/src/components/PageTitle.tsx b/frontend/src/components/PageTitle.tsx deleted file mode 100644 index 1ea61ae..0000000 --- a/frontend/src/components/PageTitle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// The centered module title in the top bar. Every non-feed module renders through this one -// component, so its styling lives in a single place. Design element (user-picked): a tracked -// small-caps "eyebrow" with a leading accent dot. -export default function PageTitle({ label }: { label: string }) { - return ( - - - {label} - - ); -} diff --git a/frontend/src/components/PanelGroup.tsx b/frontend/src/components/PanelGroup.tsx new file mode 100644 index 0000000..56cb359 --- /dev/null +++ b/frontend/src/components/PanelGroup.tsx @@ -0,0 +1,84 @@ +import { type ReactNode, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse, +// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from +// the feed sidebar's WidgetCard so all three panels share the same island look and behavior. +export default function PanelGroup({ + id, + title, + editing, + collapsed, + hidden, + onToggleCollapse, + onToggleHidden, + children, +}: { + id: string; + title: string; + editing: boolean; + collapsed: boolean; + hidden: boolean; + onToggleCollapse: () => void; + onToggleHidden: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled: !editing, + }); + const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition }; + const showBody = !editing && !collapsed; + + return ( +
+
+ {editing && ( + + )} + + {editing ? ( + + ) : ( + + )} +
+ {showBody &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/PanelGroups.tsx new file mode 100644 index 0000000..be4d270 --- /dev/null +++ b/frontend/src/components/PanelGroups.tsx @@ -0,0 +1,80 @@ +import { type ReactNode } from "react"; +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import type { PanelLayout } from "../lib/panelLayout"; +import PanelGroup from "./PanelGroup"; + +export type PanelItem = { id: string; title: string; body: ReactNode }; + +// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group +// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE +// groups (a caller drops facet-gated / empty ones); the layout order is filtered to them so a +// stored order that references a now-absent group is tolerated. Reordering/hiding is active only +// in `editing` mode. Shared by all three side panels. +export default function PanelGroups({ + items, + layout, + setLayout, + editing, +}: { + items: PanelItem[]; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + editing: boolean; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const byId = new Map(items.map((it) => [it.id, it])); + + const orderedAvailable = layout.order.filter((id) => byId.has(id)); + // Append any available id missing from the stored order (e.g. a group that became available + // after the layout was saved) so it never silently disappears. + for (const it of items) if (!orderedAvailable.includes(it.id)) orderedAvailable.push(it.id); + const renderedIds = editing + ? orderedAvailable + : orderedAvailable.filter((id) => !layout.hidden[id]); + + function toggleCollapse(id: string) { + setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); + } + function toggleHidden(id: string) { + setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); + } + function onDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + const from = layout.order.indexOf(active.id as string); + const to = layout.order.indexOf(over.id as string); + if (from < 0 || to < 0) return; + setLayout({ ...layout, order: arrayMove(layout.order, from, to) }); + } + + return ( + + +
+ {renderedIds.map((id) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index c157186..bb48586 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,6 +1,6 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { DndContext, PointerSensor, @@ -23,9 +23,7 @@ import { GripVertical, ListPlus, Pencil, - Pin, Play, - Plus, RefreshCw, RotateCcw, Trash2, @@ -33,11 +31,10 @@ import { X, Youtube, } from "lucide-react"; -import { api, type Playlist, type Video } from "../lib/api"; +import { api, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; @@ -95,9 +92,6 @@ const idsKey = (items: Video[]) => .sort() .join(","); -type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean }; -const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; - function Row({ video, index, @@ -123,7 +117,7 @@ function Row({
{readOnly ? ( @@ -183,26 +177,20 @@ function Row({ ); } -export default function Playlists({ canWrite }: { canWrite: boolean }) { +export default function Playlists({ + canWrite, + selectedId, + setSelectedId, +}: { + canWrite: boolean; + // The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the + // list + its rail controls). This detail pane reacts to the selection. + selectedId: number | null; + setSelectedId: (id: number | null) => void; +}) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). - const [selectedId, setSelectedId] = useState(() => { - const s = getAccountRaw(LS.playlist); - return s ? Number(s) : null; - }); - useEffect(() => { - if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId)); - }, [selectedId]); - const selectedRef = useRef(null); - const scrolledRef = useRef(false); - // How the left playlist rail is ordered (persisted). - const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); - useEffect(() => { - writeAccount(LS.plSort, plSort); - }, [plSort]); - const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); // The item order is undoable: drag, sort and group all go through `order.set`, so each is @@ -237,21 +225,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { }); const playlists = listQuery.data ?? []; - // Keep the selection valid: default to the first playlist, and if the selected one - // disappears (e.g. just deleted) drop to the next available, or clear when none remain. - useEffect(() => { - // Wait for the first load before adjusting the selection — otherwise the empty - // placeholder list would null the restored selection and we'd fall back to the first. - if (!listQuery.data) return; - if (!playlists.length) { - if (selectedId != null) setSelectedId(null); - return; - } - if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { - setSelectedId(playlists[0].id); - } - }, [playlists, selectedId, listQuery.data]); - const detailQuery = useQuery({ queryKey: ["playlist", selectedId], queryFn: () => api.playlist(selectedId as number), @@ -301,40 +274,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; - // The left rail in the chosen order. "custom" keeps the server position order (Array.sort - // is stable); "dirty first" floats playlists with unpushed edits to the top. - const sortedPlaylists = useMemo(() => { - const sign = plSort.dir === "asc" ? 1 : -1; - return [...playlists].sort((a, b) => { - if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; - switch (plSort.key) { - case "name": - return sign * plName(a).localeCompare(plName(b)); - case "count": - return sign * (a.item_count - b.item_count); - case "duration": - return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); - default: - return 0; // custom: server order - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playlists, plSort]); - - // Scroll the restored selection into view once after the rail first renders. - useEffect(() => { - if (!scrolledRef.current && selectedRef.current) { - selectedRef.current.scrollIntoView({ block: "nearest" }); - scrolledRef.current = true; - } - }, [sortedPlaylists]); - const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin. const editable = !builtin; const linked = editable && !!detail?.yt_playlist_id; - const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); const [reverting, setReverting] = useState(false); @@ -413,30 +357,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { } } - async function syncYoutube() { - if (syncing) return; - setSyncing(true); - try { - const r = await api.syncYoutubePlaylists(); - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist"] }); - notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); - } catch (e) { - notifyYouTubeActionError(e, t("playlists.syncFailed")); - } finally { - setSyncing(false); - } - } - - const createMut = useMutation({ - mutationFn: (name: string) => api.createPlaylist(name), - onSuccess: (pl: Playlist) => { - setNewName(""); - qc.invalidateQueries({ queryKey: ["playlists"] }); - setSelectedId(pl.id); - }, - }); - function refreshAll() { qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); @@ -523,120 +443,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { } return ( -
- - -
+
{selectedId == null || !detail ? (
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} @@ -851,7 +658,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { )} )} -
{playingIndex != null && items[playingIndex] && ( diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx new file mode 100644 index 0000000..534f35e --- /dev/null +++ b/frontend/src/components/PlaylistsRail.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; +import { api, type Playlist } from "../lib/api"; +import { formatDuration } from "../lib/format"; +import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; +import { LS, readAccountMerged, writeAccount } from "../lib/storage"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; + +type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean }; +const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; + +// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the +// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail +// controls (sort / new / sync); the selected playlist is App state so the module's detail pane +// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. +export default function PlaylistsRail({ + canWrite, + search, + selectedId, + setSelectedId, + layout, + setLayout, + collapsed, + onToggleCollapse, +}: { + canWrite: boolean; + search: string; + selectedId: number | null; + setSelectedId: (id: number | null) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); + useEffect(() => { + writeAccount(LS.plSort, plSort); + }, [plSort]); + const [newName, setNewName] = useState(""); + const [syncing, setSyncing] = useState(false); + const [editing, setEditing] = useState(false); + const selectedRef = useRef(null); + const scrolledRef = useRef(false); + + const listQuery = useQuery({ + queryKey: ["playlists"], + queryFn: () => api.playlists(), + refetchOnWindowFocus: true, + }); + const playlists = listQuery.data ?? []; + + // Keep the selection valid: default to the first playlist, and if the selected one disappears + // drop to the next available (or clear when none remain). Waits for the first load. + useEffect(() => { + if (!listQuery.data) return; + if (!playlists.length) { + if (selectedId != null) setSelectedId(null); + return; + } + if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { + setSelectedId(playlists[0].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, selectedId, listQuery.data]); + + const plName = (p: { kind: string; name: string }) => + p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + + const sortedPlaylists = useMemo(() => { + const sign = plSort.dir === "asc" ? 1 : -1; + return [...playlists].sort((a, b) => { + if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; + switch (plSort.key) { + case "name": + return sign * plName(a).localeCompare(plName(b)); + case "count": + return sign * (a.item_count - b.item_count); + case "duration": + return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); + default: + return 0; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, plSort]); + + const q = search.trim().toLowerCase(); + const visiblePlaylists = useMemo( + () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sortedPlaylists, q] + ); + + useEffect(() => { + if (!scrolledRef.current && selectedRef.current) { + selectedRef.current.scrollIntoView({ block: "nearest" }); + scrolledRef.current = true; + } + }, [sortedPlaylists]); + + async function syncYoutube() { + if (syncing) return; + setSyncing(true); + try { + const r = await api.syncYoutubePlaylists(); + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); + } catch (e) { + notifyYouTubeActionError(e, t("playlists.syncFailed")); + } finally { + setSyncing(false); + } + } + + const createMut = useMutation({ + mutationFn: (name: string) => api.createPlaylist(name), + onSuccess: (pl: Playlist) => { + setNewName(""); + qc.invalidateQueries({ queryKey: ["playlists"] }); + setSelectedId(pl.id); + }, + }); + + const items: PanelItem[] = [ + { + id: "manage", + title: t("playlists.manageGroup"), + body: ( +
+ + {playlists.length > 1 && ( +
+ + + +
+ )} +
{ + e.preventDefault(); + if (newName.trim()) createMut.mutate(newName.trim()); + }} + className="flex items-center gap-1.5" + > + + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + disabled={!canWrite} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent disabled:opacity-50" + /> + +
+ ), + }, + { + id: "list", + title: t("playlists.listGroup"), + body: ( + <> + {playlists.length === 0 && !listQuery.isLoading && ( +
{t("playlists.noneYet")}
+ )} + {playlists.length > 0 && visiblePlaylists.length === 0 && ( +
{t("playlists.noMatches")}
+ )} +
+ {visiblePlaylists.map((pl) => ( + + ))} +
+ + ), + }, + ]; + + return ( + } + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("playlists"))} + editHint={t("sidebar.editHint")} + > + + + ); +} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4409b5a..3f112a1 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -905,7 +905,7 @@ function PlexShowView({ } return ( -
+
{q.isLoading || !d || !show ? ( @@ -1103,7 +1103,7 @@ function PlexSeasonView({ } return ( -
+
{q.isLoading || !d || !se ? ( diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 1c8a28a..f05bcdf 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -90,12 +90,11 @@ export default function PlexInfo({ t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); return ( -
+
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
{/* Header: close (overlay) / customize */}
@@ -302,7 +301,6 @@ export default function PlexInfo({ {cast.length > 0 && (

{t("plex.info.cast")} @@ -365,7 +363,6 @@ export default function PlexInfo({

{col.title}

diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index e797d62..40c0117 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,16 +1,17 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; -// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the -// full metadata filter set (genre / rating / year / duration / added / content rating + the -// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State -// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the -// backend so the sidebar only offers what the library actually contains. +// The Plex module's left filter column — now the shared floating SidePanel with its filter groups +// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata +// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from +// the backend so the panel only offers what the library actually contains. type Props = { scope: string; // movie | show | both (unified cross-library scope) @@ -22,17 +23,17 @@ type Props = { filters: PlexFilters; setFilters: (f: PlexFilters) => void; onOpenPlaylist: (id: number) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; -// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime). const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const; const RATING_STEPS = [5, 6, 7, 8, 9]; const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; -// Duration buckets → [minSeconds|null, maxSeconds|null]. const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [ { key: "short", min: null, max: 90 * 60 }, { key: "mid", min: 90 * 60, max: 120 * 60 }, @@ -49,12 +50,15 @@ export default function PlexSidebar({ filters, setFilters, onOpenPlaylist, + layout, + setLayout, collapsed, onToggleCollapse, }: Props) { const { t } = useTranslation(); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); + const [editing, setEditing] = useState(false); const qc = useQueryClient(); async function createPlaylist() { const title = newPlaylist.trim(); @@ -64,29 +68,18 @@ export default function PlexSidebar({ qc.invalidateQueries({ queryKey: ["plex-playlists"] }); onOpenPlaylist(pl.id); } - // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters - // (faceted search) — so picking one filter narrows what the others offer. Refetches on any change. - // Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets, - // so keying on the whole `filters` object would re-run the heavy facet computation on every - // sort-direction toggle (or chip-title change) for an identical request. const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ queryKey: ["plex-facets", scope, show, facetKey], queryFn: () => api.plexFacets(scope, filters, show), enabled: !!scope, - placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker) + placeholderData: (prev) => prev, }); const facets = facetsQ.data; - // Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the - // unified scope has no single library). Only fetched while no collection is active (else the chip - // shows instead). Applying one sets the collection filter; the actual filtering already flows through - // /library + /facets by rating_key. const [collSearch, setCollSearch] = useState(""); const collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ - // "union" disambiguates from the editor's per-library ["plex-collections", ] key (a search - // term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working. queryKey: ["plex-collections", "union", collSearchDeb], queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), enabled: !filters.collection, @@ -106,112 +99,83 @@ export default function PlexSidebar({ setSort("title"); }; - // Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime). - // Ordered alphabetically by their localized label. const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) => t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), ); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; + const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0; - if (collapsed) { - return ; - } - - return ( -
+ ), + }, + { + id: "rating", + title: t("plex.filter.rating"), + body: ( + + patch({ ratingMin: null })}> + {t("plex.filter.any")} + + {RATING_STEPS.map((r) => ( + patch({ ratingMin: r })}> + {r}+ + + ))} + + ), + }, + ...(facets && facets.genres.length > 0 + ? [ + { + id: "genre", + title: t("plex.filter.genre"), + body: ( + <> + {filters.genres.length > 1 && ( +
+ {(["any", "all"] as const).map((m) => ( + + ))} +
+ )} + + {facets.genres.map((g) => ( + patch({ genres: toggleIn(filters.genres, g.value) })} > - {t(`plex.filter.match.${m}`)} - + {g.value} + ))} -
- )} - - {facets.genres.map((g) => ( - patch({ genres: toggleIn(filters.genres, g.value) })} - > - {g.value} - - ))} - - - )} - - {/* Year range */} -
-
- patch({ yearMin: v })} - /> - - patch({ yearMax: v })} - /> -
-
- - {/* Duration buckets — movie-only (a show has no single runtime) */} - {scope === "movie" && ( -
+ + + ), + }, + ] + : []), + { + id: "year", + title: t("plex.filter.year"), + body: ( +
+ patch({ yearMin: v })} + /> + + patch({ yearMax: v })} + /> +
+ ), + }, + ...(scope === "movie" + ? [ + { + id: "duration", + title: t("plex.filter.duration"), + body: ( ))} -
- )} - - {/* Added to Plex */} -
- - patch({ addedWithin: "" })}> - {t("plex.filter.any")} - - {ADDED_OPTS.map((a) => ( - patch({ addedWithin: a })}> - {t(`plex.filter.addedOpt.${a}`)} - - ))} - -
- - {/* Content rating (from facets) */} - {facets && facets.content_ratings.length > 0 && ( -
+ ), + }, + ] + : []), + { + id: "added", + title: t("plex.filter.added"), + body: ( + + patch({ addedWithin: "" })}> + {t("plex.filter.any")} + + {ADDED_OPTS.map((a) => ( + patch({ addedWithin: a })}> + {t(`plex.filter.addedOpt.${a}`)} + + ))} + + ), + }, + ...(facets && facets.content_ratings.length > 0 + ? [ + { + id: "contentrating", + title: t("plex.filter.contentRating"), + body: ( {facets.content_ratings.map((c) => ( ))} -
- )} - - )} - - ); -} + ), + }, + ] + : []), + ]; -function Section({ label, children }: { label: string; children: ReactNode }) { return ( -
-
{label}
- {children} -
+ } + count={activeCount} + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("plex"))} + editHint={t("sidebar.editHint")} + actions={ + anyActive ? ( + + ) : undefined + } + > + + ); } diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index e1901b5..df45f66 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -97,7 +97,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
{t("downloads.profiles.builtin")}
{builtins.map((p) => ( -
+
{p.name}
@@ -106,7 +106,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
{t("downloads.profiles.yours")}
)} {mine.map((p) => ( -
+
{p.name} + )} + {onGo && ( + + )} +
+ ); +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 0399ea3..ea7edfd 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -158,6 +158,34 @@ function Appearance({ prefs }: { prefs: PrefsController }) { onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} /> + + setTheme({ ...theme, bgImage: v })} + /> + + {theme.bgImage && ( +
+
+ {t("settings.appearance.backgroundFade")} + {theme.bgFade}% +
+ setTheme({ ...theme, bgFade: Number(e.target.value) })} + aria-label={t("settings.appearance.backgroundFade")} + className="w-full accent-accent" + /> +
+ )} {open && filtered.length > 0 && ( -
+
{filtered.map((u) => ( + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b6dc281..3b0ee85 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,47 +1,16 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - Check, - ChevronDown, - ChevronLeft, - Eye, - EyeOff, - GripVertical, - Library, - Pencil, - RotateCcw, - Share2, - User, - X, -} from "lucide-react"; -import { - closestCenter, - DndContext, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +import { Library, Pencil, Share2, SlidersHorizontal, User, X } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; -import { - DEFAULT_LAYOUT, - type SidebarLayout, - type WidgetId, -} from "../lib/sidebarLayout"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import TagManager from "./TagManager"; import SavedViewsWidget from "./SavedViewsWidget"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -110,12 +79,10 @@ export default function Sidebar({ }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; - layout: SidebarLayout; - setLayout: (l: SidebarLayout) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; onFocusChannel: (name: string) => void; isDemo?: boolean; - // Full-height collapse (state owned by App, persisted to the user's preferences), mirroring - // the left nav rail. collapsed: boolean; onToggleCollapse: () => void; }) { @@ -123,11 +90,8 @@ export default function Sidebar({ const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; - // Live per-tag channel counts for the current filter context (scope, channel, date, - // search, watch state, other category's tags). Lets us show contextual counts and hide - // chips that no longer match anything. Keyed on filters so it refetches as they change. - // Debounce the search term (matching the feed) so typing doesn't refire facets per - // keystroke, and keep the previous counts on screen during a refetch so chips don't flicker. + // Live per-tag channel counts for the current filter context. Debounce the search term so + // typing doesn't refire facets per keystroke, and keep previous counts during a refetch. const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetsQuery = useQuery({ queryKey: ["facets", facetFilters], @@ -137,12 +101,8 @@ export default function Sidebar({ }); const facetsReady = !!facetsQuery.data; const facetCounts = facetsQuery.data?.counts ?? {}; - // Before facets load, fall back to the static global count so chips don't flash empty. const chipCount = (tag: Tag): number => facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; - // Visible chips: hide zero-count ones once facets are in, but always keep selected ones - // so an active filter can still be cleared. Sorted by count (largest first), then name — - // so scanning top→bottom the smallest counts end up at the very bottom. const visibleChips = (list: Tag[]): Tag[] => { const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) @@ -152,10 +112,6 @@ export default function Sidebar({ ); }; - // After a page refresh the channel name isn't in the URL (only the id is), so - // filters.channelName is undefined and the chip would fall back to "This channel". - // Resolve the title from the (cached) channel list keyed by id. Only fetch when a - // channel filter is active but its name is missing. const needChannelName = !!filters.channelId && !filters.channelName; const channelsQuery = useQuery({ queryKey: ["channels"], @@ -167,7 +123,6 @@ export default function Sidebar({ filters.channelName ?? channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ?? undefined; - // Don't flash the misleading "This channel" fallback while the name is still resolving. const channelChipLabel = resolvedChannelName ?? (needChannelName && channelsQuery.isLoading @@ -175,18 +130,11 @@ export default function Sidebar({ : t("sidebar.thisChannel")); const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); - // The user's own (non-system) tags — those assigned to channels in the Channel manager. - // Facets cover them too now (the "other" category), so they're contextual like the rest: - // zero-count chips hide once facets load, counts reflect the current filter. const userTags = tags.filter((t) => !t.system); const [customDates, setCustomDates] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false); const [editing, setEditing] = useState(false); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) - ); - function toggleTag(id: number) { const has = filters.tags.includes(id); setFilters({ @@ -198,17 +146,12 @@ export default function Sidebar({ const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo; const contentChanged = !filters.includeNormal || filters.includeShorts || filters.includeLive; - // Library (vs the default "my"/Mine) and a non-default provenance Source both narrow the feed, - // so they count as active filters too (Source only applies in Library scope). The header search - // `q` deliberately stays out — it has its own clear (✕) in the search box and isn't reset here. const scopeActive = filters.scope === "all"; const sourceActive = filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic"; const activeCount = filters.tags.length + (filters.show !== "unwatched" ? 1 : 0) + - // "relevance" is auto-applied while searching (no manual dropdown option), so it's a search - // artifact, not a user-chosen sort filter — don't let typing a query inflate the count. (filters.sort !== "newest" && filters.sort !== "relevance" ? 1 : 0) + (contentChanged ? 1 : 0) + (filters.channelId ? 1 : 0) + @@ -228,15 +171,10 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - // Reset every filter dimension to its default — including scope back to "my" (Mine) and the - // provenance Source (dropped with the rest, so it falls back to "organic"). The search `q` is - // intentionally preserved (it has its own ✕ in the search box). setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" }); } - const available: Record = { - // Saved views are personal state; hide the widget for the shared demo account (the API - // also rejects the writes via require_human). + const available: Record = { savedviews: !isDemo, date: true, language: languages.length > 0, @@ -244,22 +182,7 @@ export default function Sidebar({ tags: userTags.length > 0, }; - function toggleCollapse(id: WidgetId) { - setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); - } - function toggleHidden(id: WidgetId) { - setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); - } - function onDragEnd(e: DragEndEvent) { - const { active: a, over } = e; - if (!over || a.id === over.id) return; - const oldIndex = layout.order.indexOf(a.id as WidgetId); - const newIndex = layout.order.indexOf(over.id as WidgetId); - if (oldIndex < 0 || newIndex < 0) return; - setLayout({ ...layout, order: arrayMove(layout.order, oldIndex, newIndex) }); - } - - function widgetBody(id: WidgetId): React.ReactNode { + function widgetBody(id: string): ReactNode { switch (id) { case "savedviews": return ; @@ -447,23 +370,51 @@ export default function Sidebar({
); } + default: + return null; } } - const orderedAvailable = layout.order.filter((id) => available[id]); - const renderedIds = editing - ? orderedAvailable - : orderedAvailable.filter((id) => !layout.hidden[id]); + // Group ids come from the shared default order (panelLayout) so there's one source of truth; + // the actual render order is the user's saved layout (PanelGroups orders by it). + const items: PanelItem[] = defaultLayout("feed") + .order.filter((id) => available[id]) + .map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) })); - // Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that - // carries the active-filter count so you can tell filters are on without opening it. - if (collapsed) { - return ; - } + const actions = ( + <> + + + + ); return ( - + ); } - -function WidgetCard({ - id, - title, - editing, - collapsed, - hidden, - onToggleCollapse, - onToggleHidden, - children, -}: { - id: WidgetId; - title: string; - editing: boolean; - collapsed: boolean; - hidden: boolean; - onToggleCollapse: () => void; - onToggleHidden: () => void; - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - disabled: !editing, - }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - }; - const showBody = !editing && !collapsed; - - return ( -
-
- {editing && ( - - )} -
- {title} -
- {editing ? ( - - ) : ( - - )} -
- {showBody &&
{children}
} -
- ); -} - diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index 0b94953..4f4af84 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -42,7 +42,14 @@ export default function Toaster() {
{toast.title &&
{toast.title}
} -
{toast.message}
+
+ {toast.message} + {toast.repeat > 1 && ( + + ×{toast.repeat} + + )} +
{toast.action && ( @@ -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.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ät eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags an, um den Feed zu filtern, oder <2>blende einen Kanal <2>aus, um ihn ohne Abbestellung aus dem Feed zu entfernen.", - "syncSubscriptions": "Abos von YouTube lesen", - "syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.", - "backfillEverything": "Alles nachladen", - "backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.", - "tabs": { - "subscribed": "Abonnements", - "discovery": "Aus Playlists entdecken" - }, - "discovery": { - "intro": "Kanäle, die in deinen Playlists vorkommen, die du aber nicht abonniert hast. Abonniere sie, um ihnen zu folgen — ihre neuen Uploads erscheinen dann in deinem Feed (das Abonnieren kostet etwas YouTube-Kontingent; vorhandene Videos werden nicht erneut geladen).", - "loading": "Kanäle werden gesucht…", - "empty": "Keine neuen Kanäle — jeden Kanal in deinen Playlists abonnierst du bereits.", - "subscribe": "Abonnieren", - "subscribeHint": "Diesen Kanal auf YouTube abonnieren (ändert dein echtes Konto; verbraucht etwas API-Kontingent). Seine Videos kommen bei der nächsten Hintergrund-Synchronisierung.", - "needWriteHint": "Aktiviere das Bearbeiten von Playlists in den Einstellungen, um auf YouTube zu abonnieren.", - "subscribedTitle": "Auf YouTube abonniert", - "subscribedBody": "Du folgst jetzt {{name}} — neue Uploads erscheinen in deinem Feed.", - "subscribeFailed": "Abonnieren fehlgeschlagen", - "confirmSubscribe": "„{{name}}“ auf YouTube abonnieren? Das ändert dein echtes YouTube-Konto und verbraucht etwas API-Kontingent.", - "cols": { - "totalVideos": "Videos", - "inPlaylists": "In Playlists", - "inPlaylistsHint": "{{videos}} Video(s) von diesem Kanal in {{playlists}} deiner Playlist(s)." - } - }, - "filters": { - "all": "Alle", - "needsFull": "Verlauf unvollständig", - "fullySynced": "Vollständig synchronisiert", - "hidden": "Ausgeblendet" - }, - "tags": { - "yourTags": "Deine Tags", - "manage": "Tags verwalten", - "yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)" - }, - "loading": "Kanäle werden geladen…", - "empty": "Keine Kanäle.", - "pager": { - "prev": "Zurück", - "next": "Weiter", - "page": "Seite {{page}} von {{total}}" - }, - "cols": { - "priority": "Prio", - "channel": "Kanal", - "stored": "Gespeichert", - "subs": "Abonnenten", - "lastUpload": "Letzter Upload", - "length": "Länge", - "types": "N/S/L", - "typesHint": "Videos nach Typ: Normale / Shorts / Live", - "sync": "Sync", - "tags": "Tags", - "actions": "Aktionen" - }, - "stats": { - "channels": "Kanäle", - "channelsHint": "Kanäle, die du abonniert hast.", - "recentSynced": "Aktuelle synchronisiert", - "recentSyncedHint": "Kanäle, deren neueste Uploads in der lokalen Datenbank sind und in deinem Feed erscheinen.", - "fullHistory": "Vollständiger Verlauf", - "fullHistoryHint": "Kanäle, deren gesamter Katalog geladen ist, von denen, für die du den vollständigen Verlauf angefordert hast.", - "left": "übrig", - "leftHint": "Grobe Schätzung zum Abschluss des Nachladens des vollständigen Verlaufs für {{count}} ausstehende(n) Kanal/Kanäle, basierend auf dem gemeinsamen Tageskontingent.", - "myVideos": "Meine Videos", - "myVideosHint": "Insgesamt verfügbare Videos über all deine Kanäle.", - "quotaLeft": "Kontingent übrig", - "quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)." - }, - "row": { - "openOnYouTube": "Auf YouTube öffnen", - "editTags": "Tags bearbeiten", - "filterFeedByTag": "Feed nach „{{name}}“ filtern", - "priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.", - "raisePriority": "Priorität erhöhen", - "lowerPriority": "Priorität senken", - "recent": "aktuell", - "recentSyncedHint": "Neueste Uploads synchronisiert.", - "recentNotSyncedHint": "Neueste Uploads noch nicht geladen.", - "full": "vollständig", - "fullHint": "Vollständiger Verlauf geladen.", - "fullNotFetchedHint": "Das vollständige Archiv ist noch nicht geladen — nutze die Backfill-Aktion in der Spalte Aktionen.", - "fullySynced": "vollständig synchron", - "fullySyncedHint": "Aktuelle Uploads und das vollständige Archiv sind beide vorhanden.", - "backfillThis": "Diesen Kanal vollständig laden", - "backfillThisHint": "Das vollständige Archiv dieses Kanals laden (ältere Videos + komplette Suche). Erneut klicken, um die Anfrage zurückzuziehen.", - "resetBackfill": "Kanal zurücksetzen & neu laden", - "resetHint": "Admin: diesen Kanal von Grund auf neu laden (aktuelle + vollständiges Archiv), unabhängig vom aktuellen Sync-Status.", - "fullHistoryQueued": "vollständiger Verlauf in Warteschlange", - "fullHistoryComing": "vollständiger Verlauf unterwegs", - "queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.", - "queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.", - "hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.", - "hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.", - "unhide": "Einblenden", - "hideFromFeed": "Aus Feed ausblenden", - "unsubscribeHint": "Bestelle diesen Kanal bei YouTube ab (ändert dein echtes Konto). Im schreibgeschützten Modus ist dies ausgeblendet — nutze stattdessen Ausblenden.", - "unsubscribeOnYoutube": "Bei YouTube abbestellen", - "thisChannel": "Dieser Kanal" - }, - "notify": { - "synced": "{{count}} Abos synchronisiert", - "syncFailed": "Abo-Synchronisierung fehlgeschlagen", - "unsubscribed": "{{name}} bei YouTube abbestellt", - "unsubscribeFailed": "Abbestellen fehlgeschlagen", - "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", - "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", - "needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.", - "connect": "Verbinden", - "filteredByTag": "Feed nach Tag gefiltert: {{name}}", - "resetDone": "{{name}} zurückgesetzt — wird neu geladen", - "resetFailed": "Kanal konnte nicht zurückgesetzt werden" - }, - "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.", - "confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.", - "searchPlaceholder": "Kanäle suchen…", - "blocked": { - "title": "Blockierte Kanäle", - "hint": "Ihre Videos sind in deinem Feed, der Bibliothek und der Live-Suche ausgeblendet." - } -} diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json deleted file mode 100644 index 854bffd..0000000 --- a/frontend/src/i18n/locales/de/common.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "privacyPolicy": "Datenschutzerklärung", - "termsOfService": "Nutzungsbedingungen", - "sourceCode": "Quellcode", - "close": "Schließen", - "cancel": "Abbrechen", - "confirm": "Bestätigen", - "confirmTitle": "Bist du sicher?", - "save": "Speichern", - "undo": "Rückgängig", - "redo": "Wiederholen", - "loading": "Wird geladen…", - "language": "Sprache", - "somethingWrong": "Etwas ist schiefgelaufen.", - "accessRequestsTitle": "Zugangsanfragen", - "accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.", - "accessRequestsReview": "Überprüfen", - "demoTitle": "Demo-Konto", - "demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden.", - "backToTop": "Nach oben" -} diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json deleted file mode 100644 index 42a0eee..0000000 --- a/frontend/src/i18n/locales/de/config.json +++ /dev/null @@ -1,204 +0,0 @@ -{ - "intro": "In der Datenbank gespeicherte Betriebseinstellungen — sie überschreiben die Datei-/Env-Standardwerte und wirken ohne erneutes Deployment. Lass ein Feld leer (oder setze es zurück), um auf den Standard zurückzufallen.", - "loading": "Konfiguration wird geladen…", - "groups": { - "access": "Zugang", - "email": "E-Mail / SMTP", - "youtube": "YouTube-API", - "google": "Google-Anmeldung", - "quota": "Kontingent", - "backfill": "Nachladen (Backfill)", - "shorts": "Shorts-Prüfung", - "batch": "Stapelgrößen", - "explore": "Kanal erkunden", - "downloads": "Downloads", - "plex": "Plex", - "audit": "Audit-Protokoll" - }, - "fields": { - "smtp_host": { - "label": "SMTP-Host", - "hint": "z. B. smtp.gmail.com" - }, - "smtp_port": { - "label": "SMTP-Port", - "hint": "Üblicherweise 587 (STARTTLS)." - }, - "smtp_user": { - "label": "SMTP-Benutzername", - "hint": "Das sendende Konto / der Login." - }, - "smtp_from": { - "label": "Absenderadresse", - "hint": "z. B. Siftlode . Standard ist der Benutzername." - }, - "smtp_password": { - "label": "SMTP-Passwort", - "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." - }, - "quota_daily_budget": { - "label": "Tägliches Kontingentbudget", - "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." - }, - "backfill_quota_reserve": { - "label": "Nachlade-Kontingentreserve", - "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." - }, - "search_daily_limit_per_user": { - "label": "Live-Suche — Tageslimit pro Nutzer", - "hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert." - }, - "search_source": { - "label": "Quelle der Live-Suche", - "hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird." - }, - "backfill_recent_max_videos": { - "label": "Aktuelles Nachladen — max. Videos", - "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." - }, - "backfill_recent_max_days": { - "label": "Aktuelles Nachladen — max. Alter (Tage)", - "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." - }, - "shorts_probe_max_seconds": { - "label": "Shorts-Prüfung — max. Dauer (s)", - "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." - }, - "shorts_probe_batch": { - "label": "Shorts-Prüfung — Stapelgröße", - "hint": "Wie viele Videos pro Lauf klassifiziert werden." - }, - "enrich_batch_size": { - "label": "Anreicherungs-Stapelgröße", - "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." - }, - "autotag_title_sample": { - "label": "Auto-Tag-Titelstichprobe", - "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." - }, - "youtube_api_key": { - "label": "YouTube-API-Schlüssel", - "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." - }, - "youtube_api_proxy": { - "label": "YouTube-API-Egress-Proxy", - "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." - }, - "google_client_id": { - "label": "Google-Client-ID", - "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." - }, - "google_client_secret": { - "label": "Google-Client-Secret", - "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." - }, - "allow_registration": { - "label": "Registrierung erlauben", - "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." - }, - "explore_grace_days": { - "label": "Erkunden — Karenzzeit (Tage)", - "hint": "Wie lange ein erkundeter, nicht abonnierter Kanal (und seine flüchtigen Videos) behalten wird, bevor die Aufräumaufgabe ihn entfernt. Ein Abo behält ihn dauerhaft." - }, - "search_grace_days": { - "label": "Suchergebnisse — Karenzzeit (Tage)", - "hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt." - }, - "download_total_max_bytes": { - "label": "Gesamt-Cache-Limit (Bytes)", - "hint": "Maximale Gesamtgröße über die Downloads aller Nutzer. 0 = unbegrenzt (die Festplatte ist die Grenze); bei Überschreitung entfernt die GC die am längsten ungenutzten Dateien." - }, - "download_default_max_bytes": { - "label": "Standard-Kontingent pro Nutzer (Bytes)", - "hint": "Wie viel Speicher ein Nutzer belegen darf, bevor neue Downloads blockiert werden. Eine Überschreibung pro Nutzer hat Vorrang. Standard 5 GiB." - }, - "download_default_max_concurrent": { - "label": "Gleichzeitige Downloads / Nutzer", - "hint": "Wie viele Downloads ein Nutzer gleichzeitig ausführen darf." - }, - "download_default_max_jobs": { - "label": "Job-Limit / Nutzer", - "hint": "Maximale Anzahl an Downloads, die ein Nutzer in seiner Bibliothek behalten darf." - }, - "download_retention_days": { - "label": "Aufbewahrung (Tage)", - "hint": "Ein Download läuft so viele Tage nach dem letzten Zugriff ab; die Aufräumaufgabe entfernt ihn dann (sobald nichts mehr darauf verweist) nach der Karenzzeit." - }, - "download_gc_grace_days": { - "label": "Aufräum-Karenzzeit (Tage)", - "hint": "Zusätzliche Tage, die ein abgelaufener Download vor dem Löschen behalten wird." - }, - "download_layout": { - "label": "Ablage-Layout", - "hint": "„plex“ = ein Kanal/Staffel JJJJ/…-Baum mit .nfo + Poster (in Plex indexierbar); „flat“ = ein einzelner Ordner." - }, - "download_worker_concurrency": { - "label": "Worker-Parallelität", - "hint": "Wie viele Downloads der Worker parallel verarbeitet." - }, - "sponsorblock_default": { - "label": "SponsorBlock standardmäßig", - "hint": "SponsorBlock (Sponsor-Segmente überspringen) für neu erstellte benutzerdefinierte Download-Profile aktivieren." - }, - "plex_enabled": { - "label": "Plex aktivieren", - "hint": "Zeigt die Plex-Bibliothek als Feed-Quelle mit einem umfangreichen Player. Erfordert, dass der Plex-Server von diesem Host erreichbar ist und die Medien auf einem lokalen Mount liegen (siehe Pfad-Zuordnung)." - }, - "plex_server_url": { - "label": "Plex-Server-URL", - "hint": "Von diesem Host erreichbare Basis-URL, z. B. http://192.168.1.100:32400." - }, - "plex_server_token": { - "label": "Plex-Server-Token", - "hint": "Der Admin-X-Plex-Token des Servers. Nur serverseitig genutzt (Metadaten + Bilder); wird nie an den Browser gesendet. Verschlüsselt gespeichert; nur schreibbar." - }, - "plex_path_map": { - "label": "Medien-Pfad-Zuordnung", - "hint": "Ordnet die Plex-Dateipfade dem lokalen Mount dieses Hosts zu, damit Siftlode die physische Datei abspielen kann. Durch Zeilenumbruch/Komma getrennte plexPrefix=localPrefix-Paare, z. B. /data=/plex-media. Leer = auf beiden Seiten identisch." - }, - "plex_libraries": { - "label": "Angezeigte Bibliotheken", - "hint": "Durch Kommas getrennte Plex-Sektionsschlüssel. Leer = alle Sektionen. Nutze nach dem Verbindungstest die Bibliotheksauswahl unten." - }, - "plex_sync_interval_min": { - "label": "Sync-Intervall (Minuten)", - "hint": "Wie oft die Katalog-Sync-Aufgabe die Plex-Metadaten spiegelt." - }, - "plex_max_transcodes": { - "label": "Max. gleichzeitige Transcodes", - "hint": "Obergrenze für gleichzeitige CPU-Transcodes inkompatibler Dateien (späterer Fallback). Direkt abspielbare Dateien sind nicht begrenzt." - } - }, - "save": "Speichern", - "saving": "Speichern…", - "saved": "Gespeichert", - "saveFailed": "Speichern fehlgeschlagen", - "unsaved": "Nicht gespeicherte Änderungen", - "discard": "Verwerfen", - "willReset": "wird beim Speichern auf den Standard zurückgesetzt", - "reset": "Zurücksetzen", - "resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.", - "resetDone": "Auf Standard zurückgesetzt", - "usingDefault": "Standard wird verwendet", - "overridden": "in der DB überschrieben", - "secretSet": "•••••••• gespeichert", - "secretEnv": "Env-Wert wird verwendet", - "secretUnset": "nicht gesetzt", - "secretsDisabled": "Setze TOKEN_ENCRYPTION_KEY, um Secrets in der Datenbank zu speichern.", - "testEmail": "Test-E-Mail senden", - "testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.", - "testSent": "Test-E-Mail gesendet an {{to}}", - "testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen", - "plexTest": "Verbindung testen", - "plexTestHint": "Prüft die Plex-Server-URL + den Token und lädt die Bibliotheksliste. Speichere zuerst deine Änderungen.", - "plexTestDirty": "Speichere deine Änderungen vor dem Verbindungstest.", - "plexTestOk": "Verbunden mit {{name}}", - "plexTestFailed": "Plex-Verbindung fehlgeschlagen — prüfe URL und Token", - "plexConnected": "Verbunden mit {{name}} {{version}}", - "plexMediaOk": "Medieneinbindung OK — eine Beispieldatei wurde auf der Festplatte gefunden.", - "plexMediaMissing": "Medien unter {{path}} nicht lesbar — prüfe, ob die Plex-Medien in den Container eingebunden sind und die Pfadzuordnung stimmt. Die Wiedergabe schlägt fehl, bis dies behoben ist.", - "plexPickLibraries": "Anzuzeigende Bibliotheken:", - "plexPickHint": "Keine auszuwählen entspricht dem Anzeigen aller Bibliotheken. Nicht vergessen zu speichern.", - "plexMovies": "Filme", - "plexShows": "Serien" -} diff --git a/frontend/src/i18n/locales/de/datatable.json b/frontend/src/i18n/locales/de/datatable.json deleted file mode 100644 index 5e67f41..0000000 --- a/frontend/src/i18n/locales/de/datatable.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "filter": "Filtern", - "clear": "Löschen", - "all": "Alle", - "rowsPerPage": "Zeilen pro Seite", - "noOptions": "Keine Optionen", - "empty": "Nichts anzuzeigen.", - "pager": { - "prev": "Zurück", - "next": "Weiter", - "page": "Seite {{page}} von {{total}}", - "pageLabel": "Seite", - "ofTotal": "von {{total}}" - } -} diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json deleted file mode 100644 index 001f96e..0000000 --- a/frontend/src/i18n/locales/de/downloads.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "navLabel": "Downloads", - "button": { - "label": "Herunterladen", - "queued": "In Warteschlange", - "downloaded": "Heruntergeladen" - }, - "dialog": { - "title": "Video herunterladen", - "profile": "Format", - "nameLabel": "Dateiname (optional)", - "namePlaceholder": "Leer lassen, um den Videotitel zu verwenden", - "submit": "Zu Downloads hinzufügen", - "added": "Zu Downloads hinzugefügt", - "view": "Öffnen", - "manage": "Formate verwalten" - }, - "page": { - "title": "Downloads", - "subtitle": "Videos auf den Server laden und dann auf dein Gerät speichern.", - "addUrl": "YouTube-Link oder Video-ID einfügen…", - "add": "Hinzufügen", - "manage": "Formate verwalten" - }, - "tabs": { - "queue": "Warteschlange", - "done": "Bibliothek", - "shared": "Mit mir geteilt", - "system": "System" - }, - "status": { - "queued": "Wartet", - "running": "Lädt", - "paused": "Pausiert", - "done": "Fertig", - "error": "Fehlgeschlagen", - "canceled": "Abgebrochen", - "expired": "Abgelaufen" - }, - "phase": { - "video": "Video wird geladen", - "audio": "Audio wird geladen", - "merging": "Zusammenführen", - "audio_extract": "Audio wird extrahiert", - "thumbnail": "Vorschaubild einbetten", - "sponsorblock": "Sponsoren entfernen", - "metadata": "Metadaten schreiben", - "processing": "Verarbeitung", - "editing": "Bearbeitung" - }, - "actions": { - "pause": "Pause", - "resume": "Fortsetzen", - "cancel": "Abbrechen", - "delete": "Entfernen", - "retry": "Erneut", - "download": "Auf mein Gerät speichern", - "rename": "Umbenennen", - "editDetails": "Details bearbeiten", - "share": "Teilen", - "edit": "Bearbeiten / schneiden", - "removeShared": "Aus meiner Liste entfernen" - }, - "empty": { - "queue": "Die Warteschlange ist leer.", - "done": "Noch keine Downloads.", - "shared": "Es wurde nichts mit dir geteilt.", - "admin": "Auf dieser Instanz gibt es noch keine Downloads." - }, - "usage": { - "title": "Dein Speicher", - "items": "{{used}} / {{max}} Elemente", - "unlimited": "Unbegrenzt" - }, - "source": { - "open": "Originalseite in neuem Tab öffnen", - "copy": "Quell-Link kopieren", - "copied": "Quell-Link in die Zwischenablage kopiert", - "copyFailed": "Link konnte nicht kopiert werden" - }, - "edit": { - "title": "Details bearbeiten", - "name": "Titel", - "channel": "Kanal", - "channelUrl": "Kanal-Link", - "extraLinks": "Weitere Links", - "addLink": "Link hinzufügen", - "removeLink": "Link entfernen", - "hint": "Nur Anzeige — die gespeicherte Datei behält ihren Namen. Kanal und Links erscheinen auch auf der Karte und auf geteilten Wiedergabeseiten.", - "save": "Speichern" - }, - "share": { - "title": "Download teilen", - "label": "E-Mail des Empfängers", - "placeholder": "user@example.com", - "submit": "Teilen", - "done": "Geteilt mit {{email}}", - "failed": "Teilen fehlgeschlagen.", - "userSection": "Mit einem Nutzer teilen", - "userHint": "Erscheint bei ihm unter „Mit mir geteilt”.", - "pickPlaceholder": "Nutzer suchen…", - "noUsers": "Keine weiteren Nutzer.", - "linkSection": "Per Link teilen", - "linkHint": "Jeder mit dem Link kann zusehen — ohne Konto.", - "noLinks": "Noch keine Links.", - "newLink": "Link erstellen", - "createLink": "Erstellen", - "allowDownload": "Download erlauben", - "expiry": "Läuft ab", - "expiryNever": "Nie", - "days": "{{n}} Tage", - "password": "Passwort (optional)", - "copy": "Link kopieren", - "copyFailed": "Kopieren fehlgeschlagen.", - "revoke": "Widerrufen", - "revokeConfirm": "Der Link funktioniert sofort nicht mehr.", - "views": "{{n}} Aufrufe", - "expiresOn": "läuft ab {{date}}", - "hasPassword": "Passwort", - "downloadable": "herunterladbar" - }, - "confirm": { - "cancelTitle": "Download abbrechen?", - "cancelBody": "Dies stoppt den Download und entfernt ihn aus deiner Warteschlange.", - "deleteTitle": "Download entfernen?", - "deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben.", - "removeSharedTitle": "Aus deiner Liste entfernen?", - "removeSharedBody": "Entfernt es nur aus deiner geteilten Liste — die Datei des Besitzers wird nicht gelöscht." - }, - "profiles": { - "manage": "Formate verwalten", - "title": "Download-Formate", - "builtin": "Integriert", - "yours": "Deine Formate", - "new": "Neues Format", - "name": "Name", - "namePlaceholder": "Mein Format", - "mode": "Inhalt", - "modeAv": "Video + Audio", - "modeV": "Nur Video", - "modeA": "Nur Audio", - "quality": "Max. Qualität", - "best": "Beste", - "container": "Container", - "audioFormat": "Audioformat", - "vcodec": "Video-Codec", - "any": "Beliebig", - "embedSubs": "Untertitel einbetten", - "embedChapters": "Kapitel einbetten", - "embedThumbnail": "Vorschaubild einbetten", - "sponsorblock": "Sponsor-Segmente überspringen (SponsorBlock)", - "save": "Speichern", - "create": "Erstellen", - "delete": "Löschen", - "deleteConfirm": "Das Format „{{name}}“ löschen?" - }, - "admin": { - "storageTitle": "Speicher", - "readyFiles": "Fertige Dateien", - "totalSize": "Gesamtgröße", - "cap": "Cache-Limit", - "noCap": "kein Limit", - "perUser": "Speicher pro Nutzer", - "user": "Nutzer", - "footprint": "Belegung", - "editQuota": "Kontingent bearbeiten", - "quotaTitle": "Download-Kontingent — {{email}}", - "maxBytes": "Speicherlimit (GB)", - "maxJobs": "Max. Downloads", - "maxConcurrent": "Max. gleichzeitig", - "unlimited": "Unbegrenzt (Speicherlimit umgehen)", - "reset": "Auf Standard zurücksetzen", - "save": "Speichern", - "custom": "individuell", - "default": "Standard", - "quotaPickPlaceholder": "Kontingent für einen Nutzer festlegen…", - "noFootprint": "Noch keine Downloads." - }, - "cols": { - "title": "Titel", - "channel": "Kanal", - "format": "Format", - "size": "Größe", - "status": "Status", - "added": "Hinzugefügt", - "user": "Nutzer" - }, - "clipBadge": "Clip", - "errors": { - "quota": { - "max_bytes": "Dein Download-Speicherkontingent ist aufgebraucht ({{size}} GB). Gib zuerst Speicher frei.", - "max_jobs": "Du hast dein Download-Limit erreicht ({{limit}} Objekte). Entferne zuerst einige.", - "generic": "Download-Kontingent überschritten." - }, - "edit": { - "empty_edit": "Wähle zuerst einen Zuschneidebereich oder Ausschnitt.", - "source_not_ready": "Der Quell-Download ist noch nicht bearbeitbar.", - "generic": "Dieser Schnitt konnte nicht erstellt werden." - } - } -} diff --git a/frontend/src/i18n/locales/de/editor.json b/frontend/src/i18n/locales/de/editor.json deleted file mode 100644 index e82903c..0000000 --- a/frontend/src/i18n/locales/de/editor.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "title": "Bearbeiten: „{{name}}“", - "playPause": "Wiedergabe / Pause", - "setIn": "Start hier", - "setOut": "Ende hier", - "in": "Start", - "out": "Ende", - "selected": "Auswahl", - "cropOff": "Zuschneiden", - "cropOn": "Zuschneiden aktiv", - "cropReencode": "Zuschneiden kodiert immer neu (bildgenau).", - "split": "Aufteilen in", - "accurate": { - "label": "Genauer Schnitt", - "hint": "Bildgenau. Kodiert den Clip neu." - }, - "fast": { - "label": "Schneller Schnitt", - "hint": "Sofort, schneidet aber an Keyframes (±1–2 s)." - }, - "nameLabel": "Clip-Name (optional)", - "willReencode": "Wird neu kodiert", - "willCopy": "Sofort (Stream-Kopie)", - "create": "Clip erstellen", - "createN": "{{count}} Clips erstellen", - "created_one": "{{count}} Clip zu deinen Downloads hinzugefügt", - "created_other": "{{count}} Clips zu deinen Downloads hinzugefügt", - "failed": "Clip konnte nicht erstellt werden.", - "splitHere": "Hier teilen", - "output": { - "label": "Ausgabe", - "separate": "Einzelne Dateien", - "join": "Zu einer zusammenfügen" - }, - "segment": "Segment {{n}}", - "keep": "Behalten", - "drop": "Verwerfen", - "kept": "Behalten", - "dropped": "Verworfen", - "keptSeg": "Behaltenes Segment", - "droppedSeg": "Verworfenes Segment", - "removeCut": "Schnitt entfernen", - "keptSummary": "{{n}} behalten · {{dur}}", - "createJoin": "Zusammengefügten Clip erstellen" -} diff --git a/frontend/src/i18n/locales/de/errors.json b/frontend/src/i18n/locales/de/errors.json deleted file mode 100644 index 8b09923..0000000 --- a/frontend/src/i18n/locales/de/errors.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "title": "Nicht möglich", - "generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.", - "server": "Serverfehler", - "ok": "OK", - "offline": { - "title": "Verbindung verloren", - "body": "Server nicht erreichbar — er startet möglicherweise gerade neu. Dieser Hinweis verschwindet, sobald die Verbindung wieder steht." - }, - "boundary": { - "title": "Etwas ist schiefgelaufen.", - "subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.", - "reload": "Neu laden", - "notifTitle": "Etwas ist kaputtgegangen", - "notifMessage": "Unerwarteter Fehler" - } -} diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json deleted file mode 100644 index 8008e5a..0000000 --- a/frontend/src/i18n/locales/de/feed.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "loading": "Feed wird geladen…", - "loadError": "Feed konnte nicht geladen werden.", - "emptyTitle": "Dein Feed ist leer", - "emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.", - "setUp": "Meinen Feed einrichten", - "browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen", - "demoEmptyTitle": "Hier in der Demo ist nichts", - "demoEmptyBody": "Das Demo-Konto hat keine eigenen Abos — aber die gesamte gemeinsame Bibliothek steht dir offen: durchsuchen, filtern und sortieren.", - "browseLibrary": "Bibliothek durchsuchen", - "noMatches": "Keine Videos entsprechen diesen Filtern.", - "videoCount_one": "{{formattedCount}} Video", - "videoCount_other": "{{formattedCount}} Videos", - "sortLabel": "Sortierung", - "dirAsc": "Aufsteigend", - "dirDesc": "Absteigend", - "sortKey": { - "date": "Datum", - "popular": "Beliebt", - "duration": "Dauer", - "title": "Name", - "subscribers": "Kanal-Abonnenten", - "priority": "Kanal-Priorität", - "shuffle": "Überraschung", - "relevance": "Relevanz" - }, - "loadingMore": "Mehr wird geladen…", - "hiddenNamed": "Ausgeblendet: „{{title}}”", - "hidden": "Video ausgeblendet", - "undo": "Rückgängig", - "markedWatchedNamed": "Als angesehen markiert: „{{title}}”", - "markedWatched": "Als angesehen markiert", - "unwatch": "Nicht mehr als angesehen", - "source": { - "label": "Quelle", - "tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.", - "organic": "Ohne Suchergebnisse", - "all": "Mit Suchergebnissen", - "only": "Nur Suchergebnisse", - "plex": "Plex" - }, - "yt": { - "searchFor": "Auf YouTube suchen nach „{{query}}”", - "resultsFor": "YouTube-Ergebnisse für „{{query}}”", - "quotaNote": "Live-Ergebnisse — verbraucht gemeinsames API-Kontingent", - "freeNote": "Live-Ergebnisse — kein Suchkontingent verbraucht", - "back": "Zurück zum Feed", - "searching": "Suche auf YouTube…", - "noResults": "Keine YouTube-Videos gefunden.", - "error": "YouTube-Suche fehlgeschlagen.", - "loadMore": "Mehr laden (verbraucht Kontingent)", - "loadMoreFree": "Mehr laden", - "show": "Zeigen", - "ephemeralNote": "Diese Ergebnisse sind temporär — sie verschwinden automatisch, außer du siehst dir eines an oder speicherst es.", - "clearNow": "Jetzt löschen", - "cleared_one": "{{count}} Ergebnis gelöscht", - "cleared_other": "{{count}} Ergebnisse gelöscht", - "showHint": "Wie viele Ergebnisse auf einmal — Mehr laden holt einen weiteren Block dieser Größe." - } -} diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json deleted file mode 100644 index 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“-Bildschirm — das ist zu erwarten, da Siftlode die vollständige Überprüfung von Google nicht durchlaufen hat. Es ist sicher fortzufahren: klicke auf <1>Erweitert → <2>Weiter zu Siftlode. Du kannst den Zugriff jederzeit in deinem <3>Google-Konto widerrufen.", - "read": { - "title": "Verbinde deine YouTube-Abos", - "body": "Du bist angemeldet. Um deinen Feed aufzubauen, benötigt Siftlode <0>schreibgeschützten Zugriff auf die Kanäle, die du auf YouTube abonniert hast. Es postet, löscht oder ändert damit niemals etwas.", - "connect": "Verbinden (schreibgeschützt)", - "skip": "Vorerst überspringen" - }, - "importing": { - "title": "Dein Feed wird aufgebaut…", - "body": "Deine YouTube-Abos werden importiert. Bereits in Siftlode vorhandene Kanäle erscheinen sofort; neue werden automatisch im Hintergrund ergänzt." - }, - "write": { - "title": "Du bist verbunden", - "importFailed": "Dein YouTube-Konto ist verbunden, aber der Import der Abos ist fehlgeschlagen — du kannst es unter Einstellungen → Synchronisierung erneut versuchen. ", - "feedReady": "Dein Feed ist bereit{{channels}}. Optional kannst du Siftlode erlauben, ", - "feedReadyChannels": " ({{count}} Kanäle)", - "rationale": "Kanäle auf YouTube für dich <0>abzubestellen — dafür ist eine zusätzliche Schreibberechtigung nötig. Überspringe es, um schreibgeschützt zu bleiben; du kannst es später jederzeit in den Einstellungen aktivieren.", - "retryImport": "Import erneut versuchen", - "enableUnsubscribe": "Abbestellen aktivieren", - "keepReadOnly": "Nein danke — schreibgeschützt bleiben" - }, - "done": { - "title": "Alles erledigt", - "body": "Lese- und Abbestell-Zugriff sind beide erteilt. Du kannst beide jederzeit unter Einstellungen → Konto oder in deinem Google-Konto überprüfen oder widerrufen.", - "done": "Fertig" - } -} diff --git a/frontend/src/i18n/locales/de/player.json b/frontend/src/i18n/locales/de/player.json deleted file mode 100644 index 26ef23f..0000000 --- a/frontend/src/i18n/locales/de/player.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "loading": "Wird geladen…", - "backToOriginal": "Zurück zum ursprünglichen Video", - "back": "Zurück", - "previous": "Vorheriges", - "next": "Nächstes", - "autoAdvance": { - "label": "Auto", - "hint": "Was am Videoende folgt. Klicken zum Wechseln: Aus, Nächstes, Vorheriges, Zufall. In deinem Konto gespeichert.", - "off": "Aus", - "next": "Nächstes", - "prev": "Vorheriges", - "random": "Zufall" - }, - "loop": { - "label": "Wiederholen", - "hint": "Aktuelles Video wiederholen (Eins), ganze Liste wiederholen (Alle) oder keines (Aus). In deinem Konto gespeichert.", - "off": "Aus", - "one": "Eins", - "all": "Alle" - }, - "close": "Schließen", - "closeEsc": "Schließen (Esc)", - "description": "Beschreibung", - "noDescription": "Keine Beschreibung.", - "channel": "Kanal", - "views": "{{formattedCount}} Aufrufe", - "stream": "Stream", - "watched": "Angesehen", - "markWatched": "Als angesehen markieren", - "watchedUnmark": "Angesehen — zum Aufheben klicken", - "jumpToTime": "Zu dieser Stelle springen", - "playInApp": "Im integrierten Player abspielen", - "unavailableTitle": "Hier nicht abspielbar", - "unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.", - "embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.", - "openOnYoutube": "Auf YouTube öffnen", - "shortcutsHint": "Klick: Wiedergabe/Pause · Scrollen: Lautstärke · F: Vollbild", - "nativeControls": "YouTube-Steuerung aktiv" -} diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json deleted file mode 100644 index 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" ? :