Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-13 05:16:36 +02:00
commit c8385e4475
98 changed files with 2419 additions and 2994 deletions

View file

@ -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 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. 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. - **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, - **Self-hosted & private**, with a first-run web setup wizard and the interface in **English
Hungarian and German**. and Hungarian**.
## Quick start (self-hosting) ## Quick start (self-hosting)

View file

@ -1 +1 @@
0.41.0 0.42.0

View file

@ -364,7 +364,7 @@ async def callback(
prefs = dict(user.preferences or {}) prefs = dict(user.preferences or {})
if not prefs.get("language"): if not prefs.get("language"):
loc = (userinfo.get("locale") or "").split("-")[0].lower() 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 user.preferences = prefs
db.flush() db.flush()

View file

@ -51,14 +51,6 @@ WELCOME = {
"jelszóval.\n\n" "jelszóval.\n\n"
"Jó böngészést!" "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!"
),
} }

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f5245" stop-opacity=".95"/><stop offset="100%" stop-color="#0f5245" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#134a2c" stop-opacity=".9"/><stop offset="100%" stop-color="#134a2c" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0d3a52" stop-opacity=".85"/><stop offset="100%" stop-color="#0d3a52" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#06120e"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7fd0bf" stop-opacity=".9"/><stop offset="100%" stop-color="#7fd0bf" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#8fc9a5" stop-opacity=".85"/><stop offset="100%" stop-color="#8fc9a5" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#88c2d6" stop-opacity=".8"/><stop offset="100%" stop-color="#88c2d6" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#e9f5f0"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd8ae" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd8ae" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#7fc9c9" stop-opacity=".85"/><stop offset="100%" stop-color="#7fc9c9" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#96d69c" stop-opacity=".8"/><stop offset="100%" stop-color="#96d69c" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#eaf5ec"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#a9bcf3" stop-opacity=".9"/><stop offset="100%" stop-color="#a9bcf3" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#6d8cff" stop-opacity=".5"/><stop offset="100%" stop-color="#6d8cff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b3a8ec" stop-opacity=".85"/><stop offset="100%" stop-color="#b3a8ec" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#9fd6e0" stop-opacity=".8"/><stop offset="100%" stop-color="#9fd6e0" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#edf1fc"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e6c393" stop-opacity=".9"/><stop offset="100%" stop-color="#e6c393" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#dba98c" stop-opacity=".85"/><stop offset="100%" stop-color="#dba98c" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#cabf9e" stop-opacity=".8"/><stop offset="100%" stop-color="#cabf9e" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#f4efe6"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fb6e8" stop-opacity=".9"/><stop offset="100%" stop-color="#8fb6e8" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#94a0e0" stop-opacity=".85"/><stop offset="100%" stop-color="#94a0e0" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#8fc3d2" stop-opacity=".8"/><stop offset="100%" stop-color="#8fc3d2" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#e9f1fc"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#f0a3aa" stop-opacity=".85"/><stop offset="100%" stop-color="#f0a3aa" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff5b64" stop-opacity=".4"/><stop offset="100%" stop-color="#ff5b64" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#e096a8" stop-opacity=".8"/><stop offset="100%" stop-color="#e096a8" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#eec2c2" stop-opacity=".8"/><stop offset="100%" stop-color="#eec2c2" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#faeef0"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f4a30" stop-opacity=".95"/><stop offset="100%" stop-color="#0f4a30" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#0c3a3a" stop-opacity=".9"/><stop offset="100%" stop-color="#0c3a3a" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#164a26" stop-opacity=".85"/><stop offset="100%" stop-color="#164a26" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#020805"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#2a4aa0" stop-opacity=".95"/><stop offset="100%" stop-color="#2a4aa0" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#6d8cff" stop-opacity=".5"/><stop offset="100%" stop-color="#6d8cff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3b2f80" stop-opacity=".9"/><stop offset="100%" stop-color="#3b2f80" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#145e6b" stop-opacity=".85"/><stop offset="100%" stop-color="#145e6b" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#070b16"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a4d18" stop-opacity=".95"/><stop offset="100%" stop-color="#7a4d18" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#5a2f14" stop-opacity=".9"/><stop offset="100%" stop-color="#5a2f14" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#403820" stop-opacity=".85"/><stop offset="100%" stop-color="#403820" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#141009"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#123b6b" stop-opacity=".95"/><stop offset="100%" stop-color="#123b6b" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2a72" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2a72" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0f5566" stop-opacity=".85"/><stop offset="100%" stop-color="#0f5566" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#040914"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a1620" stop-opacity=".9"/><stop offset="100%" stop-color="#7a1620" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff3b46" stop-opacity=".4"/><stop offset="100%" stop-color="#ff3b46" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a0d24" stop-opacity=".9"/><stop offset="100%" stop-color="#3a0d24" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2a1010" stop-opacity=".85"/><stop offset="100%" stop-color="#2a1010" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#0d0708"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -23,9 +23,11 @@ import { pageTitleKey } from "./lib/pageMeta";
import { import {
loadLayout, loadLayout,
normalizeLayout, normalizeLayout,
PREF_KEY,
saveLayoutLocal, saveLayoutLocal,
type SidebarLayout, type PanelId,
} from "./lib/sidebarLayout"; type PanelLayout,
} from "./lib/panelLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { import {
@ -49,7 +51,9 @@ import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header"; import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop"; import BackToTop from "./components/BackToTop";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock"; import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
@ -179,8 +183,18 @@ export default function App() {
})); }));
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle"); const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined); const saveMsgTimer = useRef<number | undefined>(undefined);
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(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<Record<PanelId, PanelLayout>>(() => ({
feed: loadLayout("feed"),
plex: loadLayout("plex"),
playlists: loadLayout("playlists"),
}));
const [page, setPageState] = useState<Page>(loadInitialPage); const [page, setPageState] = useState<Page>(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, // 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). // 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 // 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; document.title = label ? `${label} · ${brand}` : brand;
}, [page, channelView, i18n.language, t]); }, [page, channelView, i18n.language, t]);
function setSidebarLayout(next: SidebarLayout) { function setPanelLayout(panel: PanelId, next: PanelLayout) {
setSidebarLayoutState(next); setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(next); saveLayoutLocal(panel, next);
api.savePrefs({ sidebarLayout: next }).catch(() => {}); api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {});
} }
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the // 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>(() => const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, false)) 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>(() =>
Boolean(readAccount(LS.playlistsCollapsed, false))
);
function setNavCollapsed(next: boolean) { function setNavCollapsed(next: boolean) {
setNavCollapsedState(next); setNavCollapsedState(next);
writeAccount(LS.navCollapsed, next); writeAccount(LS.navCollapsed, next);
@ -371,6 +389,21 @@ export default function App() {
writeAccount(LS.filterCollapsed, next); writeAccount(LS.filterCollapsed, next);
api.savePrefs({ filterCollapsed: next }).catch(() => {}); 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<number | null>(() => {
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]); useEffect(() => applyTheme(theme), [theme]);
@ -380,6 +413,14 @@ export default function App() {
document.documentElement.dataset.perf = perf ? "1" : ""; document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0"); setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]); }, [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 <body> (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(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]); useEffect(() => configureNotifications(notif), [notif]);
@ -577,11 +618,15 @@ export default function App() {
setNotif(adopted.notifications); setNotif(adopted.notifications);
setSavedPrefs(adopted); setSavedPrefs(adopted);
// Out-of-scope prefs stay instant (edited outside the Settings page). // Out-of-scope prefs stay instant (edited outside the Settings page).
if (prefs.sidebarLayout) { const prefsRec = prefs as Record<string, unknown>;
const l = normalizeLayout(prefs.sidebarLayout); (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
setSidebarLayoutState(l); const raw = prefsRec[PREF_KEY[p]];
saveLayoutLocal(l); if (raw) {
const l = normalizeLayout(p, raw);
setPanelLayouts((prev) => ({ ...prev, [p]: l }));
saveLayoutLocal(p, l);
} }
});
if (typeof prefs.navCollapsed === "boolean") { if (typeof prefs.navCollapsed === "boolean") {
setNavCollapsedState(prefs.navCollapsed); setNavCollapsedState(prefs.navCollapsed);
writeAccount(LS.navCollapsed, prefs.navCollapsed); writeAccount(LS.navCollapsed, prefs.navCollapsed);
@ -590,6 +635,10 @@ export default function App() {
setFilterCollapsedState(prefs.filterCollapsed); setFilterCollapsedState(prefs.filterCollapsed);
writeAccount(LS.filterCollapsed, 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); if (isSupported(prefs.language)) setLanguage(prefs.language);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the // (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.) // dedicated effect above, which also covers accounts that have no stored preferences.)
@ -725,7 +774,7 @@ export default function App() {
); );
return ( return (
<div className="h-screen flex bg-bg text-fg"> <div className="h-screen flex text-fg">
<NavSidebar <NavSidebar
me={meQuery.data!} me={meQuery.data!}
page={page} page={page}
@ -748,8 +797,8 @@ export default function App() {
<Sidebar <Sidebar
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
layout={sidebarLayout} layout={panelLayouts.feed}
setLayout={setSidebarLayout} setLayout={(l) => setPanelLayout("feed", l)}
onFocusChannel={focusChannel} onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo} isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed} collapsed={filterCollapsed}
@ -768,11 +817,25 @@ export default function App() {
filters={plexFilters} filters={plexFilters}
setFilters={setPlexFilters} setFilters={setPlexFilters}
onOpenPlaylist={setPlexPlaylistOpen} onOpenPlaylist={setPlexPlaylistOpen}
layout={panelLayouts.plex}
setLayout={(l) => setPanelLayout("plex", l)}
collapsed={filterCollapsed} collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/> />
</Suspense> </Suspense>
)} )}
{page === "playlists" && !channelView && (
<PlaylistsRail
canWrite={meQuery.data!.can_write}
search={playlistsSearch}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
layout={panelLayouts.playlists}
setLayout={(l) => setPanelLayout("playlists", l)}
collapsed={playlistsCollapsed}
onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)}
/>
)}
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? ( {channelView ? (
<Suspense fallback={pageFallback}> <Suspense fallback={pageFallback}>
@ -794,9 +857,20 @@ export default function App() {
setFilters={setFilters} setFilters={setFilters}
plexQ={plexQ} plexQ={plexQ}
setPlexQ={setPlexQ} setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
page={page} page={page}
setPage={setPage}
onYtSearch={enterYtSearch} 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.) */}
<div
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]"
>
{meQuery.data!.is_demo && <DemoBanner />} {meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} /> <VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
@ -805,6 +879,8 @@ export default function App() {
<Channels <Channels
canWrite={meQuery.data!.can_write} canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"} isAdmin={meQuery.data!.role === "admin"}
search={channelsSearch}
setSearch={setChannelsSearch}
focusChannelName={focusChannelName} focusChannelName={focusChannelName}
focusChannelToken={focusChannelToken} focusChannelToken={focusChannelToken}
onFocusChannel={focusChannel} onFocusChannel={focusChannel}
@ -832,7 +908,11 @@ export default function App() {
) : page === "audit" && meQuery.data!.role === "admin" ? ( ) : page === "audit" && meQuery.data!.role === "admin" ? (
<AuditLog /> <AuditLog />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists
canWrite={meQuery.data!.can_write}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
/>
) : page === "notifications" ? ( ) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} /> <NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
) : page === "messages" && !meQuery.data!.is_demo ? ( ) : page === "messages" && !meQuery.data!.is_demo ? (
@ -875,6 +955,7 @@ export default function App() {
)} )}
</Suspense> </Suspense>
</main> </main>
</div>
</> </>
)} )}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail. {/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
@ -903,6 +984,8 @@ export default function App() {
<ErrorDialog /> <ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */} {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} /> <BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
{/* Local-only live appearance tuner (renders only on localhost; null everywhere else). */}
<GlassTuner />
</div> </div>
); );
} }

View file

@ -34,7 +34,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
return createPortal( return createPortal(
<button <button
onClick={toTop} onClick={toTop}
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-11 h-11 rounded-full bg-card border border-border text-fg shadow-lg hover:border-accent hover:text-accent transition-all duration-200 ${ className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-11 h-11 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none" show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`} }`}
> >

View file

@ -17,10 +17,13 @@ import { useConfirm } from "./ConfirmProvider";
// the channel's metadata — the scheduler pulls its videos on its next run (see backend). // the channel's metadata — the scheduler pulls its videos on its next run (see backend).
export default function ChannelDiscovery({ export default function ChannelDiscovery({
canWrite, canWrite,
search,
onOpenWizard, onOpenWizard,
onViewChannel, onViewChannel,
}: { }: {
canWrite: boolean; canWrite: boolean;
// Client-side name filter from the shared top SearchBar (matches title + handle).
search: string;
onOpenWizard: () => void; onOpenWizard: () => void;
onViewChannel: (id: string, name: string) => void; onViewChannel: (id: string, name: string) => void;
}) { }) {
@ -67,7 +70,10 @@ export default function ChannelDiscovery({
if (ok) subscribe.mutate(c); if (ok) subscribe.mutate(c);
}; };
const rows = query.data ?? []; const q = search.trim().toLowerCase();
const rows = (query.data ?? []).filter(
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)
);
const columns: Column<DiscoveredChannel>[] = [ const columns: Column<DiscoveredChannel>[] = [
{ {

View file

@ -12,7 +12,6 @@ import {
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
UserMinus, UserMinus,
X, X,
} from "lucide-react"; } from "lucide-react";
@ -44,6 +43,8 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
export default function Channels({ export default function Channels({
canWrite, canWrite,
isAdmin, isAdmin,
search,
setSearch,
onViewChannel, onViewChannel,
onFilterByTag, onFilterByTag,
onFocusChannel, onFocusChannel,
@ -58,6 +59,9 @@ export default function Channels({
}: { }: {
canWrite: boolean; canWrite: boolean;
isAdmin: boolean; isAdmin: boolean;
// The channel-name filter, lifted to App so the shared top SearchBar drives it.
search: string;
setSearch: (q: string) => void;
onViewChannel: (id: string, name: string) => void; onViewChannel: (id: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void; onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
@ -190,9 +194,8 @@ export default function Channels({
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
}); });
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list // Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
// (prominent controls instead of the DataTable's hidden per-column popovers). // client-side over the status-filtered list.
const [search, setSearch] = useState("");
const [tagFilter, setTagFilter] = useState<number[]>([]); const [tagFilter, setTagFilter] = useState<number[]>([]);
// A focus-channel intent (header "without full history" / tag manager) seeds the search box. // A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => { useEffect(() => {
@ -399,7 +402,12 @@ export default function Channels({
return ( return (
<> <>
{tabs} {tabs}
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} /> <ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</> </>
); );
} }
@ -482,25 +490,7 @@ export default function Channels({
/> />
</p> </p>
{/* Channel-name search */} {/* The channel-name search now lives in the shared top SearchBar (driven via props). */}
<div className="relative mb-3 max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("channels.searchPlaceholder")}
className="w-full bg-card border border-border rounded-lg pl-8 pr-8 py-2 text-sm outline-none focus:border-accent"
/>
{search && (
<button
onClick={() => setSearch("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-fg"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */} {/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
<div className="flex flex-wrap items-center gap-1.5 mb-4"> <div className="flex flex-wrap items-center gap-1.5 mb-4">

View file

@ -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 (
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
}

View file

@ -189,7 +189,7 @@ function DownloadRow({
const { t } = useTranslation(); const { t } = useTranslation();
const running = job.status === "running"; const running = job.status === "running";
return ( return (
<div className="flex gap-3 p-2.5 rounded-xl bg-card/40 hover:bg-card transition"> <div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
<Thumb job={job} /> <Thumb job={job} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5"> <div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
@ -338,7 +338,7 @@ function UsageBar() {
if (!u) return null; if (!u) return null;
const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return ( return (
<div className="rounded-xl bg-card/40 p-3 mb-4"> <div className="rounded-xl glass-card p-3 mb-4">
<div className="flex justify-between text-sm mb-1.5"> <div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span> <span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted"> <span className="text-muted">
@ -446,7 +446,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
className={inputCls} className={inputCls}
/> />
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden"> <div className="absolute z-10 mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
{filtered.map((u) => ( {filtered.map((u) => (
<button <button
key={u.id} key={u.id}
@ -482,7 +482,7 @@ function AdminSystem() {
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)], [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
[t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")], [t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")],
].map(([label, val]) => ( ].map(([label, val]) => (
<div key={label} className="rounded-xl bg-card/40 p-3"> <div key={label} className="rounded-xl glass-card p-3">
<div className="text-xs text-muted">{label}</div> <div className="text-xs text-muted">{label}</div>
<div className="text-lg font-semibold mt-0.5">{val}</div> <div className="text-lg font-semibold mt-0.5">{val}</div>
</div> </div>

View file

@ -0,0 +1,368 @@
import { useEffect, useMemo, useState } from "react";
/**
* Local-only live appearance tuner. Mounted at the app root so it's reachable on every page; it
* writes the glass CSS custom properties (index.css `:root`) inline on the document element for
* instant, build-free feedback, and persists to localStorage.
*
* Only active on localhost, so it never appears in a hosted build. A scratch tool for dialling in
* the surface values "Copy CSS" exports them to paste into index.css.
*/
const DEV_TUNER =
typeof window !== "undefined" && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
const LS_KEY = "siftlode.devGlassTuner";
type Slider = {
k: string;
label: string;
min: number;
max: number;
step: number;
unit: string;
def: number;
};
// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it.
// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css.
const SLIDERS: Slider[] = [
{ k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 10 },
{ k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
{ k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.2 },
{ k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 50 },
{ k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 60 },
{ k: "--glass-menu-alpha", label: "Menu opacity", min: 40, max: 100, step: 1, unit: "%", def: 66 },
{ k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 80 },
{ k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 25 },
{ k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 15 },
{ k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
{ k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 },
];
const PALETTE: { k: string; label: string }[] = [
{ k: "--bg", label: "Background" },
{ k: "--surface", label: "Surface" },
{ k: "--card", label: "Card" },
{ k: "--border", label: "Border" },
{ k: "--fg", label: "Text" },
{ k: "--muted", label: "Muted" },
{ k: "--accent", label: "Accent" },
];
// Each preset resets every slider to its default, then applies these overrides.
const PRESETS: { id: string; name: string; hint: string; over: Record<string, number> }[] = [
{ id: "current", name: "Glass/image", hint: "The default (over image)", over: {} },
{
id: "solid",
name: "Solid (flat)",
hint: "As shown w/ image off",
over: {
"--glass-blur": 20,
"--glass-dark-bright": 1.25,
"--glass-surface-alpha": 94,
"--glass-card-alpha": 94,
"--glass-menu-alpha": 98,
"--glass-edge": 10,
"--glass-inset": 16,
"--glass-scrim": 14,
},
},
{
id: "readable",
name: "Max readable",
hint: "Solid + strong scrim",
over: { "--glass-edge": 42, "--glass-scrim": 45, "--glass-surface-alpha": 96, "--glass-card-alpha": 96 },
},
];
const ALL = SLIDERS;
const root = () => document.documentElement;
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
type Saved = {
vars?: Record<string, number>;
palette?: Record<string, string>;
open?: boolean;
};
function loadSaved(): Saved {
try {
return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved;
} catch {
return {};
}
}
export default function GlassTuner() {
const initial = useMemo(loadSaved, []);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
return { ...base, ...(initial.vars || {}) };
});
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
const [open, setOpen] = useState<boolean>(() => initial.open ?? true);
const [tab, setTab] = useState<"glass" | "palette">("glass");
const [copied, setCopied] = useState(false);
const [themeTick, setThemeTick] = useState(0);
// Seed the colour pickers from the live scheme for display (without forcing them onto <html>).
const [swatches, setSwatches] = useState<Record<string, string>>({});
// Apply saved var overrides (and any saved palette edits) once on mount.
useEffect(() => {
Object.entries(initial.vars || {}).forEach(([k, v]) => {
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
root().style.setProperty(k, v + unit);
});
Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v));
const sw: Record<string, string> = {};
PALETTE.forEach((p) => {
const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
setSwatches(sw);
}, [initial]);
function persist(next: Partial<Saved>) {
const cur = loadSaved();
localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next }));
}
function setVar(k: string, value: number) {
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
root().style.setProperty(k, value + unit);
setVars((prev) => {
const next = { ...prev, [k]: value };
persist({ vars: next });
return next;
});
}
function setColor(k: string, hex: string) {
root().style.setProperty(k, hex);
setSwatches((s) => ({ ...s, [k]: hex }));
setPalette((prev) => {
const next = { ...prev, [k]: hex };
persist({ palette: next });
return next;
});
}
function applyPreset(over: Record<string, number>) {
const next: Record<string, number> = { ...vars };
SLIDERS.forEach((s) => {
const v = over[s.k] ?? s.def;
next[s.k] = v;
root().style.setProperty(s.k, v + s.unit);
});
setVars(next);
persist({ vars: next });
}
function reset() {
[...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k));
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
setVars(base);
setPalette({});
localStorage.removeItem(LS_KEY);
const sw: Record<string, string> = {};
PALETTE.forEach((p) => {
const v = readVar(p.k);
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
setSwatches(sw);
}
// Re-read the LIVE, theme-driven glass values into the sliders (clearing inline overrides first),
// so the panel reflects whatever theme is active — switch to light, hit this, and the opacity
// sliders show the light-theme values (index.css `html[data-theme="light"]`) ready to tune.
function syncToTheme() {
ALL.forEach((s) => root().style.removeProperty(s.k));
const next: Record<string, number> = { ...vars };
ALL.forEach((s) => {
const n = parseFloat(readVar(s.k));
if (!Number.isNaN(n)) next[s.k] = n;
});
setVars(next);
persist({ vars: {} });
setThemeTick((t) => t + 1);
}
// Export the values that differ from the baseline, headered for the active theme's scope so a
// light-theme tune pastes into the right block.
const cssOut = useMemo(() => {
void themeTick; // re-run when the active theme is re-synced
const lines: string[] = [];
ALL.forEach((s) => {
if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`);
});
PALETTE.forEach((p) => {
if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`);
});
const scope = root().dataset.theme === "light" ? 'html[data-theme="light"]' : ":root";
return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */";
}, [vars, palette, themeTick]);
function copy() {
navigator.clipboard?.writeText(cssOut).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
});
}
function persistOpen(v: boolean) {
setOpen(v);
persist({ open: v });
}
if (!DEV_TUNER) return null;
return (
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
{!open && (
<button
onClick={() => persistOpen(true)}
style={{ pointerEvents: "auto" }}
className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
title="Open the glass tuner (dev only)"
>
<span style={{ writingMode: "vertical-rl" }}> GLASS</span>
</button>
)}
{open && (
<div
style={{ pointerEvents: "auto" }}
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden shadow-2xl"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg"> Glass Tuner</span>
<span className="text-[10px] uppercase tracking-wider text-muted">dev</span>
<button
onClick={() => persistOpen(false)}
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
title="Collapse"
>
×
</button>
</div>
<div className="overflow-y-auto px-3 py-3 flex flex-col gap-4 text-sm">
{/* Treatment presets */}
<div className="flex flex-col gap-1.5">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Dark treatment</div>
<div className="grid grid-cols-2 gap-1.5">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => applyPreset(p.over)}
title={p.hint}
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
>
<b className="block font-semibold">{p.name}</b>
<span className="text-muted text-[10px]">{p.hint}</span>
</button>
))}
</div>
</div>
{/* Tab switch */}
<div className="flex gap-1.5">
{(["glass", "palette"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
tab === t ? "bg-accent text-[color:var(--accent-fg)] font-semibold" : "glass-card text-muted"
}`}
>
{t}
</button>
))}
</div>
{tab === "glass" && (
<div className="flex flex-col gap-2.5">
{SLIDERS.map((s) => (
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
))}
</div>
)}
{tab === "palette" && (
<div className="flex flex-col gap-2">
<p className="text-[10px] text-muted leading-relaxed">
Overrides the live scheme (inline on &lt;html&gt;). Reset clears these back to the scheme.
</p>
{PALETTE.map((p) => (
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
<input
type="color"
value={swatches[p.k] || "#000000"}
onChange={(e) => setColor(p.k, e.target.value)}
className="w-8 h-6 rounded border border-border bg-transparent cursor-pointer"
/>
<span className="flex-1">{p.label}</span>
<code className="text-[10px] text-muted">{swatches[p.k]}</code>
</label>
))}
</div>
)}
{/* Export */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Export bake in</div>
<button
onClick={syncToTheme}
title="Load the values of the theme you're currently viewing (light or dark) into the sliders"
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-xs text-fg text-left"
>
Sync to current theme
</button>
<div className="flex gap-1.5">
<button
onClick={copy}
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-[color:var(--accent-fg)]"
>
{copied ? "Copied ✓" : "Copy CSS"}
</button>
<button onClick={reset} className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg">
Reset
</button>
</div>
<pre className="text-[9.5px] leading-relaxed bg-[color:var(--bg)] border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
{cssOut}
</pre>
</div>
</div>
</div>
)}
</div>
);
}
function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) {
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 gap-y-1">
<label className="text-xs text-fg">{s.label}</label>
<input
type="number"
min={s.min}
max={s.max}
step={s.step}
value={value}
onChange={(e) => 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"
/>
<input
type="range"
min={s.min}
max={s.max}
step={s.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="col-span-2 w-full accent-accent"
/>
</div>
);
}

View file

@ -1,20 +1,28 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next"; 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 { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta"; import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
import PageTitle from "./PageTitle"; import ModuleName from "./ModuleName";
import SearchBar from "./SearchBar";
// Contextual top bar over the content column. Primary navigation, account and the per-user sync // The floating top header: a fixed-position row of glass pills over the content — a ModuleName
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar. // pill (label + accent dot + back/forward arrows) and, where a module searches, a SearchBar pill.
// The header carries just the feed search (or the current page title). // 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({ export default function Header({
me, me,
filters, filters,
setFilters, setFilters,
plexQ, plexQ,
setPlexQ, setPlexQ,
channelsSearch,
setChannelsSearch,
playlistsSearch,
setPlaylistsSearch,
page, page,
setPage,
onYtSearch, onYtSearch,
}: { }: {
me: Me; 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. // Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
plexQ: string; plexQ: string;
setPlexQ: (q: string) => void; setPlexQ: (q: string) => void;
channelsSearch: string;
setChannelsSearch: (q: string) => void;
playlistsSearch: string;
setPlaylistsSearch: (q: string) => void;
page: Page; page: Page;
// Trigger a live YouTube search for the current term (hidden for the demo account, which setPage: (p: Page) => void;
// can't spend the shared quota). // 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; onYtSearch: (q: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const canSearchYt = !me.is_demo; // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
// The search box serves both the YouTube feed and the Plex module (integrated search); the live // it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
// YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed // dynamic width so the pill is sized to the widest reachable title in the current language.
// the persisted `filters.q`. const order = moduleOrder(me);
const isSearchPage = page === "feed" || page === "plex"; const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
const isPlex = page === "plex"; const multiModule = order.length > 1;
const isYtCapable = page === "feed" && canSearchYt; // Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const searchValue = isPlex ? plexQ : filters.q; const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
const trimmedQ = searchValue.trim();
return ( // Per-page SearchBar wiring — null for modules without a search.
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20"> let search: ReactNode = null;
{isSearchPage ? ( if (page === "feed") {
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2"> const trimmed = filters.q.trim();
<div className="flex-1 relative"> search = (
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> <SearchBar
<input value={filters.q}
value={searchValue} placeholder={t("header.searchPlaceholder")}
onChange={(e) => { onChange={(q) => {
const q = e.target.value; // When a search first appears, rank the feed by relevance — set atomically with the
if (isPlex) { // query (race-free vs. per-keystroke updates). Only overrides the default "newest"
setPlexQ(q); // sort; a custom sort the user chose is left alone. Cleared in Feed.
return; const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
}
// 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" } : {}) }); setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}} }}
onKeyDown={(e) => { onGo={() => onYtSearch(trimmed)}
// On the feed, Enter escalates the typed term to a live YouTube search (the box goLabel={t("header.searchYoutube")}
// itself filters the local catalog / Plex library as you type). goTitle={t("header.searchYoutubeTip")}
if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ); goIcon={<Youtube className="w-4 h-4" />}
}} goDisabled={!trimmed || me.is_demo}
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" );
} else if (page === "plex" && me.plex_enabled) {
search = (
<SearchBar
value={plexQ}
placeholder={t("plex.searchPlaceholder")}
onChange={setPlexQ}
onGo={blur}
/>
);
} else if (page === "channels") {
search = (
<SearchBar
value={channelsSearch}
placeholder={t("channels.searchPlaceholder")}
onChange={setChannelsSearch}
onGo={blur}
/>
);
} else if (page === "playlists") {
search = (
<SearchBar
value={playlistsSearch}
placeholder={t("playlists.searchPlaceholder")}
onChange={setPlaylistsSearch}
onGo={blur}
/> />
{searchValue && ( );
<button }
onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
title={t("header.clearSearch")} return (
aria-label={t("header.clearSearch")} // Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) +
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition" // filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change.
> <div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[504px]">
<X className="w-4 h-4" /> <ModuleName
</button> label={t(moduleLabelKey[page])}
)} labels={moduleLabels}
</div> onPrev={() => setPage(stepModule(me, page, -1))}
{trimmedQ && isYtCapable && ( onNext={() => setPage(stepModule(me, page, 1))}
<button canPrev={multiModule}
onClick={() => onYtSearch(trimmedQ)} canNext={multiModule}
title={t("header.searchYoutubeTip")} />
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-xs font-medium text-muted hover:text-accent hover:border-accent transition" {search}
> </div>
<Youtube className="w-4 h-4" />
<span className="hidden md:inline">{t("header.searchYoutube")}</span>
</button>
)}
</div>
) : (
<div className="flex-1 flex justify-center">
<PageTitle label={t(pageTitleKey(page))} />
</div>
)}
</header>
); );
} }

View file

@ -95,7 +95,7 @@ export default function LanguageSwitcher({
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
> >
<Globe className="w-5 h-5" /> <Globe className="w-5 h-5" />
{/* 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. */} opening the menu. The ring matches the page background to detach it from the icon. */}
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg"> <span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()} {current.code.toUpperCase()}

View file

@ -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<HTMLSpanElement>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [labelW, setLabelW] = useState<number>();
// 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 (
<div className="pointer-events-auto shrink-0 max-w-[55vw] h-11 px-2.5 rounded-2xl glass-menu flex items-center gap-1.5">
<button
onClick={onPrev}
disabled={!canPrev}
title={t("header.prevModule")}
aria-label={t("header.prevModule")}
className={arrow}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={onNext}
disabled={!canNext}
title={t("header.nextModule")}
aria-label={t("header.nextModule")}
className={arrow}
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="w-px h-5 bg-border/70 mx-1 shrink-0" />
<span className="min-w-0 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
<span
ref={labelRef}
className="min-w-0 truncate"
style={labelW ? { width: labelW } : undefined}
>
{label}
</span>
</span>
</div>
);
}

View file

@ -28,6 +28,8 @@ import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState"; 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 { type LangCode } from "../i18n";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
@ -167,36 +169,46 @@ export default function NavSidebar({
}); });
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; 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 }; 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<Page, typeof Home> = {
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<Record<Page, number>> = {
notifications: unread,
messages: msgUnread,
downloads: dlActive,
};
const META = (p: Page): Omit<NavItem, "page"> => ({
icon: ICON[p],
label: t(moduleLabelKey[p]),
badge: BADGE[p],
});
const sys = new Set<Page>(SYSTEM_PAGES);
const order = moduleOrder(me);
// User-facing content modules vs. admin/system modules, separated by a divider in the rail. // User-facing content modules vs. admin/system modules, separated by a divider in the rail.
const userItems: NavItem[] = [ const userItems: NavItem[] = order.filter((p) => !sys.has(p)).map((p) => ({ page: p, ...META(p) }));
{ page: "feed", icon: Home, label: t("header.account.feed") }, const systemItems: NavItem[] = order.filter((p) => sys.has(p)).map((p) => ({ page: p, ...META(p) }));
{ 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 rowBase = const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; "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 ( return (
<nav <nav
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${ className={`glass relative shrink-0 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
collapsed ? "w-[56px] px-2" : "w-52 px-3" collapsed ? "w-[56px] px-2" : "w-52 px-3"
}`} }`}
aria-label={t("nav.primary")} aria-label={t("nav.primary")}
@ -300,7 +312,11 @@ export default function NavSidebar({
/> />
</div> </div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1"> <div
ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
style={listFade.style}
>
{userItems.map(renderItem)} {userItems.map(renderItem)}
{systemItems.length > 0 && {systemItems.length > 0 &&
(collapsed ? ( (collapsed ? (

View file

@ -320,7 +320,14 @@ function ClientActivityRow({
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>} {n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div> <div className="text-sm break-words">
{n.message}
{n.repeat > 1 && (
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
×{n.repeat}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span> <span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span>
{needsAction && ( {needsAction && (

View file

@ -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 (
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
{label}
</span>
);
}

View file

@ -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 (
<div
ref={setNodeRef}
style={style}
className={`glass-card rounded-xl ${
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
} ${hidden && editing ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5 px-3 py-2">
{editing && (
<button
{...attributes}
{...listeners}
title={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
>
<GripVertical className="w-4 h-4" />
</button>
)}
<button
onClick={editing ? undefined : onToggleCollapse}
disabled={editing}
className="flex-1 min-w-0 flex items-center text-left select-none text-xs uppercase tracking-wide text-muted enabled:hover:text-fg transition"
>
<span className="truncate">{title}</span>
</button>
{editing ? (
<button
onClick={onToggleHidden}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
>
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
) : (
<button
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`} />
</button>
)}
</div>
{showBody && <div className="px-3 pb-3">{children}</div>}
</div>
);
}

View file

@ -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 (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{renderedIds.map((id) => (
<PanelGroup
key={id}
id={id}
title={byId.get(id)!.title}
editing={editing}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
>
{byId.get(id)!.body}
</PanelGroup>
))}
</div>
</SortableContext>
</DndContext>
);
}

View file

@ -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 { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
DndContext, DndContext,
PointerSensor, PointerSensor,
@ -23,9 +23,7 @@ import {
GripVertical, GripVertical,
ListPlus, ListPlus,
Pencil, Pencil,
Pin,
Play, Play,
Plus,
RefreshCw, RefreshCw,
RotateCcw, RotateCcw,
Trash2, Trash2,
@ -33,11 +31,10 @@ import {
X, X,
Youtube, Youtube,
} from "lucide-react"; } 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 { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
@ -95,9 +92,6 @@ const idsKey = (items: Video[]) =>
.sort() .sort()
.join(","); .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({ function Row({
video, video,
index, index,
@ -123,7 +117,7 @@ function Row({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card" className="flex items-center gap-2.5 p-2 rounded-lg glass-card glass-hover"
> >
{readOnly ? ( {readOnly ? (
<span className="w-4" /> <span className="w-4" />
@ -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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
useEffect(() => {
if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
}, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
// The item order is undoable: drag, sort and group all go through `order.set`, so each is // 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 ?? []; 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({ const detailQuery = useQuery({
queryKey: ["playlist", selectedId], queryKey: ["playlist", selectedId],
queryFn: () => api.playlist(selectedId as number), queryFn: () => api.playlist(selectedId as number),
@ -301,40 +274,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const plName = (p: { kind: string; name: string }) => const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name; 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"; const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a // 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. // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
const editable = !builtin; const editable = !builtin;
const linked = editable && !!detail?.yt_playlist_id; const linked = editable && !!detail?.yt_playlist_id;
const [syncing, setSyncing] = useState(false);
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [reverting, setReverting] = 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() { function refreshAll() {
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
@ -523,120 +443,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
} }
return ( return (
<div className="flex h-full min-h-0"> <div className="px-4 pb-4">
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold">{t("playlists.title")}</span>
<button
onClick={syncYoutube}
disabled={syncing}
title={t("playlists.syncYoutube")}
aria-label={t("playlists.syncYoutube")}
className="inline-flex items-center gap-1 text-[11px] text-muted hover:text-accent disabled:opacity-40 transition"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
<Youtube className="w-3.5 h-3.5" />
</button>
</div>
{playlists.length > 1 && (
<div className="flex items-center gap-1 mb-2">
<select
value={plSort.key}
onChange={(e) =>
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
}
aria-label={t("playlists.sortLabel")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
>
<option value="custom">{t("playlists.railSortCustom")}</option>
<option value="name">{t("playlists.railSortName")}</option>
<option value="count">{t("playlists.railSortCount")}</option>
<option value="duration">{t("playlists.railSortDuration")}</option>
</select>
<button
onClick={() =>
setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })
}
disabled={plSort.key === "custom"}
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{plSort.dir === "asc" ? (
<ArrowUp className="w-3.5 h-3.5" />
) : (
<ArrowDown className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst}
className={`p-1 rounded-md border transition ${
plSort.dirtyFirst
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
<Pin className="w-3.5 h-3.5" />
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5 mb-3 pb-3 border-b border-border"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</form>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)}
<div className="space-y-1">
{sortedPlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[13px] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
</div>
</div>
</button>
))}
</div>
</aside>
<main className="flex-1 min-w-0 overflow-y-auto p-4">
{selectedId == null || !detail ? ( {selectedId == null || !detail ? (
<div className="text-muted text-sm p-4"> <div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} {selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
@ -851,7 +658,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
)} )}
</> </>
)} )}
</main>
{playingIndex != null && items[playingIndex] && ( {playingIndex != null && items[playingIndex] && (
<Suspense fallback={null}> <Suspense fallback={null}>

View file

@ -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<PlSort>(() => 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<HTMLButtonElement | null>(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: (
<div className="flex flex-col gap-2">
<button
onClick={syncYoutube}
disabled={syncing}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
{t("playlists.syncYoutube")}
</button>
{playlists.length > 1 && (
<div className="flex items-center gap-1">
<select
value={plSort.key}
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
aria-label={t("playlists.sortLabel")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
>
<option value="custom">{t("playlists.railSortCustom")}</option>
<option value="name">{t("playlists.railSortName")}</option>
<option value="count">{t("playlists.railSortCount")}</option>
<option value="duration">{t("playlists.railSortDuration")}</option>
</select>
<button
onClick={() => setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })}
disabled={plSort.key === "custom"}
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{plSort.dir === "asc" ? <ArrowUp className="w-3.5 h-3.5" /> : <ArrowDown className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst}
className={`p-1 rounded-md border transition ${
plSort.dirtyFirst
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
<Pin className="w-3.5 h-3.5" />
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => 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"
/>
</form>
</div>
),
},
{
id: "list",
title: t("playlists.listGroup"),
body: (
<>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
)}
{playlists.length > 0 && visiblePlaylists.length === 0 && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
{visiblePlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[13px] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 && ` · ${formatDuration(pl.total_duration_seconds)}`}
</div>
</div>
</button>
))}
</div>
</>
),
},
];
return (
<SidePanel
title={t("playlists.title")}
icon={<ListVideo className="w-4 h-4" />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout("playlists"))}
editHint={t("sidebar.editHint")}
>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
</SidePanel>
);
}

View file

@ -905,7 +905,7 @@ function PlexShowView({
} }
return ( return (
<div className="p-4 max-w-[1200px] mx-auto"> <div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} /> <BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isLoading || !d || !show ? ( {q.isLoading || !d || !show ? (
@ -1103,7 +1103,7 @@ function PlexSeasonView({
} }
return ( return (
<div className="p-4 max-w-[1200px] mx-auto"> <div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} /> <BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d || !se ? ( {q.isLoading || !d || !se ? (

View file

@ -90,12 +90,11 @@ export default function PlexInfo({
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
return ( return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}> <div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}> <div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */} {/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div <div
className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"} className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}
style={overlay ? undefined : { background: "color-mix(in srgb, var(--surface) 55%, transparent)" }}
> >
{/* Header: close (overlay) / customize */} {/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3"> <div className="mb-4 flex items-start gap-3">
@ -302,7 +301,6 @@ export default function PlexInfo({
{cast.length > 0 && ( {cast.length > 0 && (
<div <div
className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"} className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
> >
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}> <h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")} {t("plex.info.cast")}
@ -365,7 +363,6 @@ export default function PlexInfo({
<div <div
key={col.id} key={col.id}
className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"} className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
> >
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2> <h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>

View file

@ -1,16 +1,17 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react"; import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import CollapsedFilterRail from "./CollapsedFilterRail";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced"; 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 // The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// full metadata filter set (genre / rating / year / duration / added / content rating + the // as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State // set; shows keep library + sort. State is owned by App (per-account persisted); facets come from
// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the // the backend so the panel only offers what the library actually contains.
// backend so the sidebar only offers what the library actually contains.
type Props = { type Props = {
scope: string; // movie | show | both (unified cross-library scope) scope: string; // movie | show | both (unified cross-library scope)
@ -22,17 +23,17 @@ type Props = {
filters: PlexFilters; filters: PlexFilters;
setFilters: (f: PlexFilters) => void; setFilters: (f: PlexFilters) => void;
onOpenPlaylist: (id: number) => void; onOpenPlaylist: (id: number) => void;
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean; collapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
}; };
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] 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 SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
const RATING_STEPS = [5, 6, 7, 8, 9]; const RATING_STEPS = [5, 6, 7, 8, 9];
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; 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 }[] = [ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
{ key: "short", min: null, max: 90 * 60 }, { key: "short", min: null, max: 90 * 60 },
{ key: "mid", min: 90 * 60, max: 120 * 60 }, { key: "mid", min: 90 * 60, max: 120 * 60 },
@ -49,12 +50,15 @@ export default function PlexSidebar({
filters, filters,
setFilters, setFilters,
onOpenPlaylist, onOpenPlaylist,
layout,
setLayout,
collapsed, collapsed,
onToggleCollapse, onToggleCollapse,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState(""); const [newPlaylist, setNewPlaylist] = useState("");
const [editing, setEditing] = useState(false);
const qc = useQueryClient(); const qc = useQueryClient();
async function createPlaylist() { async function createPlaylist() {
const title = newPlaylist.trim(); const title = newPlaylist.trim();
@ -64,29 +68,18 @@ export default function PlexSidebar({
qc.invalidateQueries({ queryKey: ["plex-playlists"] }); qc.invalidateQueries({ queryKey: ["plex-playlists"] });
onOpenPlaylist(pl.id); 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 { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
const facetsQ = useQuery({ const facetsQ = useQuery({
queryKey: ["plex-facets", scope, show, facetKey], queryKey: ["plex-facets", scope, show, facetKey],
queryFn: () => api.plexFacets(scope, filters, show), queryFn: () => api.plexFacets(scope, filters, show),
enabled: !!scope, enabled: !!scope,
placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker) placeholderData: (prev) => prev,
}); });
const facets = facetsQ.data; 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 [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300); const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({ const collectionsQ = useQuery({
// "union" disambiguates from the editor's per-library ["plex-collections", <plex_key>] key (a search
// term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working.
queryKey: ["plex-collections", "union", collSearchDeb], queryKey: ["plex-collections", "union", collSearchDeb],
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection, enabled: !filters.collection,
@ -106,64 +99,42 @@ export default function PlexSidebar({
setSort("title"); 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) => const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
); );
const durBucketKey = DURATION_BUCKETS.find( const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key; )?.key;
const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0;
if (collapsed) { // Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />; // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
} // layout and lets the user reorder / hide / collapse them.
const items: PanelItem[] = [
return ( {
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4"> id: "scope",
{/* Collapse control + active-filter count mirrors the feed Sidebar so the Plex filter panel title: t("plex.filter.scope"),
can be collapsed from the Plex page too (the collapsed rail + shared state already existed). */} body: (
<div className="flex items-center gap-1.5 min-w-0"> <div className="grid grid-cols-3 gap-1">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
{activeCount > 0 && (
<span
title={t("sidebar.activeCount", { count: activeCount })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{activeCount}
</span>
)}
</div>
{/* Scope — one unified library, filtered to movies, shows, or both. */}
<Section label={t("plex.filter.scope")}>
<div className="flex gap-1">
{(["both", "movie", "show"] as const).map((s) => ( {(["both", "movie", "show"] as const).map((s) => (
<button <button
key={s} key={s}
onClick={() => setScope(s)} onClick={() => setScope(s)}
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${ className={`min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate transition ${
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover" s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`} }`}
> >
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />} {t(`plex.filter.scopeOpt.${s}`)}
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
</button> </button>
))} ))}
</div> </div>
</Section> ),
},
{/* Playlists per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not {
a filter. Available in any library. */} id: "playlists",
<Section label={t("plex.playlist.section")}> title: t("plex.playlist.section"),
body: (
<>
<div className="mb-1.5 flex gap-1.5"> <div className="mb-1.5 flex gap-1.5">
<input <input
value={newPlaylist} value={newPlaylist}
@ -198,20 +169,13 @@ export default function PlexSidebar({
))} ))}
</div> </div>
)} )}
</Section> </>
),
{anyActive && ( },
<button {
onClick={clearAll} id: "watch",
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent transition" title: t("plex.filter.show"),
> body: (
<X className="w-3.5 h-3.5" />
{t("plex.filter.clearAll")} ({activeCount})
</button>
)}
{/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
<Section label={t("plex.filter.show")}>
<ChipRow> <ChipRow>
{SHOW_OPTS.map((s) => ( {SHOW_OPTS.map((s) => (
<Chip key={s} active={show === s} onClick={() => setShow(s)}> <Chip key={s} active={show === s} onClick={() => setShow(s)}>
@ -219,10 +183,13 @@ export default function PlexSidebar({
</Chip> </Chip>
))} ))}
</ChipRow> </ChipRow>
</Section> ),
},
{/* Sort + direction */} {
<Section label={t("plex.filter.sort")}> id: "sort",
title: t("plex.filter.sort"),
body: (
<>
<ChipRow> <ChipRow>
{sorts.map((s) => ( {sorts.map((s) => (
<Chip key={s} active={sort === s} onClick={() => setSort(s)}> <Chip key={s} active={sort === s} onClick={() => setSort(s)}>
@ -237,14 +204,15 @@ export default function PlexSidebar({
</Chip> </Chip>
))} ))}
</div> </div>
</Section> </>
),
{/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */} },
{( ...(peopleActive
<> ? [
{/* Active people / studios — set by clicking the info page (stackable). */} {
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && ( id: "people",
<Section label={t("plex.filter.active")}> title: t("plex.filter.active"),
body: (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{filters.directors.map((d) => ( {filters.directors.map((d) => (
<RemovableChip <RemovableChip
@ -268,14 +236,14 @@ export default function PlexSidebar({
/> />
))} ))}
</div> </div>
</Section> ),
)} },
]
{/* Collection the active one shows as a chip; otherwise a searchable picker over the union : []),
of all enabled libraries' collections (also settable from an item info page's "Browse {
collection"). */} id: "collection",
<Section label={t("plex.filter.collection")}> title: t("plex.filter.collection"),
{filters.collection ? ( body: filters.collection ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<RemovableChip <RemovableChip
label={filters.collectionTitle || filters.collection} label={filters.collectionTitle || filters.collection}
@ -307,11 +275,12 @@ export default function PlexSidebar({
)} )}
</div> </div>
</div> </div>
)} ),
</Section> },
{
{/* IMDb rating min */} id: "rating",
<Section label={t("plex.filter.rating")}> title: t("plex.filter.rating"),
body: (
<ChipRow> <ChipRow>
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}> <Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
{t("plex.filter.any")} {t("plex.filter.any")}
@ -322,11 +291,15 @@ export default function PlexSidebar({
</Chip> </Chip>
))} ))}
</ChipRow> </ChipRow>
</Section> ),
},
{/* Genres (from facets) + Any/All when more than one is picked */} ...(facets && facets.genres.length > 0
{facets && facets.genres.length > 0 && ( ? [
<Section label={t("plex.filter.genre")}> {
id: "genre",
title: t("plex.filter.genre"),
body: (
<>
{filters.genres.length > 1 && ( {filters.genres.length > 1 && (
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]"> <div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
{(["any", "all"] as const).map((m) => ( {(["any", "all"] as const).map((m) => (
@ -355,11 +328,15 @@ export default function PlexSidebar({
</Chip> </Chip>
))} ))}
</ChipRow> </ChipRow>
</Section> </>
)} ),
},
{/* Year range */} ]
<Section label={t("plex.filter.year")}> : []),
{
id: "year",
title: t("plex.filter.year"),
body: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NumberInput <NumberInput
value={filters.yearMin ?? null} value={filters.yearMin ?? null}
@ -373,11 +350,14 @@ export default function PlexSidebar({
onChange={(v) => patch({ yearMax: v })} onChange={(v) => patch({ yearMax: v })}
/> />
</div> </div>
</Section> ),
},
{/* Duration buckets — movie-only (a show has no single runtime) */} ...(scope === "movie"
{scope === "movie" && ( ? [
<Section label={t("plex.filter.duration")}> {
id: "duration",
title: t("plex.filter.duration"),
body: (
<ChipRow> <ChipRow>
<Chip <Chip
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null} active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
@ -395,11 +375,14 @@ export default function PlexSidebar({
</Chip> </Chip>
))} ))}
</ChipRow> </ChipRow>
</Section> ),
)} },
]
{/* Added to Plex */} : []),
<Section label={t("plex.filter.added")}> {
id: "added",
title: t("plex.filter.added"),
body: (
<ChipRow> <ChipRow>
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}> <Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
{t("plex.filter.any")} {t("plex.filter.any")}
@ -410,11 +393,14 @@ export default function PlexSidebar({
</Chip> </Chip>
))} ))}
</ChipRow> </ChipRow>
</Section> ),
},
{/* Content rating (from facets) */} ...(facets && facets.content_ratings.length > 0
{facets && facets.content_ratings.length > 0 && ( ? [
<Section label={t("plex.filter.contentRating")}> {
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<ChipRow> <ChipRow>
{facets.content_ratings.map((c) => ( {facets.content_ratings.map((c) => (
<Chip <Chip
@ -426,20 +412,37 @@ export default function PlexSidebar({
</Chip> </Chip>
))} ))}
</ChipRow> </ChipRow>
</Section> ),
)} },
</> ]
)} : []),
</aside> ];
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<div> <SidePanel
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{label}</div> title={t("sidebar.filters")}
{children} icon={<SlidersHorizontal className="w-4 h-4" />}
</div> count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout("plex"))}
editHint={t("sidebar.editHint")}
actions={
anyActive ? (
<button
onClick={clearAll}
className="inline-flex items-center gap-1 text-xs text-muted hover:text-accent transition px-1 whitespace-nowrap"
>
<X className="w-3.5 h-3.5" />
{t("plex.filter.clearAll")}
</button>
) : undefined
}
>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
</SidePanel>
); );
} }

View file

@ -97,7 +97,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<div className="space-y-1.5 mb-4"> <div className="space-y-1.5 mb-4">
<div className="text-xs uppercase tracking-wide text-muted">{t("downloads.profiles.builtin")}</div> <div className="text-xs uppercase tracking-wide text-muted">{t("downloads.profiles.builtin")}</div>
{builtins.map((p) => ( {builtins.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60"> <div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card">
<Lock className="w-3.5 h-3.5 text-muted shrink-0" /> <Lock className="w-3.5 h-3.5 text-muted shrink-0" />
<span className="flex-1 truncate">{p.name}</span> <span className="flex-1 truncate">{p.name}</span>
</div> </div>
@ -106,7 +106,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<div className="text-xs uppercase tracking-wide text-muted pt-2">{t("downloads.profiles.yours")}</div> <div className="text-xs uppercase tracking-wide text-muted pt-2">{t("downloads.profiles.yours")}</div>
)} )}
{mine.map((p) => ( {mine.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60"> <div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card">
<span className="flex-1 truncate">{p.name}</span> <span className="flex-1 truncate">{p.name}</span>
<button onClick={() => startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded hover:bg-surface text-muted hover:text-fg"> <button onClick={() => startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded hover:bg-surface text-muted hover:text-fg">
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />

View file

@ -0,0 +1,70 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
export default function SearchBar({
value,
onChange,
placeholder,
onGo,
goLabel,
goIcon,
goDisabled,
goTitle,
}: {
value: string;
onChange: (q: string) => void;
placeholder: string;
// Enter / the Go button call this. Omit to hide the Go button entirely.
onGo?: () => void;
goLabel?: string;
goIcon?: ReactNode;
goDisabled?: boolean;
goTitle?: string;
}) {
const { t } = useTranslation();
const fire = () => {
if (onGo && !goDisabled) onGo();
};
return (
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2">
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fire();
}}
placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-none text-sm placeholder:text-muted"
/>
{value && (
<button
onClick={() => onChange("")}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
>
<X className="w-4 h-4" />
</button>
)}
{onGo && (
<button
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
)}
</div>
);
}

View file

@ -158,6 +158,34 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/> />
</SettingRow> </SettingRow>
<SettingRow
label={t("settings.appearance.backgroundImage")}
hint={t("settings.appearance.backgroundImageHint")}
>
<Switch
label={t("settings.appearance.backgroundImage")}
checked={theme.bgImage}
onChange={(v) => setTheme({ ...theme, bgImage: v })}
/>
</SettingRow>
{theme.bgImage && (
<div className="pl-1 pb-1">
<div className="flex items-center justify-between text-xs text-muted mb-1">
<span>{t("settings.appearance.backgroundFade")}</span>
<span className="tabular-nums">{theme.bgFade}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={theme.bgFade}
onChange={(e) => setTheme({ ...theme, bgFade: Number(e.target.value) })}
aria-label={t("settings.appearance.backgroundFade")}
className="w-full accent-accent"
/>
</div>
)}
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}> <SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch <Switch
label={t("settings.appearance.listView")} label={t("settings.appearance.listView")}

View file

@ -56,7 +56,7 @@ function UserShare({ job }: { job: DownloadJob }) {
className={inputCls} className={inputCls}
/> />
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden"> <div className="absolute z-10 mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
{filtered.map((u) => ( {filtered.map((u) => (
<button <button
key={u.id} key={u.id}
@ -110,7 +110,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
if (link.expires_at) badges.push(t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() })); if (link.expires_at) badges.push(t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }));
return ( return (
<div className="rounded-lg bg-card/40 p-2.5 space-y-2"> <div className="rounded-lg glass-card p-2.5 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link2 className="w-4 h-4 text-muted shrink-0" /> <Link2 className="w-4 h-4 text-muted shrink-0" />
<input readOnly value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none" /> <input readOnly value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none" />

View file

@ -0,0 +1,127 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronLeft, Pencil, RotateCcw } from "lucide-react";
import { useScrollFade } from "../lib/useScrollFade";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read
// as one system: a rounded glass card that floats beside the nav (aligned with the header top,
// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a
// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses
// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav).
export default function SidePanel({
title,
icon,
count = 0,
collapsed,
onToggleCollapse,
editing = false,
onToggleEditing,
onReset,
editHint,
actions,
children,
}: {
title: string;
icon: ReactNode;
count?: number;
collapsed: boolean;
onToggleCollapse: () => void;
// Customize (reorder/hide) mode — omit onToggleEditing to hide the pencil entirely.
editing?: boolean;
onToggleEditing?: () => void;
onReset?: () => void;
editHint?: string;
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
actions?: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation();
const fade = useScrollFade();
if (collapsed) {
return (
<button
onClick={onToggleCollapse}
title={t("sidebar.expand")}
aria-label={t("sidebar.expand")}
className="hidden md:flex my-3 mr-3 w-11 shrink-0 rounded-2xl glass glass-hover flex-col items-center justify-center gap-3 text-fg"
>
<span className="text-accent">{icon}</span>
{count > 0 && (
<span className="bg-accent text-accent-fg text-[10px] font-bold px-1.5 py-0.5 rounded-full tabular-nums">
{count > 9 ? "9+" : count}
</span>
)}
<span
className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{title}
</span>
</button>
);
}
return (
<aside className="hidden md:flex my-3 mr-3 w-64 shrink-0 rounded-2xl glass flex-col overflow-hidden">
{/* Row 1 identity only: collapse, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="shrink-0 flex text-accent">{icon}</span>
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
{title}
</span>
{count > 0 && (
<span
title={t("sidebar.activeCount", { count })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{count}
</span>
)}
{editing && onReset && (
<button
onClick={onReset}
title={t("sidebar.resetDefaults")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
{onToggleEditing && (
<button
onClick={onToggleEditing}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
>
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
</button>
)}
</div>
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
{!editing && actions && (
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
{actions}
</div>
)}
<div ref={fade.ref} style={fade.style} className="flex-1 min-h-0 overflow-y-auto no-scrollbar">
<div className="p-3 space-y-3">
{editing && editHint && <div className="text-[11px] text-muted">{editHint}</div>}
{children}
</div>
</div>
</aside>
);
}

View file

@ -1,47 +1,16 @@
import { useState } from "react"; import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import { Library, Pencil, Share2, SlidersHorizontal, User, X } from "lucide-react";
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 { api, type FeedFilters, type Tag } from "../lib/api"; import { api, type FeedFilters, type Tag } from "../lib/api";
import { import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
DEFAULT_LAYOUT,
type SidebarLayout,
type WidgetId,
} from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget"; 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.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [ const DATE_PRESETS: { days: number; key: string }[] = [
@ -110,12 +79,10 @@ export default function Sidebar({
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
layout: SidebarLayout; layout: PanelLayout;
setLayout: (l: SidebarLayout) => void; setLayout: (l: PanelLayout) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
isDemo?: boolean; isDemo?: boolean;
// Full-height collapse (state owned by App, persisted to the user's preferences), mirroring
// the left nav rail.
collapsed: boolean; collapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
}) { }) {
@ -123,11 +90,8 @@ export default function Sidebar({
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? []; const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context (scope, channel, date, // Live per-tag channel counts for the current filter context. Debounce the search term so
// search, watch state, other category's tags). Lets us show contextual counts and hide // typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
// 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.
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
const facetsQuery = useQuery({ const facetsQuery = useQuery({
queryKey: ["facets", facetFilters], queryKey: ["facets", facetFilters],
@ -137,12 +101,8 @@ export default function Sidebar({
}); });
const facetsReady = !!facetsQuery.data; const facetsReady = !!facetsQuery.data;
const facetCounts = facetsQuery.data?.counts ?? {}; 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 => const chipCount = (tag: Tag): number =>
facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; 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 visibleChips = (list: Tag[]): Tag[] => {
const shown = facetsReady const shown = facetsReady
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) ? 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 needChannelName = !!filters.channelId && !filters.channelName;
const channelsQuery = useQuery({ const channelsQuery = useQuery({
queryKey: ["channels"], queryKey: ["channels"],
@ -167,7 +123,6 @@ export default function Sidebar({
filters.channelName ?? filters.channelName ??
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ?? channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
undefined; undefined;
// Don't flash the misleading "This channel" fallback while the name is still resolving.
const channelChipLabel = const channelChipLabel =
resolvedChannelName ?? resolvedChannelName ??
(needChannelName && channelsQuery.isLoading (needChannelName && channelsQuery.isLoading
@ -175,18 +130,11 @@ export default function Sidebar({
: t("sidebar.thisChannel")); : t("sidebar.thisChannel"));
const languages = tags.filter((t) => t.category === "language"); const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic"); 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 userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false); const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
function toggleTag(id: number) { function toggleTag(id: number) {
const has = filters.tags.includes(id); const has = filters.tags.includes(id);
setFilters({ setFilters({
@ -198,17 +146,12 @@ export default function Sidebar({
const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo; const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo;
const contentChanged = const contentChanged =
!filters.includeNormal || filters.includeShorts || filters.includeLive; !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 scopeActive = filters.scope === "all";
const sourceActive = const sourceActive =
filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic"; filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic";
const activeCount = const activeCount =
filters.tags.length + filters.tags.length +
(filters.show !== "unwatched" ? 1 : 0) + (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) + (filters.sort !== "newest" && filters.sort !== "relevance" ? 1 : 0) +
(contentChanged ? 1 : 0) + (contentChanged ? 1 : 0) +
(filters.channelId ? 1 : 0) + (filters.channelId ? 1 : 0) +
@ -228,15 +171,10 @@ export default function Sidebar({
function clearAll() { function clearAll() {
setCustomDates(false); 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" }); setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" });
} }
const available: Record<WidgetId, boolean> = { const available: Record<string, boolean> = {
// Saved views are personal state; hide the widget for the shared demo account (the API
// also rejects the writes via require_human).
savedviews: !isDemo, savedviews: !isDemo,
date: true, date: true,
language: languages.length > 0, language: languages.length > 0,
@ -244,22 +182,7 @@ export default function Sidebar({
tags: userTags.length > 0, tags: userTags.length > 0,
}; };
function toggleCollapse(id: WidgetId) { function widgetBody(id: string): ReactNode {
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 {
switch (id) { switch (id) {
case "savedviews": case "savedviews":
return <SavedViewsWidget filters={filters} onApply={setFilters} />; return <SavedViewsWidget filters={filters} onApply={setFilters} />;
@ -447,23 +370,51 @@ export default function Sidebar({
</div> </div>
); );
} }
default:
return null;
} }
} }
const orderedAvailable = layout.order.filter((id) => available[id]); // Group ids come from the shared default order (panelLayout) so there's one source of truth;
const renderedIds = editing // the actual render order is the user's saved layout (PanelGroups orders by it).
? orderedAvailable const items: PanelItem[] = defaultLayout("feed")
: orderedAvailable.filter((id) => !layout.hidden[id]); .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 const actions = (
// carries the active-filter count so you can tell filters are on without opening it. <>
if (collapsed) { <button
return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />; onClick={clearAll}
} disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
>
{t("sidebar.clearAll")}
</button>
<button
onClick={shareView}
title={t("sidebar.shareView")}
aria-label={t("sidebar.shareView")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Share2 className="w-4 h-4" />
</button>
</>
);
return ( return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3"> <SidePanel
{/* Scope: your own subscriptions (Mine) vs the shared Library — moved out of the top bar. */} title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout("feed"))}
editHint={t("sidebar.editHint")}
actions={actions}
>
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
<div <div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs" className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
role="group" role="group"
@ -479,83 +430,12 @@ export default function Sidebar({
filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg" filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`} }`}
> >
{s === "my" ? ( {s === "my" ? <User className="w-3.5 h-3.5" /> : <Library className="w-3.5 h-3.5" />}
<User className="w-3.5 h-3.5" />
) : (
<Library className="w-3.5 h-3.5" />
)}
<span>{t("header.scope." + s)}</span> <span>{t("header.scope." + s)}</span>
</button> </button>
))} ))}
</div> </div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
{activeCount > 0 && (
// Compact count pill (not "{n} active" text) so it never truncates or forces the
// action buttons to wrap in a narrow / zoomed-in sidebar.
<span
title={t("sidebar.activeCount", { count: activeCount })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{activeCount}
</span>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{!editing && (
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
>
{t("sidebar.clearAll")}
</button>
)}
{!editing && (
<button
onClick={shareView}
title={t("sidebar.shareView")}
aria-label={t("sidebar.shareView")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Share2 className="w-4 h-4" />
</button>
)}
{editing && (
<button
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
title={t("sidebar.resetDefaults")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
<button
onClick={() => setEditing((e) => !e)}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
>
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
</button>
</div>
</div>
{editing && (
<div className="text-[11px] text-muted -mt-1">{t("sidebar.editHint")}</div>
)}
{filters.channelId && !editing && ( {filters.channelId && !editing && (
<div className="glass-card rounded-xl"> <div className="glass-card rounded-xl">
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted"> <div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">
@ -575,107 +455,11 @@ export default function Sidebar({
</div> </div>
)} )}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}> <PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{renderedIds.map((id) => (
<WidgetCard
key={id}
id={id}
title={t("sidebar.widget." + id)}
editing={editing}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
>
{widgetBody(id)}
</WidgetCard>
))}
</div>
</SortableContext>
</DndContext>
{tagManagerOpen && ( {tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> <TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)} )}
</aside> </SidePanel>
); );
} }
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 (
<div
ref={setNodeRef}
style={style}
className={`glass-card rounded-xl ${
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
} ${hidden && editing ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5 px-3 py-2">
{editing && (
<button
{...attributes}
{...listeners}
title={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
>
<GripVertical className="w-4 h-4" />
</button>
)}
<div className="flex-1 select-none text-xs uppercase tracking-wide text-muted">
{title}
</div>
{editing ? (
<button
onClick={onToggleHidden}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
>
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
) : (
<button
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
)}
</div>
{showBody && <div className="px-3 pb-3">{children}</div>}
</div>
);
}

View file

@ -42,7 +42,14 @@ export default function Toaster() {
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>} {toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
<div className="text-sm break-words">{toast.message}</div> <div className="text-sm break-words">
{toast.message}
{toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
×{toast.repeat}
</span>
)}
</div>
{toast.action && ( {toast.action && (
<button <button
onClick={() => { onClick={() => {

View file

@ -443,7 +443,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* selected-segment editor */} {/* selected-segment editor */}
{sel && ( {sel && (
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg bg-card/40 px-3 py-2"> <div className="flex flex-wrap items-center gap-2 text-sm rounded-lg glass-card px-3 py-2">
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span> <span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
<button <button
onClick={() => toggleKeep(sel.id)} onClick={() => toggleKeep(sel.id)}

View file

@ -122,29 +122,29 @@ export default function WatchPage() {
}; };
return ( return (
<div className="min-h-screen bg-[#0b0f14] text-slate-100 flex flex-col items-center"> <div className="min-h-screen bg-bg text-fg flex flex-col items-center">
<div className="w-full max-w-4xl px-4 py-6 flex-1 flex flex-col"> <div className="w-full max-w-4xl px-4 py-6 flex-1 flex flex-col">
<div className="flex items-center gap-2 mb-5 text-sm text-slate-400"> <div className="flex items-center gap-2 mb-5 text-sm text-muted">
<PlayCircle className="w-5 h-5 text-teal-400" /> <PlayCircle className="w-5 h-5 text-accent" />
<span className="font-semibold text-slate-200">Siftlode</span> <span className="font-semibold text-fg">Siftlode</span>
</div> </div>
{status === "loading" && <div className="flex-1 grid place-items-center text-slate-400">{T.loading}</div>} {status === "loading" && <div className="flex-1 grid place-items-center text-muted">{T.loading}</div>}
{status === "gone" && ( {status === "gone" && (
<div className="flex-1 grid place-items-center text-center"> <div className="flex-1 grid place-items-center text-center">
<div> <div>
<div className="text-lg font-semibold">{T.gone}</div> <div className="text-lg font-semibold">{T.gone}</div>
<div className="text-sm text-slate-400 mt-1">{T.goneHint}</div> <div className="text-sm text-muted mt-1">{T.goneHint}</div>
</div> </div>
</div> </div>
)} )}
{status === "password" && ( {status === "password" && (
<div className="flex-1 grid place-items-center"> <div className="flex-1 grid place-items-center">
<div className="w-full max-w-sm rounded-2xl bg-slate-900/60 border border-slate-700 p-6"> <div className="w-full max-w-sm rounded-2xl glass p-6">
<div className="flex items-center gap-2 font-medium mb-4"> <div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-teal-400" /> {T.passwordTitle} <Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
</div> </div>
<input <input
type="password" type="password"
@ -153,13 +153,13 @@ export default function WatchPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()} onKeyDown={(e) => e.key === "Enter" && password && unlock()}
placeholder={T.passwordPlaceholder} placeholder={T.passwordPlaceholder}
className="w-full rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-teal-500/40" className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent/40"
/> />
{error && <div className="text-sm text-red-400 mt-2">{error}</div>} {error && <div className="text-sm text-red-400 mt-2">{error}</div>}
<button <button
onClick={unlock} onClick={unlock}
disabled={!password || unlocking} disabled={!password || unlocking}
className="mt-4 w-full rounded-lg bg-teal-600 hover:bg-teal-500 disabled:opacity-50 py-2 text-sm font-medium transition" className="mt-4 w-full rounded-lg bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 py-2 text-sm font-medium transition"
> >
{T.watch} {T.watch}
</button> </button>
@ -171,7 +171,7 @@ export default function WatchPage() {
<div> <div>
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow {/* 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. */} player instead of a wide box with black bars; a landscape clip fills the width. */}
<div className="rounded-xl overflow-hidden bg-black border border-slate-800 w-fit max-w-full mx-auto"> <div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video <video
controls controls
playsInline playsInline
@ -189,18 +189,18 @@ export default function WatchPage() {
href={meta.uploader_url} href={meta.uploader_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-slate-400 hover:text-teal-400 hover:underline" className="text-sm text-muted hover:text-accent hover:underline"
> >
{meta.uploader} {meta.uploader}
</a> </a>
) : ( ) : (
<div className="text-sm text-slate-400">{meta.uploader}</div> <div className="text-sm text-muted">{meta.uploader}</div>
))} ))}
</div> </div>
{meta.allow_download && ( {meta.allow_download && (
<a <a
href={meta.file_url} href={meta.file_url}
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 px-3 py-2 text-sm font-medium transition" className="shrink-0 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
> >
<Download className="w-4 h-4" /> {T.download} <Download className="w-4 h-4" /> {T.download}
</a> </a>
@ -217,7 +217,7 @@ export default function WatchPage() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={url} 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"
> >
<Link2 className="w-3.5 h-3.5 shrink-0" /> <Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span> <span className="truncate">{displayUrl(url)}</span>
@ -228,7 +228,7 @@ export default function WatchPage() {
</div> </div>
)} )}
</div> </div>
<div className="text-xs text-slate-600 py-4">{T.brand}</div> <div className="text-xs text-muted py-4">{T.brand}</div>
</div> </div>
); );
} }

View file

@ -6,7 +6,6 @@ import { LS } from "../lib/storage";
export const LANGUAGES = [ export const LANGUAGES = [
{ code: "en", label: "English" }, { code: "en", label: "English" },
{ code: "hu", label: "Magyar" }, { code: "hu", label: "Magyar" },
{ code: "de", label: "Deutsch" },
] as const; ] as const;
export type LangCode = (typeof LANGUAGES)[number]["code"]; export type LangCode = (typeof LANGUAGES)[number]["code"];
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[]; const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];

View file

@ -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"
}

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1,124 +0,0 @@
{
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, 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."
}
}

View file

@ -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"
}

View file

@ -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 <addr@gmail.com>. 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"
}

View file

@ -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}}"
}
}

View file

@ -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."
}
}
}

View file

@ -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 (±12 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"
}

View file

@ -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"
}
}

View file

@ -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."
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -1,11 +0,0 @@
{
"primary": "Hauptnavigation",
"collapse": "Seitenleiste einklappen",
"expand": "Seitenleiste ausklappen",
"adminSection": "Admin",
"role": {
"admin": "Admin",
"user": "Benutzer",
"demo": "Demo"
}
}

View file

@ -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}}”"
}

View file

@ -1,29 +0,0 @@
{
"close": "Schließen",
"consent": "Google zeigt einen <0>„Google hat diese App nicht überprüft“</0>-Bildschirm — das ist zu erwarten, da Siftlode die vollständige Überprüfung von Google nicht durchlaufen hat. Es ist sicher fortzufahren: klicke auf <1>Erweitert</1> → <2>Weiter zu Siftlode</2>. Du kannst den Zugriff jederzeit in deinem <3>Google-Konto</3> widerrufen.",
"read": {
"title": "Verbinde deine YouTube-Abos",
"body": "Du bist angemeldet. Um deinen Feed aufzubauen, benötigt Siftlode <0>schreibgeschützten</0> 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</0> — 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"
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "90120 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"
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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?"
}
}

View file

@ -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."
}

View file

@ -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 (AZ)",
"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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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."
}
}

View file

@ -4,6 +4,10 @@
"clearSearch": "Clear search", "clearSearch": "Clear search",
"searchYoutube": "Search YouTube", "searchYoutube": "Search YouTube",
"searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.", "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", "channelManager": "Channel manager",
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",

View file

@ -1,5 +1,7 @@
{ {
"title": "Playlists", "title": "Playlists",
"manageGroup": "Manage",
"listGroup": "Your playlists",
"watchLater": "Watch later", "watchLater": "Watch later",
"syncYoutube": "Sync from YouTube", "syncYoutube": "Sync from YouTube",
"syncedToast": "Synced {{count}} playlists from YouTube", "syncedToast": "Synced {{count}} playlists from YouTube",
@ -7,6 +9,8 @@
"ytReadonly": "Synced from YouTube — edits sync back", "ytReadonly": "Synced from YouTube — edits sync back",
"noneYet": "No playlists yet", "noneYet": "No playlists yet",
"newPlaylist": "New playlist…", "newPlaylist": "New playlist…",
"searchPlaceholder": "Search playlists…",
"noMatches": "No playlists match.",
"itemCount_one": "{{count}} video", "itemCount_one": "{{count}} video",
"itemCount_other": "{{count}} videos", "itemCount_other": "{{count}} videos",
"pickOne": "Pick a playlist on the left.", "pickOne": "Pick a playlist on the left.",

View file

@ -33,6 +33,9 @@
"colorScheme": "Color scheme", "colorScheme": "Color scheme",
"display": "Display", "display": "Display",
"darkMode": "Dark mode", "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", "listView": "List view",
"listViewHint": "Show the feed as a compact list instead of a grid of cards.", "listViewHint": "Show the feed as a compact list instead of a grid of cards.",
"performanceMode": "Performance mode", "performanceMode": "Performance mode",

View file

@ -38,7 +38,7 @@
}, },
"private": { "private": {
"title": "Self-hosted and 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": { "auth": {

View file

@ -4,6 +4,10 @@
"clearSearch": "Keresés törlése", "clearSearch": "Keresés törlése",
"searchYoutube": "Keresés a YouTube-on", "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.", "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ő", "channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",

View file

@ -1,5 +1,7 @@
{ {
"title": "Lejátszási listák", "title": "Lejátszási listák",
"manageGroup": "Kezelés",
"listGroup": "Listáid",
"watchLater": "Megnézem később", "watchLater": "Megnézem később",
"syncYoutube": "Szinkron YouTube-ról", "syncYoutube": "Szinkron YouTube-ról",
"syncedToast": "{{count}} lista szinkronizálva a 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", "ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók",
"noneYet": "Még nincs lejátszási lista", "noneYet": "Még nincs lejátszási lista",
"newPlaylist": "Új lista…", "newPlaylist": "Új lista…",
"searchPlaceholder": "Listák keresése…",
"noMatches": "Nincs találó lista.",
"itemCount_one": "{{count}} videó", "itemCount_one": "{{count}} videó",
"itemCount_other": "{{count}} videó", "itemCount_other": "{{count}} videó",
"pickOne": "Válassz egy listát a bal oldalon.", "pickOne": "Válassz egy listát a bal oldalon.",

View file

@ -33,6 +33,9 @@
"colorScheme": "Színséma", "colorScheme": "Színséma",
"display": "Megjelenítés", "display": "Megjelenítés",
"darkMode": "Sötét mód", "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", "listView": "Listanézet",
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.", "listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
"performanceMode": "Teljesítmény mód", "performanceMode": "Teljesítmény mód",

View file

@ -38,7 +38,7 @@
}, },
"private": { "private": {
"title": "Self-hosted és privát", "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": { "auth": {

View file

@ -4,6 +4,86 @@
:root { :root {
--font-scale: 1.06; --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 <body> (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. */ /* 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, 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. */ header and dialogs are all frosted glass and refract it. */
background: background:
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, 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) 14%, transparent), transparent 55%), 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) 11%, transparent), transparent 60%), radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60%),
var(--bg); var(--bg);
background-attachment: fixed; 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) ===== */ /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
.glass { .glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows /* Frosted overlay surface: translucent enough that the blurred backdrop shows
through (the glassy look), opaque enough that the background isn't distracting. 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. */ The strong blur turns whatever is behind into soft colour, not a sharp image.
background: color-mix(in srgb, var(--surface) 78%, transparent); Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
backdrop-filter: blur(30px) saturate(1.8); background:
-webkit-backdrop-filter: blur(30px) saturate(1.8); linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
border: 1px solid color-mix(in srgb, var(--border) 78%, 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: 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); 0 18px 44px -16px rgba(0, 0, 0, 0.6);
} }
.glass-card { .glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background /* 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. 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. */ 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: 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: 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); 0 8px 22px -14px rgba(0, 0, 0, 0.45);
} }
.glass-menu { .glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs), /* 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. */ 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); background:
backdrop-filter: blur(30px) saturate(1.8); linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
-webkit-backdrop-filter: blur(30px) saturate(1.8); 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: 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: 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); 0 18px 44px -16px rgba(0, 0, 0, 0.6);
} }
.glass-hover:hover { .glass-hover:hover {
@ -80,9 +180,9 @@ body {
(The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */ (The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */
html[data-theme="dark"] body { html[data-theme="dark"] body {
background: background:
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 34%, 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) 24%, transparent), transparent 55%), 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) 18%, transparent), transparent 60%), radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60%),
var(--bg); var(--bg);
background-attachment: fixed; 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). */ 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,
html[data-theme="dark"] .glass-menu { html[data-theme="dark"] .glass-menu {
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(30px) saturate(1.7) brightness(1.4); -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 /* 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 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; --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) */ /* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@keyframes toastbar { @keyframes toastbar {
from { from {

View file

@ -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<Page, string> = {
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];
}

View file

@ -59,6 +59,11 @@ export interface Notification {
// timer) that the producer removes itself when the condition resolves. Never persisted // 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. // to history, so a reload can't orphan it with no live handle to clear it.
transient: boolean; 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 { export interface NotifyInput {
@ -136,7 +141,10 @@ function load(): Notification[] {
// resurrect as active toasts on reload — their auto-dismiss timers aren't // 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 // re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization. // 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 { } catch {
return []; return [];
} }
@ -184,10 +192,13 @@ export function removeByMetaKind(kind: NotifMeta["kind"]): void {
if (items.length !== before) emit(); 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 { export function notify(input: NotifyInput): number {
const id = counter++;
const requiresInteraction = input.requiresInteraction ?? false; const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info"; const level = input.level ?? "info";
const now = Date.now();
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed). // 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), // A transient status stays put until its producer clears it (no auto-dismiss timer),
// same as an interaction-awaiting notice. // same as an interaction-awaiting notice.
@ -198,6 +209,38 @@ export function notify(input: NotifyInput): number {
: config.durationMs > 0 : config.durationMs > 0
? config.durationMs ? config.durationMs
: undefined; : 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 = [
...items, ...items,
{ {
@ -209,10 +252,11 @@ export function notify(input: NotifyInput): number {
meta: input.meta, meta: input.meta,
requiresInteraction, requiresInteraction,
duration, duration,
ts: Date.now(), ts: now,
read: false, read: false,
dismissed: false, dismissed: false,
transient: input.transient ?? false, transient: input.transient ?? false,
repeat: 1,
}, },
]; ];
emit(); emit();

View file

@ -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<string, boolean>;
hidden: Record<string, boolean>;
}
// 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<PanelId, string[]> = {
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<PanelId, string> = {
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<PanelId, "sidebarLayout" | "plexLayout" | "playlistsLayout"> = {
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<PanelLayout>;
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<unknown>(LS_KEY[panel], {}));
}
export function saveLayoutLocal(panel: PanelId, l: PanelLayout): void {
writeAccount(LS_KEY[panel], l);
}

View file

@ -14,6 +14,26 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: 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", version: "0.41.0",
date: "2026-07-12", date: "2026-07-12",

View file

@ -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<Record<WidgetId, boolean>>;
hidden: Partial<Record<WidgetId, boolean>>;
}
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<SidebarLayout>;
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<unknown>(LS.sidebarLayout, {}));
}
export function saveLayoutLocal(l: SidebarLayout): void {
writeAccount(LS.sidebarLayout, l);
}

View file

@ -6,6 +6,9 @@ import { useCallback, useState } from "react";
export const LS = { export const LS = {
theme: "siftlode.theme", theme: "siftlode.theme",
sidebarLayout: "siftlode.sidebarLayout", sidebarLayout: "siftlode.sidebarLayout",
plexLayout: "siftlode.plexLayout",
playlistsLayout: "siftlode.playlistsLayout",
playlistsCollapsed: "siftlode.playlistsCollapsed",
hints: "siftlode.hints", hints: "siftlode.hints",
lang: "siftlode.lang", lang: "siftlode.lang",
filters: "siftlode.filters", filters: "siftlode.filters",

View file

@ -1,25 +1,33 @@
import { LS, readAccountMerged, writeAccount } from "./storage"; import { LS, readAccountMerged, writeAccount } from "./storage";
type Mode = "dark" | "light"; 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 }[] = [ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" }, { id: "midnight", name: "Midnight", swatch: "#6d8cff" },
{ id: "forest", name: "Forest", swatch: "#2dd4bf" }, { id: "forest", name: "Forest", swatch: "#2dd4bf" },
{ id: "slate", name: "Slate", swatch: "#e8913c" }, { id: "slate", name: "Slate", swatch: "#e8913c" },
{ id: "youtube", name: "YouTube", swatch: "#ff3b46" }, { id: "youtube", name: "YouTube", swatch: "#ff3b46" },
{ id: "starship", name: "Starship", swatch: "#38b6ff" },
{ id: "matrix", name: "Matrix", swatch: "#46b87c" },
]; ];
export interface ThemePrefs { export interface ThemePrefs {
mode: Mode; mode: Mode;
scheme: Scheme; scheme: Scheme;
fontScale: number; fontScale: number;
/** Show the per-scheme background image (dark theme only); off = flat colour. */
bgImage: boolean;
/** How strongly the background image is faded out, 0100% (higher = fainter). */
bgFade: number;
} }
export const DEFAULT_THEME: ThemePrefs = { export const DEFAULT_THEME: ThemePrefs = {
mode: "dark", mode: "dark",
scheme: "midnight", scheme: "midnight",
fontScale: 1.06, fontScale: 1.06,
bgImage: true,
bgFade: 60,
}; };
export function applyTheme(t: ThemePrefs): void { export function applyTheme(t: ThemePrefs): void {
@ -27,6 +35,7 @@ export function applyTheme(t: ThemePrefs): void {
el.dataset.theme = t.mode; el.dataset.theme = t.mode;
el.dataset.scheme = t.scheme; el.dataset.scheme = t.scheme;
el.style.setProperty("--font-scale", String(t.fontScale)); el.style.setProperty("--font-scale", String(t.fontScale));
if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`);
} }
export function loadLocalTheme(): ThemePrefs { export function loadLocalTheme(): ThemePrefs {

View file

@ -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<HTMLDivElement | null>(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 } };
}

View file

@ -20,6 +20,13 @@ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } }, 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 <App> 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 path = window.location.pathname;
const root = const root =
path === "/privacy" ? <PrivacyPolicy /> : path === "/privacy" ? <PrivacyPolicy /> :