From 22cce807a0a2437cd38091dc70f7ae2b9d21e831 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 19:14:40 +0200 Subject: [PATCH 01/20] feat(glass): variable-drive the glass system + dev GlassTuner (Phase 0/0b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the app-wide glass-consistency epic: - index.css: every look-driving value (blur/saturate/dark-brightness/panel-card- menu opacity/border/inset/edge/scrim/ambient + mosaic) is now a CSS custom property defaulting to the historical hard-coded value — one-place tunable, no visual change until a value is bumped. - GlassTuner.tsx: dev-only (localhost-gated) always-on corner panel that live- drives those vars, with treatment presets (Adaptive/Gradient+/Edge+scrim), per-scheme palette editor, muted thumbnail-mosaic toggle, localStorage persist, and Copy-CSS export. Deleted once the tuned finals are baked in. - MosaicBackdrop.tsx: opt-in muted (blur+darken+desaturate) mosaic of on-screen thumbnails so dark glass has real content to refract. --- frontend/src/App.tsx | 4 + frontend/src/components/GlassTuner.tsx | 374 +++++++++++++++++++++ frontend/src/components/MosaicBackdrop.tsx | 58 ++++ frontend/src/index.css | 104 ++++-- 4 files changed, 520 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/GlassTuner.tsx create mode 100644 frontend/src/components/MosaicBackdrop.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f0892a..4d178b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,6 +50,7 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; import BackToTop from "./components/BackToTop"; +import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import Toaster from "./components/Toaster"; import ErrorDialog from "./components/ErrorDialog"; @@ -903,6 +904,9 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} + {/* DEV-ONLY live appearance tuner (localhost-gated; self-returns null on prod). Delete when + the glass-consistency epic bakes the final values into index.css. */} + ); } diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx new file mode 100644 index 0000000..a51a99e --- /dev/null +++ b/frontend/src/components/GlassTuner.tsx @@ -0,0 +1,374 @@ +import { useEffect, useMemo, useState } from "react"; +import MosaicBackdrop from "./MosaicBackdrop"; + +/** + * DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page. + * It writes the glass CSS custom properties (index.css `:root`) inline on for instant, + * build-free feedback, and persists to localStorage so tweaks survive an F5 during UAT. + * + * Gated to localhost/127.0.0.1 so it renders on the :8080 UAT build and :5173 HMR but NEVER on + * prod (siftlode.b1fr0st.eu). It is a scaffold for the glass-consistency epic: dial in values + * here, hit "Copy CSS", hand them over to bake into index.css — then this component is deleted. + */ +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; +}; + +const SLIDERS: Slider[] = [ + { k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 30 }, + { k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.8 }, + { k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.4 }, + { k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 78 }, + { k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 84 }, + { k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 92 }, + { k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 78 }, + { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 0 }, + { k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 16 }, + { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 0 }, + { k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1 }, +]; + +const MOSAIC_SLIDERS: Slider[] = [ + { k: "--mosaic-blur", label: "Mosaic blur", min: 20, max: 100, step: 1, unit: "px", def: 64 }, + { k: "--mosaic-sat", label: "Mosaic saturation", min: 0, max: 1.5, step: 0.05, unit: "", def: 0.7 }, + { k: "--mosaic-bright", label: "Mosaic brightness", min: 0.2, max: 1, step: 0.05, unit: "", def: 0.5 }, + { k: "--mosaic-opacity", label: "Mosaic opacity", min: 0, max: 1, step: 0.05, unit: "", def: 0.5 }, +]; + +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 }[] = [ + { id: "current", name: "Current", hint: "The shipped defaults", over: {} }, + { + id: "adaptive", + name: "Adaptive (rec.)", + hint: "Solid-enough dark glass", + over: { + "--glass-surface-alpha": 94, + "--glass-card-alpha": 94, + "--glass-menu-alpha": 98, + "--glass-scrim": 14, + "--glass-edge": 10, + "--ambient": 1.1, + "--glass-dark-bright": 1.25, + "--glass-blur": 20, + }, + }, + { id: "gradient", name: "Gradient+", hint: "Stronger accent pools", over: { "--ambient": 1.6, "--glass-scrim": 6 } }, + { + id: "wcag", + name: "Edge + scrim", + hint: "Max readability", + over: { "--glass-edge": 42, "--glass-scrim": 45, "--glass-surface-alpha": 84, "--glass-card-alpha": 90 }, + }, +]; + +const ALL = [...SLIDERS, ...MOSAIC_SLIDERS]; +const root = () => document.documentElement; +const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); + +type Saved = { + vars?: Record; + palette?: Record; + mosaic?: boolean; + 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>(() => { + const base: Record = {}; + ALL.forEach((s) => (base[s.k] = s.def)); + return { ...base, ...(initial.vars || {}) }; + }); + const [palette, setPalette] = useState>(() => initial.palette || {}); + const [mosaic, setMosaic] = useState(() => Boolean(initial.mosaic)); + const [open, setOpen] = useState(() => initial.open ?? true); + const [tab, setTab] = useState<"glass" | "palette">("glass"); + const [copied, setCopied] = useState(false); + // Seed the colour pickers from the live scheme for display (without forcing them onto ). + const [swatches, setSwatches] = useState>({}); + + // 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 = {}; + 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) { + 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) { + const next: Record = { ...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 toggleMosaic(v: boolean) { + setMosaic(v); + persist({ mosaic: v }); + } + + function reset() { + [...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k)); + const base: Record = {}; + ALL.forEach((s) => (base[s.k] = s.def)); + setVars(base); + setPalette({}); + toggleMosaic(false); + localStorage.removeItem(LS_KEY); + const sw: Record = {}; + PALETTE.forEach((p) => { + const v = readVar(p.k); + sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000"; + }); + setSwatches(sw); + } + + // Export only what differs from the shipped defaults, ready to paste into index.css. + const cssOut = useMemo(() => { + 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]};`); + }); + return lines.length ? `:root {\n${lines.join("\n")}\n}` : "/* all values at defaults */"; + }, [vars, palette]); + + 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 ( + <> + {mosaic && } +
+ {!open && ( + + )} + {open && ( +
+
+ ◐ Glass Tuner + dev + +
+ +
+ {/* Treatment presets */} +
+
Dark treatment
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {/* Mosaic toggle */} + + + {/* Tab switch */} +
+ {(["glass", "palette"] as const).map((t) => ( + + ))} +
+ + {tab === "glass" && ( +
+ {SLIDERS.map((s) => ( + setVar(s.k, v)} /> + ))} + {mosaic && ( +
+ {MOSAIC_SLIDERS.map((s) => ( + setVar(s.k, v)} /> + ))} +
+ )} +
+ )} + + {tab === "palette" && ( +
+

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

+ {PALETTE.map((p) => ( + + ))} +
+ )} + + {/* Export */} +
+
Export → bake in
+
+ + +
+
+                  {cssOut}
+                
+
+
+
+ )} +
+ + ); +} + +function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) { + return ( +
+ + onChange(Number(e.target.value))} + className="w-16 text-right text-[11px] tabular-nums rounded bg-card border border-border px-1.5 py-0.5 text-accent" + /> + onChange(Number(e.target.value))} + className="col-span-2 w-full accent-accent" + /> +
+ ); +} diff --git a/frontend/src/components/MosaicBackdrop.tsx b/frontend/src/components/MosaicBackdrop.tsx new file mode 100644 index 0000000..05b8c1e --- /dev/null +++ b/frontend/src/components/MosaicBackdrop.tsx @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useState } from "react"; + +/** + * DEV-ONLY, OPT-IN backdrop: a heavily blurred + darkened + desaturated mosaic of the real + * thumbnails currently on screen, so dark "glass" surfaces have actual content to refract. + * Deliberately muted (see the --mosaic-* vars in index.css) — a soft texture, never a collage. + * + * Thumbnails are sampled straight from the rendered DOM (the feed/channel image tags), so it + * needs no data wiring and works on whatever page mounts it. Looks richest on the feed; on a + * thumbnail-less page it simply falls back to the plain background. + * + * Toggled by the GlassTuner. Not shipped to prod (the tuner that mounts it is localhost-gated). + */ +export default function MosaicBackdrop({ cols = 8, rows = 6 }: { cols?: number; rows?: number }) { + const [srcs, setSrcs] = useState([]); + + // Snapshot the on-screen thumbnails once per mount (toggling the tuner off/on re-samples on a + // content-rich page). Looks richest on the feed; elsewhere it just falls back to the plain bg. + useEffect(() => { + const found = new Set(); + document.querySelectorAll("img").forEach((img) => { + const s = img.currentSrc || img.src; + if (!s) return; + // YouTube thumbnails / channel avatars — skip our own tiny UI icons/data URIs. + if (/ytimg\.com|ggpht\.com|googleusercontent\.com/.test(s)) found.add(s); + }); + setSrcs([...found]); + }, []); + + useEffect(() => { + document.documentElement.dataset.mosaic = "1"; + return () => { + delete document.documentElement.dataset.mosaic; + }; + }, []); + + const tiles = useMemo(() => { + const n = cols * rows; + if (srcs.length === 0) return []; + return Array.from({ length: n }, (_, i) => srcs[i % srcs.length]); + }, [srcs, cols, rows]); + + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d66cc3e..e735122 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,6 +4,29 @@ :root { --font-scale: 1.06; + + /* ===== Glass tunables — every value that materially drives the look lives here so it can be + tuned in ONE place (and live-driven by the dev GlassTuner during UAT). Defaults below equal + the historical hard-coded values, so nothing changes until a value is bumped. When the user + signs off on tuned values, bake the finals right here. ===== */ + --glass-blur: 30px; /* backdrop blur radius (2026 sweet spot is ~8–16px; 30 is heavy) */ + --glass-saturate: 1.8; /* backdrop saturation */ + --glass-dark-bright: 1.4; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */ + --glass-surface-alpha: 78%; /* .glass panel translucency (higher = more solid = "adaptive") */ + --glass-card-alpha: 84%; /* .glass-card translucency */ + --glass-menu-alpha: 92%; /* .glass-menu translucency */ + --glass-border-alpha: 78%; /* .glass border opacity */ + --glass-inset: 16%; /* top specular highlight strength (#fff N%) */ + --glass-edge: 0%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */ + --glass-scrim: 0%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */ + --ambient: 1; /* multiplier on the ambient accent backdrop pools */ + + /* Optional muted thumbnail-mosaic backdrop (off unless mounts). Conservative + defaults: heavy blur + low saturation + darkened so it reads as subtle texture, never a rainbow. */ + --mosaic-blur: 64px; + --mosaic-sat: 0.7; + --mosaic-bright: 0.5; + --mosaic-opacity: 0.5; } /* Make native controls (date picker, scrollbars) follow the theme. */ @@ -26,9 +49,9 @@ body { subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav, header and dialogs are all frosted glass and refract it. */ background: - radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 60%), - radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 55%), - radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 11%, transparent), transparent 60%), + radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent), transparent 60%), + radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent), transparent 55%), + radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60%), var(--bg); background-attachment: fixed; } @@ -37,34 +60,44 @@ body { .glass { /* Frosted overlay surface: translucent enough that the blurred backdrop shows through (the glassy look), opaque enough that the background isn't distracting. - The strong blur turns whatever is behind into soft colour, not a sharp image. */ - background: color-mix(in srgb, var(--surface) 78%, transparent); - backdrop-filter: blur(30px) saturate(1.8); - -webkit-backdrop-filter: blur(30px) saturate(1.8); - border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + The strong blur turns whatever is behind into soft colour, not a sharp image. + Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */ + background: + linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)), + color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent); + border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 16%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent), 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-card { /* No backdrop-filter here: cards render in bulk (feed grid) over a solid background where blur adds almost nothing visually but is GPU-expensive and triggers reflow. Translucency + border + depth keep the look; blur is reserved for .glass overlays. */ - background: color-mix(in srgb, var(--card) 84%, transparent); + background: + linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)), + color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); + border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 65%, transparent)); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent), 0 8px 22px -14px rgba(0, 0, 0, 0.45); } .glass-menu { /* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs), so they must be near-opaque to stay readable — only a hint of translucency + the blur. */ - background: color-mix(in srgb, var(--surface) 92%, transparent); - backdrop-filter: blur(30px) saturate(1.8); - -webkit-backdrop-filter: blur(30px) saturate(1.8); + background: + linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)), + color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent)); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent), 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-hover:hover { @@ -80,9 +113,9 @@ body { (The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */ html[data-theme="dark"] body { background: - radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 60%), - radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 24%, transparent), transparent 55%), - radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%), + radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent), transparent 60%), + radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent), transparent 55%), + radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60%), var(--bg); background-attachment: fixed; } @@ -90,8 +123,8 @@ html[data-theme="dark"] body { the frosted sheen shows even over dark UI (over colourful content it just looks richer). */ html[data-theme="dark"] .glass, html[data-theme="dark"] .glass-menu { - backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); - -webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright)); } /* Toasts surface bottom-left (by the nav's notification bell), off the conventional top-right; a brighter rim draws the eye there. Most needed in dark mode, where the @@ -100,6 +133,37 @@ html[data-theme="dark"] .toast-card { border-color: color-mix(in srgb, #fff 50%, transparent); } +/* ===== Optional muted thumbnail-mosaic backdrop (mounted by ) ===== + Gives dark glass real content to refract. Kept conservative: heavy blur + low saturation + + darkened, so it reads as a soft texture behind everything, never a colourful collage. When it + is present, the ambient accent pools on are dimmed so the two don't fight. */ +.mosaic-backdrop { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} +.mosaic-backdrop .mosaic-tiles { + position: absolute; + inset: -10%; + display: grid; + gap: 0; + filter: blur(var(--mosaic-blur)) saturate(var(--mosaic-sat)) brightness(var(--mosaic-bright)); + opacity: var(--mosaic-opacity); +} +.mosaic-backdrop .mosaic-tiles > span { + display: block; + background-size: cover; + background-position: center; +} +html[data-mosaic="1"] body { + background: var(--bg); +} +html[data-perf="1"] .mosaic-backdrop { + display: none; +} + /* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */ html[data-perf="1"] .glass, html[data-perf="1"] .glass-menu, From dc51aeeae9a46664aeea6d6453919fad8c2e0b79 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 20:12:25 +0200 Subject: [PATCH 02/20] feat(glass): bake two-tier defaults (Adaptive chrome + media scope), add Starship/Matrix schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From UAT: glass reads strongest where there's content behind it, so split the look into two tiers instead of one global compromise. - index.css: global :root defaults are now the 'Adaptive' baseline (solid-enough dark glass for content-less chrome). New .glass-media scope class carries the UAT-tuned translucent values (blur 10 / surface 50% / card 60% / scrim 30% / edge 25%) for surfaces that float over real artwork. - Apply .glass-media to the art-backed Plex detail views (PlexInfo page variant, PlexShowView, PlexSeasonView) and drop PlexInfo's hard-coded inline 55% overrides so those panels are scope-driven and consistent (hero no longer reads solid while the season strips are translucent). - Two new colour schemes: Starship (deep-space blue, azure accent) and Matrix (near-black, phosphor green), each with dark+light tokens + swatches. - Remove the thumbnail-mosaic backdrop (did nothing usefully; its home is the future 'videos behind glass' phase) — deletes MosaicBackdrop, mosaic vars/CSS, and the tuner's mosaic controls. GlassTuner presets resynced to the new baseline (Current / Media / Max-readable). --- frontend/src/components/GlassTuner.tsx | 90 ++++++---------- frontend/src/components/MosaicBackdrop.tsx | 58 ---------- frontend/src/components/PlexBrowse.tsx | 4 +- frontend/src/components/PlexInfo.tsx | 5 +- frontend/src/index.css | 120 ++++++++++++--------- frontend/src/lib/theme.ts | 4 +- 6 files changed, 108 insertions(+), 173 deletions(-) delete mode 100644 frontend/src/components/MosaicBackdrop.tsx diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index a51a99e..5b899c5 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import MosaicBackdrop from "./MosaicBackdrop"; /** * DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page. @@ -25,25 +24,19 @@ type Slider = { def: number; }; +// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it. const SLIDERS: Slider[] = [ - { k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 30 }, + { k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 20 }, { k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.8 }, - { k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.4 }, - { k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 78 }, - { k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 84 }, - { k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 92 }, + { k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.25 }, + { k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 94 }, + { k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 94 }, + { k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 98 }, { k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 78 }, - { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 0 }, + { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 10 }, { k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 16 }, - { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 0 }, - { k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1 }, -]; - -const MOSAIC_SLIDERS: Slider[] = [ - { k: "--mosaic-blur", label: "Mosaic blur", min: 20, max: 100, step: 1, unit: "px", def: 64 }, - { k: "--mosaic-sat", label: "Mosaic saturation", min: 0, max: 1.5, step: 0.05, unit: "", def: 0.7 }, - { k: "--mosaic-bright", label: "Mosaic brightness", min: 0.2, max: 1, step: 0.05, unit: "", def: 0.5 }, - { k: "--mosaic-opacity", label: "Mosaic opacity", min: 0, max: 1, step: 0.05, unit: "", def: 0.5 }, + { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 14 }, + { k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 }, ]; const PALETTE: { k: string; label: string }[] = [ @@ -58,39 +51,39 @@ const PALETTE: { k: string; label: string }[] = [ // Each preset resets every slider to its default, then applies these overrides. const PRESETS: { id: string; name: string; hint: string; over: Record }[] = [ - { id: "current", name: "Current", hint: "The shipped defaults", over: {} }, + { id: "current", name: "Current", hint: "The baked baseline", over: {} }, { - id: "adaptive", - name: "Adaptive (rec.)", - hint: "Solid-enough dark glass", + id: "media", + name: "Media (glassy)", + hint: "For art-backed pages", over: { - "--glass-surface-alpha": 94, - "--glass-card-alpha": 94, - "--glass-menu-alpha": 98, - "--glass-scrim": 14, - "--glass-edge": 10, - "--ambient": 1.1, - "--glass-dark-bright": 1.25, - "--glass-blur": 20, + "--glass-blur": 10, + "--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, }, }, - { id: "gradient", name: "Gradient+", hint: "Stronger accent pools", over: { "--ambient": 1.6, "--glass-scrim": 6 } }, { - id: "wcag", - name: "Edge + scrim", - hint: "Max readability", - over: { "--glass-edge": 42, "--glass-scrim": 45, "--glass-surface-alpha": 84, "--glass-card-alpha": 90 }, + 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, ...MOSAIC_SLIDERS]; +const ALL = SLIDERS; const root = () => document.documentElement; const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); type Saved = { vars?: Record; palette?: Record; - mosaic?: boolean; open?: boolean; }; @@ -110,7 +103,6 @@ export default function GlassTuner() { return { ...base, ...(initial.vars || {}) }; }); const [palette, setPalette] = useState>(() => initial.palette || {}); - const [mosaic, setMosaic] = useState(() => Boolean(initial.mosaic)); const [open, setOpen] = useState(() => initial.open ?? true); const [tab, setTab] = useState<"glass" | "palette">("glass"); const [copied, setCopied] = useState(false); @@ -168,18 +160,12 @@ export default function GlassTuner() { persist({ vars: next }); } - function toggleMosaic(v: boolean) { - setMosaic(v); - persist({ mosaic: v }); - } - function reset() { [...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k)); const base: Record = {}; ALL.forEach((s) => (base[s.k] = s.def)); setVars(base); setPalette({}); - toggleMosaic(false); localStorage.removeItem(LS_KEY); const sw: Record = {}; PALETTE.forEach((p) => { @@ -216,9 +202,7 @@ export default function GlassTuner() { if (!DEV_TUNER) return null; return ( - <> - {mosaic && } -
+
{!open && (
- {/* Mosaic toggle */} - - {/* Tab switch */}
{(["glass", "palette"] as const).map((t) => ( @@ -291,13 +269,6 @@ export default function GlassTuner() { {SLIDERS.map((s) => ( setVar(s.k, v)} /> ))} - {mosaic && ( -
- {MOSAIC_SLIDERS.map((s) => ( - setVar(s.k, v)} /> - ))} -
- )}
)} @@ -342,8 +313,7 @@ export default function GlassTuner() { )} - - + ); } diff --git a/frontend/src/components/MosaicBackdrop.tsx b/frontend/src/components/MosaicBackdrop.tsx deleted file mode 100644 index 05b8c1e..0000000 --- a/frontend/src/components/MosaicBackdrop.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; - -/** - * DEV-ONLY, OPT-IN backdrop: a heavily blurred + darkened + desaturated mosaic of the real - * thumbnails currently on screen, so dark "glass" surfaces have actual content to refract. - * Deliberately muted (see the --mosaic-* vars in index.css) — a soft texture, never a collage. - * - * Thumbnails are sampled straight from the rendered DOM (the feed/channel image tags), so it - * needs no data wiring and works on whatever page mounts it. Looks richest on the feed; on a - * thumbnail-less page it simply falls back to the plain background. - * - * Toggled by the GlassTuner. Not shipped to prod (the tuner that mounts it is localhost-gated). - */ -export default function MosaicBackdrop({ cols = 8, rows = 6 }: { cols?: number; rows?: number }) { - const [srcs, setSrcs] = useState([]); - - // Snapshot the on-screen thumbnails once per mount (toggling the tuner off/on re-samples on a - // content-rich page). Looks richest on the feed; elsewhere it just falls back to the plain bg. - useEffect(() => { - const found = new Set(); - document.querySelectorAll("img").forEach((img) => { - const s = img.currentSrc || img.src; - if (!s) return; - // YouTube thumbnails / channel avatars — skip our own tiny UI icons/data URIs. - if (/ytimg\.com|ggpht\.com|googleusercontent\.com/.test(s)) found.add(s); - }); - setSrcs([...found]); - }, []); - - useEffect(() => { - document.documentElement.dataset.mosaic = "1"; - return () => { - delete document.documentElement.dataset.mosaic; - }; - }, []); - - const tiles = useMemo(() => { - const n = cols * rows; - if (srcs.length === 0) return []; - return Array.from({ length: n }, (_, i) => srcs[i % srcs.length]); - }, [srcs, cols, rows]); - - return ( - - ); -} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4409b5a..3f112a1 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -905,7 +905,7 @@ function PlexShowView({ } return ( -
+
{q.isLoading || !d || !show ? ( @@ -1103,7 +1103,7 @@ function PlexSeasonView({ } return ( -
+
{q.isLoading || !d || !se ? ( diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 1c8a28a..f05bcdf 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -90,12 +90,11 @@ export default function PlexInfo({ t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); return ( -
+
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
{/* Header: close (overlay) / customize */}
@@ -302,7 +301,6 @@ export default function PlexInfo({ {cast.length > 0 && (

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

{col.title}

diff --git a/frontend/src/index.css b/frontend/src/index.css index e735122..d0618c4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,27 +6,38 @@ --font-scale: 1.06; /* ===== Glass tunables — every value that materially drives the look lives here so it can be - tuned in ONE place (and live-driven by the dev GlassTuner during UAT). Defaults below equal - the historical hard-coded values, so nothing changes until a value is bumped. When the user - signs off on tuned values, bake the finals right here. ===== */ - --glass-blur: 30px; /* backdrop blur radius (2026 sweet spot is ~8–16px; 30 is heavy) */ + tuned in ONE place (and live-driven by the dev GlassTuner during UAT). ===== */ + /* 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). + Baked from UAT 2026-07-12. 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.4; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */ - --glass-surface-alpha: 78%; /* .glass panel translucency (higher = more solid = "adaptive") */ - --glass-card-alpha: 84%; /* .glass-card translucency */ - --glass-menu-alpha: 92%; /* .glass-menu translucency */ + --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: 0%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */ - --glass-scrim: 0%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */ - --ambient: 1; /* multiplier on the ambient accent backdrop pools */ + --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 */ +} - /* Optional muted thumbnail-mosaic backdrop (off unless mounts). Conservative - defaults: heavy blur + low saturation + darkened so it reads as subtle texture, never a rainbow. */ - --mosaic-blur: 64px; - --mosaic-sat: 0.7; - --mosaic-bright: 0.5; - --mosaic-opacity: 0.5; +/* ===== Rich-backdrop scope: surfaces that float over real imagery (Plex art hero/detail, and + later channel-page banners) want MORE translucency so the picture shows through — the "glass + over content" look. Put `.glass-media` on such a page root; every .glass* inside inherits these + (UAT-tuned 2026-07-12). Global chrome stays solid via the :root defaults above. ===== */ +.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%; } /* Make native controls (date picker, scrollbars) follow the theme. */ @@ -133,37 +144,6 @@ html[data-theme="dark"] .toast-card { border-color: color-mix(in srgb, #fff 50%, transparent); } -/* ===== Optional muted thumbnail-mosaic backdrop (mounted by ) ===== - Gives dark glass real content to refract. Kept conservative: heavy blur + low saturation + - darkened, so it reads as a soft texture behind everything, never a colourful collage. When it - is present, the ambient accent pools on are dimmed so the two don't fight. */ -.mosaic-backdrop { - position: fixed; - inset: 0; - z-index: -1; - overflow: hidden; - pointer-events: none; -} -.mosaic-backdrop .mosaic-tiles { - position: absolute; - inset: -10%; - display: grid; - gap: 0; - filter: blur(var(--mosaic-blur)) saturate(var(--mosaic-sat)) brightness(var(--mosaic-bright)); - opacity: var(--mosaic-opacity); -} -.mosaic-backdrop .mosaic-tiles > span { - display: block; - background-size: cover; - background-position: center; -} -html[data-mosaic="1"] body { - background: var(--bg); -} -html[data-perf="1"] .mosaic-backdrop { - display: none; -} - /* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */ html[data-perf="1"] .glass, html[data-perf="1"] .glass-menu, @@ -316,6 +296,50 @@ html[data-scheme="youtube"][data-theme="light"] { --accent-fg: #ffffff; } +/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */ +html[data-scheme="starship"][data-theme="dark"] { + --bg: #050b16; + --surface: #0b1728; + --card: #101f36; + --border: #1f3557; + --fg: #dfeaff; + --muted: #8aa6cc; + --accent: #38b6ff; + --accent-fg: #04101f; +} +html[data-scheme="starship"][data-theme="light"] { + --bg: #eef4fb; + --surface: #ffffff; + --card: #ffffff; + --border: #d3e0f0; + --fg: #0b1b30; + --muted: #4a5f7d; + --accent: #0b6bd6; + --accent-fg: #ffffff; +} + +/* Matrix — near-black with a phosphor-green terminal accent */ +html[data-scheme="matrix"][data-theme="dark"] { + --bg: #030a06; + --surface: #08130d; + --card: #0b1c12; + --border: #163d27; + --fg: #d6ffe4; + --muted: #6fae86; + --accent: #2fe36e; + --accent-fg: #02160a; +} +html[data-scheme="matrix"][data-theme="light"] { + --bg: #f0f7f1; + --surface: #ffffff; + --card: #ffffff; + --border: #cfe6d6; + --fg: #08130b; + --muted: #4c6b56; + --accent: #12a150; + --accent-fg: #ffffff; +} + /* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */ @keyframes toastbar { from { diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index db8caac..f9aa5ed 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -1,13 +1,15 @@ import { LS, readAccountMerged, writeAccount } from "./storage"; type Mode = "dark" | "light"; -export type Scheme = "midnight" | "forest" | "slate" | "youtube"; +export type Scheme = "midnight" | "forest" | "slate" | "youtube" | "starship" | "matrix"; export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [ { id: "midnight", name: "Midnight", swatch: "#6d8cff" }, { id: "forest", name: "Forest", swatch: "#2dd4bf" }, { id: "slate", name: "Slate", swatch: "#e8913c" }, { id: "youtube", name: "YouTube", swatch: "#ff3b46" }, + { id: "starship", name: "Starship", swatch: "#38b6ff" }, + { id: "matrix", name: "Matrix", swatch: "#2fe36e" }, ]; export interface ThemePrefs { From c1ba5229c36790bc973f5f6fab2927a27bc719a8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 20:27:37 +0200 Subject: [PATCH 03/20] =?UTF-8?q?fix(glass):=20soften=20the=20Matrix=20sch?= =?UTF-8?q?eme=20accent=20(neon=20phosphor=20=E2=86=92=20muted=20emerald)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAT: #2fe36e read as too vivid/stabby. Drop to #46b87c (lower saturation) + slightly less green-tinted fg; update the swatch to match. --- frontend/src/index.css | 8 ++++---- frontend/src/lib/theme.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index d0618c4..2ed21a0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -318,16 +318,16 @@ html[data-scheme="starship"][data-theme="light"] { --accent-fg: #ffffff; } -/* Matrix — near-black with a phosphor-green terminal accent */ +/* 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: #d6ffe4; + --fg: #cfe8d6; --muted: #6fae86; - --accent: #2fe36e; - --accent-fg: #02160a; + --accent: #46b87c; + --accent-fg: #04170c; } html[data-scheme="matrix"][data-theme="light"] { --bg: #f0f7f1; diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index f9aa5ed..a6d080c 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -9,7 +9,7 @@ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [ { id: "slate", name: "Slate", swatch: "#e8913c" }, { id: "youtube", name: "YouTube", swatch: "#ff3b46" }, { id: "starship", name: "Starship", swatch: "#38b6ff" }, - { id: "matrix", name: "Matrix", swatch: "#2fe36e" }, + { id: "matrix", name: "Matrix", swatch: "#46b87c" }, ]; export interface ThemePrefs { From 036e2fe7ea9bb017004b03c078c671b872f56f35 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 20:40:20 +0200 Subject: [PATCH 04/20] feat(glass): dev-prototype feed backdrop (blurred rotating thumbnail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idea from UAT-2: give the feeds the same 'content behind glass' lift the Plex detail page gets. New FeedBackdrop paints a faint, slowly-rotating feed thumbnail as a fixed backdrop on
(the proven art-backdrop path — a fixed z:-1 layer is occluded by the root's opaque bg-bg). Blur = tiny-canvas downscale + cover upscale (no CSS filter; ytimg is CORS-readable so it uses a data-URL, else raw). Flat --bg stays as the 'perf mode' look. Behind the GlassTuner toggle + a Backdrop-fade slider (--feedbg-fade) so it's judged/tuned in UAT before anything is committed for real. --- frontend/src/components/FeedBackdrop.tsx | 93 ++++++++++++++++++++++++ frontend/src/components/GlassTuner.tsx | 40 +++++++++- frontend/src/index.css | 4 + 3 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/FeedBackdrop.tsx diff --git a/frontend/src/components/FeedBackdrop.tsx b/frontend/src/components/FeedBackdrop.tsx new file mode 100644 index 0000000..6ad7009 --- /dev/null +++ b/frontend/src/components/FeedBackdrop.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; + +/** + * DEV prototype (toggled by GlassTuner): a faint, blurred, slowly-rotating feed thumbnail painted + * as a fixed backdrop so the glass surfaces have real content to refract — the same trick that makes + * the Plex detail page pop, brought to the YT/Plex feeds. The flat `--bg` then reads as "perf mode". + * + * It writes `
`'s background (NOT a separate fixed layer): a fixed z-index:-1 layer is occluded + * by the app root's opaque `bg-bg`, whereas `
` is a transparent descendant whose own background + * paints above it — this is exactly how `useArtBackdrop` (Plex) works, reused here. + * + * Blur: downscale the thumbnail into a tiny canvas and let `background-size: cover` upscale it back — + * a cheap gaussian-ish blur with no CSS filter (which can't touch a background-image). Falls back to + * the raw url if the image isn't CORS-readable (still soft once upscaled + heavily faded). + */ +export default function FeedBackdrop() { + const [srcs, setSrcs] = useState([]); + const [idx, setIdx] = useState(0); + const [art, setArt] = useState(null); + + // Sample the video thumbnails currently on screen. + useEffect(() => { + const found: string[] = []; + document.querySelectorAll("img").forEach((img) => { + const s = img.currentSrc || img.src; + if (/i\.ytimg\.com\/vi\//.test(s)) found.push(s); + }); + setSrcs([...new Set(found)]); + }, []); + + // Slowly cycle through them. + useEffect(() => { + if (srcs.length < 2) return; + const t = setInterval(() => setIdx((i) => (i + 1) % srcs.length), 14000); + return () => clearInterval(t); + }, [srcs]); + + // Blur the current pick via a tiny-canvas downscale (fallback: raw url on CORS taint). + useEffect(() => { + const url = srcs[idx]; + if (!url) return; + let cancelled = false; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + if (cancelled) return; + try { + const c = document.createElement("canvas"); + c.width = 40; + c.height = 22; + c.getContext("2d")!.drawImage(img, 0, 0, 40, 22); + setArt(c.toDataURL("image/jpeg", 0.6)); + } catch { + setArt(url); + } + }; + img.onerror = () => { + if (!cancelled) setArt(url); + }; + img.src = url; + return () => { + cancelled = true; + }; + }, [srcs, idx]); + + // Paint (and clean up) the faded backdrop on
. + useEffect(() => { + const main = document.querySelector("main"); + if (!main) return; + const s = main.style; + const clear = () => { + s.backgroundImage = ""; + s.backgroundSize = ""; + s.backgroundPosition = ""; + s.backgroundRepeat = ""; + s.backgroundAttachment = ""; + }; + if (art) { + s.backgroundImage = + `linear-gradient(color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent), ` + + `color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent)), url("${art}")`; + s.backgroundSize = "cover"; + s.backgroundPosition = "center"; + s.backgroundRepeat = "no-repeat"; + s.backgroundAttachment = "fixed"; + } else { + clear(); + } + return clear; + }, [art]); + + return null; +} diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 5b899c5..f287cba 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import FeedBackdrop from "./FeedBackdrop"; /** * DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page. @@ -77,13 +78,19 @@ const PRESETS: { id: string; name: string; hint: string; over: Record document.documentElement; const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); type Saved = { vars?: Record; palette?: Record; + feedbg?: boolean; open?: boolean; }; @@ -103,6 +110,7 @@ export default function GlassTuner() { return { ...base, ...(initial.vars || {}) }; }); const [palette, setPalette] = useState>(() => initial.palette || {}); + const [feedbg, setFeedbg] = useState(() => Boolean(initial.feedbg)); const [open, setOpen] = useState(() => initial.open ?? true); const [tab, setTab] = useState<"glass" | "palette">("glass"); const [copied, setCopied] = useState(false); @@ -199,10 +207,17 @@ export default function GlassTuner() { persist({ open: v }); } + function toggleFeedbg(v: boolean) { + setFeedbg(v); + persist({ feedbg: v }); + } + if (!DEV_TUNER) return null; return ( -
+ <> + {feedbg && } +
{!open && (
+ {/* Feed backdrop (dev prototype) */} + + {/* Tab switch */}
{(["glass", "palette"] as const).map((t) => ( @@ -269,6 +295,13 @@ export default function GlassTuner() { {SLIDERS.map((s) => ( setVar(s.k, v)} /> ))} + {feedbg && ( +
+ {FEEDBG_SLIDERS.map((s) => ( + setVar(s.k, v)} /> + ))} +
+ )}
)} @@ -313,7 +346,8 @@ export default function GlassTuner() {
)} -

+
+ ); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 2ed21a0..7471124 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -21,6 +21,10 @@ --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 */ + + /* Dev-prototype feed backdrop (see ): how strongly the faded --bg overlay hides the + blurred thumbnail — higher = fainter image. Only in effect while the backdrop is toggled on. */ + --feedbg-fade: 86%; } /* ===== Rich-backdrop scope: surfaces that float over real imagery (Plex art hero/detail, and From f9f90cf6094cebfe8c249d89ead2bb654bf816ae Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 21:20:40 +0200 Subject: [PATCH 05/20] feat(glass): per-scheme background images + glass-over-image mode Land the UAT-approved backdrop direction as a real feature (replacing the dev thumbnail prototype): - 6 generated abstract SVG backdrops in public/backdrops/, one tuned to each accent scheme (dark, mixed-colour, few light areas). Non-figurative + generated = repo-safe, tiny, accent-matched. - New data-backdrop="on" state (dark theme + the setting + not perf) paints the per-scheme image on (app root now transparent so it shows app-wide and the glass refracts it) AND switches the glass to its translucent tier (the user's tuned values: surface 50 / card 60 / scrim 30 / blur 10 / edge 25). Off / light / perf -> flat colour + solid glass (the readable fallback). - Settings > Appearance 'Background image' toggle (bgImage pref, dark-only), trilingual. Flat is the fallback + opt-out. - Remove the FeedBackdrop prototype + its tuner controls; tuner baseline resynced to the glass-over-image tier, add a Backdrop-fade slider. GOTCHA fixed: the dark-mode ambient 'background' shorthand reset background-size to auto (image rendered un-covered); out-specified it with [data-theme="dark"]. --- frontend/public/backdrops/forest.svg | 18 +++++ frontend/public/backdrops/matrix.svg | 18 +++++ frontend/public/backdrops/midnight.svg | 18 +++++ frontend/public/backdrops/slate.svg | 18 +++++ frontend/public/backdrops/starship.svg | 18 +++++ frontend/public/backdrops/youtube.svg | 18 +++++ frontend/src/App.tsx | 10 ++- frontend/src/components/FeedBackdrop.tsx | 93 ---------------------- frontend/src/components/GlassTuner.tsx | 88 +++++++------------- frontend/src/components/SettingsPanel.tsx | 11 +++ frontend/src/i18n/locales/de/settings.json | 2 + frontend/src/i18n/locales/en/settings.json | 2 + frontend/src/i18n/locales/hu/settings.json | 2 + frontend/src/index.css | 34 ++++++-- frontend/src/lib/theme.ts | 3 + 15 files changed, 191 insertions(+), 162 deletions(-) create mode 100644 frontend/public/backdrops/forest.svg create mode 100644 frontend/public/backdrops/matrix.svg create mode 100644 frontend/public/backdrops/midnight.svg create mode 100644 frontend/public/backdrops/slate.svg create mode 100644 frontend/public/backdrops/starship.svg create mode 100644 frontend/public/backdrops/youtube.svg delete mode 100644 frontend/src/components/FeedBackdrop.tsx diff --git a/frontend/public/backdrops/forest.svg b/frontend/public/backdrops/forest.svg new file mode 100644 index 0000000..5e8f0f4 --- /dev/null +++ b/frontend/public/backdrops/forest.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg new file mode 100644 index 0000000..3e699a7 --- /dev/null +++ b/frontend/public/backdrops/matrix.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/midnight.svg b/frontend/public/backdrops/midnight.svg new file mode 100644 index 0000000..217531f --- /dev/null +++ b/frontend/public/backdrops/midnight.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/slate.svg b/frontend/public/backdrops/slate.svg new file mode 100644 index 0000000..b5c4889 --- /dev/null +++ b/frontend/public/backdrops/slate.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/starship.svg b/frontend/public/backdrops/starship.svg new file mode 100644 index 0000000..2a8d704 --- /dev/null +++ b/frontend/public/backdrops/starship.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/backdrops/youtube.svg b/frontend/public/backdrops/youtube.svg new file mode 100644 index 0000000..6d50059 --- /dev/null +++ b/frontend/public/backdrops/youtube.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d178b4..5dc1d0c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -381,6 +381,14 @@ export default function App() { document.documentElement.dataset.perf = perf ? "1" : ""; setAccountRaw(PERF_KEY, perf ? "1" : "0"); }, [perf]); + + // The per-scheme background image (the "glass over image" look) is on only in dark theme, when + // the user hasn't opted out, and not in perf mode. Drives `html[data-backdrop]` (see index.css), + // which both paints the image on and switches the glass to its translucent tier. + useEffect(() => { + const on = theme.mode === "dark" && theme.bgImage && !perf; + document.documentElement.dataset.backdrop = on ? "on" : "off"; + }, [theme.mode, theme.bgImage, perf]); useEffect(() => setHintsEnabled(hints), [hints]); useEffect(() => configureNotifications(notif), [notif]); @@ -726,7 +734,7 @@ export default function App() { ); return ( -
+
`'s background (NOT a separate fixed layer): a fixed z-index:-1 layer is occluded - * by the app root's opaque `bg-bg`, whereas `
` is a transparent descendant whose own background - * paints above it — this is exactly how `useArtBackdrop` (Plex) works, reused here. - * - * Blur: downscale the thumbnail into a tiny canvas and let `background-size: cover` upscale it back — - * a cheap gaussian-ish blur with no CSS filter (which can't touch a background-image). Falls back to - * the raw url if the image isn't CORS-readable (still soft once upscaled + heavily faded). - */ -export default function FeedBackdrop() { - const [srcs, setSrcs] = useState([]); - const [idx, setIdx] = useState(0); - const [art, setArt] = useState(null); - - // Sample the video thumbnails currently on screen. - useEffect(() => { - const found: string[] = []; - document.querySelectorAll("img").forEach((img) => { - const s = img.currentSrc || img.src; - if (/i\.ytimg\.com\/vi\//.test(s)) found.push(s); - }); - setSrcs([...new Set(found)]); - }, []); - - // Slowly cycle through them. - useEffect(() => { - if (srcs.length < 2) return; - const t = setInterval(() => setIdx((i) => (i + 1) % srcs.length), 14000); - return () => clearInterval(t); - }, [srcs]); - - // Blur the current pick via a tiny-canvas downscale (fallback: raw url on CORS taint). - useEffect(() => { - const url = srcs[idx]; - if (!url) return; - let cancelled = false; - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = () => { - if (cancelled) return; - try { - const c = document.createElement("canvas"); - c.width = 40; - c.height = 22; - c.getContext("2d")!.drawImage(img, 0, 0, 40, 22); - setArt(c.toDataURL("image/jpeg", 0.6)); - } catch { - setArt(url); - } - }; - img.onerror = () => { - if (!cancelled) setArt(url); - }; - img.src = url; - return () => { - cancelled = true; - }; - }, [srcs, idx]); - - // Paint (and clean up) the faded backdrop on
. - useEffect(() => { - const main = document.querySelector("main"); - if (!main) return; - const s = main.style; - const clear = () => { - s.backgroundImage = ""; - s.backgroundSize = ""; - s.backgroundPosition = ""; - s.backgroundRepeat = ""; - s.backgroundAttachment = ""; - }; - if (art) { - s.backgroundImage = - `linear-gradient(color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent), ` + - `color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent)), url("${art}")`; - s.backgroundSize = "cover"; - s.backgroundPosition = "center"; - s.backgroundRepeat = "no-repeat"; - s.backgroundAttachment = "fixed"; - } else { - clear(); - } - return clear; - }, [art]); - - return null; -} diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index f287cba..888ac88 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import FeedBackdrop from "./FeedBackdrop"; /** * DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page. @@ -26,18 +25,20 @@ type Slider = { }; // 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: 20 }, - { k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.8 }, - { k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.25 }, - { k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 94 }, - { k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 94 }, - { k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 98 }, - { k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 78 }, - { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 10 }, - { k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 16 }, - { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 14 }, + { 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 }, + { k: "--bg-fade", label: "Backdrop fade", min: 40, max: 97, step: 1, unit: "%", def: 60 }, ]; const PALETTE: { k: string; label: string }[] = [ @@ -52,22 +53,20 @@ const PALETTE: { k: string; label: string }[] = [ // Each preset resets every slider to its default, then applies these overrides. const PRESETS: { id: string; name: string; hint: string; over: Record }[] = [ - { id: "current", name: "Current", hint: "The baked baseline", over: {} }, + { id: "current", name: "Glass/image", hint: "The default (over image)", over: {} }, { - id: "media", - name: "Media (glassy)", - hint: "For art-backed pages", + id: "solid", + name: "Solid (flat)", + hint: "As shown w/ image off", over: { - "--glass-blur": 10, - "--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, + "--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, }, }, { @@ -78,19 +77,13 @@ const PRESETS: { id: string; name: string; hint: string; over: Record document.documentElement; const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); type Saved = { vars?: Record; palette?: Record; - feedbg?: boolean; open?: boolean; }; @@ -110,7 +103,6 @@ export default function GlassTuner() { return { ...base, ...(initial.vars || {}) }; }); const [palette, setPalette] = useState>(() => initial.palette || {}); - const [feedbg, setFeedbg] = useState(() => Boolean(initial.feedbg)); const [open, setOpen] = useState(() => initial.open ?? true); const [tab, setTab] = useState<"glass" | "palette">("glass"); const [copied, setCopied] = useState(false); @@ -207,17 +199,10 @@ export default function GlassTuner() { persist({ open: v }); } - function toggleFeedbg(v: boolean) { - setFeedbg(v); - persist({ feedbg: v }); - } - if (!DEV_TUNER) return null; return ( - <> - {feedbg && } -
+
{!open && (
- {/* Feed backdrop (dev prototype) */} - - {/* Tab switch */}
{(["glass", "palette"] as const).map((t) => ( @@ -295,13 +269,6 @@ export default function GlassTuner() { {SLIDERS.map((s) => ( setVar(s.k, v)} /> ))} - {feedbg && ( -
- {FEEDBG_SLIDERS.map((s) => ( - setVar(s.k, v)} /> - ))} -
- )}
)} @@ -346,8 +313,7 @@ export default function GlassTuner() {
)} -
- +
); } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 0399ea3..6675db0 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -158,6 +158,17 @@ function Appearance({ prefs }: { prefs: PrefsController }) { onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} /> + + setTheme({ ...theme, bgImage: v })} + /> + ): how strongly the faded --bg overlay hides the - blurred thumbnail — higher = fainter image. Only in effect while the backdrop is toggled on. */ - --feedbg-fade: 86%; + /* 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%; } -/* ===== Rich-backdrop scope: surfaces that float over real imagery (Plex art hero/detail, and - later channel-page banners) want MORE translucency so the picture shows through — the "glass - over content" look. Put `.glass-media` on such a page root; every .glass* inside inherits these - (UAT-tuned 2026-07-12). Global chrome stays solid via the :root defaults above. ===== */ +/* ===== "Glass over image" — the translucent tier (UAT-tuned 2026-07-12). 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 / light theme / perf), the :root defaults keep it solid & readable. ===== */ +html[data-backdrop="on"], .glass-media { --glass-blur: 10px; --glass-saturate: 1.6; @@ -44,6 +46,24 @@ --glass-scrim: 30%; } +/* Per-scheme background image, painted on (the app root is transparent so it shows through + the whole app and the glass refracts it). A faded --bg overlay keeps it subtle; `fixed` so it + doesn't scroll. Only when the backdrop is on (dark theme + the "Background image" setting). */ +html[data-backdrop="on"][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-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-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-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-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-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"); } +/* [data-theme="dark"] added purely to out-specify the `html[data-theme="dark"] body` ambient rule + below, whose `background` shorthand would otherwise reset background-size back to auto. */ +html[data-backdrop="on"][data-theme="dark"] body { + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-attachment: fixed; +} + /* Make native controls (date picker, scrollbars) follow the theme. */ html[data-theme="dark"] { color-scheme: dark; diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index a6d080c..e3a97e6 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -16,12 +16,15 @@ export interface ThemePrefs { mode: Mode; scheme: Scheme; fontScale: number; + /** Show the per-scheme background image (dark theme only); off = flat colour. */ + bgImage: boolean; } export const DEFAULT_THEME: ThemePrefs = { mode: "dark", scheme: "midnight", fontScale: 1.06, + bgImage: true, }; export function applyTheme(t: ThemePrefs): void { From a187b2f4759f1156dddab5c0fafe30909e505d3c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 22:24:09 +0200 Subject: [PATCH 06/20] feat(glass): user-facing Image-fade slider under Settings > Background image Replaces the dev-only backdrop-fade knob with a real control: when Background image is on (dark theme), a 0-100% 'Image fade' slider appears beneath it and drives --bg-fade live (higher = fainter). Persists to DB + localStorage via the existing theme prefs path (ThemePrefs.bgFade, applyTheme sets the CSS var). Removed --bg-fade from the GlassTuner so the two don't fight over the var. --- frontend/src/components/GlassTuner.tsx | 1 - frontend/src/components/SettingsPanel.tsx | 18 ++++++++++++++++++ frontend/src/i18n/locales/de/settings.json | 1 + frontend/src/i18n/locales/en/settings.json | 1 + frontend/src/i18n/locales/hu/settings.json | 1 + frontend/src/lib/theme.ts | 4 ++++ 6 files changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 888ac88..eb59791 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -38,7 +38,6 @@ const SLIDERS: Slider[] = [ { 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 }, - { k: "--bg-fade", label: "Backdrop fade", min: 40, max: 97, step: 1, unit: "%", def: 60 }, ]; const PALETTE: { k: string; label: string }[] = [ diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 6675db0..8ebd4cc 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -169,6 +169,24 @@ function Appearance({ prefs }: { prefs: PrefsController }) { onChange={(v) => setTheme({ ...theme, bgImage: v })} /> + {theme.mode === "dark" && theme.bgImage && ( +
+
+ {t("settings.appearance.backgroundFade")} + {theme.bgFade}% +
+ setTheme({ ...theme, bgFade: Number(e.target.value) })} + aria-label={t("settings.appearance.backgroundFade")} + className="w-full accent-accent" + /> +
+ )} Date: Sun, 12 Jul 2026 22:28:50 +0200 Subject: [PATCH 07/20] =?UTF-8?q?chore(release):=200.42.0=20=E2=80=94=20gl?= =?UTF-8?q?ass-over-image=20look=20+=20Starship/Matrix=20schemes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ca59bb0..57c1868 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.0 \ No newline at end of file +0.42.0 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index e035467..9fb890e 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,18 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.42.0", + date: "2026-07-12", + summary: "A refreshed “glass over image” look for dark theme, plus two new colour schemes.", + 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).", + ], + chores: [ + "Reworked the translucent “glass” surface system so the whole app shares one consistent, tunable look.", + ], + }, { version: "0.41.0", date: "2026-07-12", From 7fd5f204908f75a02aa72b8e5b36570e5707f666 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 22:44:57 +0200 Subject: [PATCH 08/20] feat(glass): glassify the side rails + back-to-top (Phase 1, feed chrome) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the always-visible flat rails to the frosted glass system so they sit consistently over the new background image (like the nav/header already do): - Feed filter Sidebar + CollapsedFilterRail: bg-surface/40 -> glass - Playlists left rail: bg-surface/40 -> glass - BackToTop FAB: bg-card+border -> glass-card glass-hover Small controls inside (filter pills/inputs) stay solid — a solid-on-glass hierarchy, not everything translucent. --- frontend/src/components/BackToTop.tsx | 2 +- frontend/src/components/CollapsedFilterRail.tsx | 2 +- frontend/src/components/Playlists.tsx | 2 +- frontend/src/components/Sidebar.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/BackToTop.tsx index 26bc792..f23519c 100644 --- a/frontend/src/components/BackToTop.tsx +++ b/frontend/src/components/BackToTop.tsx @@ -34,7 +34,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr return createPortal( @@ -171,7 +171,7 @@ export default function WatchPage() {
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow player instead of a wide box with black bars; a landscape clip fills the width. */} -
+
{meta.allow_download && ( {T.download} @@ -217,7 +217,7 @@ export default function WatchPage() { target="_blank" rel="noopener noreferrer" title={url} - className="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-teal-400 hover:underline min-w-0" + className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0" > {displayUrl(url)} @@ -228,7 +228,7 @@ export default function WatchPage() {
)}
-
{T.brand}
+
{T.brand}
); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6bd188b..869717b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -20,6 +20,13 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } }, }); +// Baseline theme so the token CSS vars (--bg/--surface/--accent/…) resolve on first paint — including +// the standalone public pages (/watch, /privacy, /terms) that render outside and never call +// applyTheme. The app overrides this with the user's saved theme once it mounts. +const de = document.documentElement.dataset; +de.theme ||= "dark"; +de.scheme ||= "midnight"; + const path = window.location.pathname; const root = path === "/privacy" ? : From 9f664a7ca68b878d7a24f91a33195102a3652543 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 23:30:50 +0200 Subject: [PATCH 11/20] chore(glass): scrub dev-process/hostname refs from tracked comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review (public-repo rule): the GlassTuner + index.css/App.tsx comments named the prod hostname and dev-process jargon (UAT/HMR/dev ports/epic), which ship in the public bundle. Genericised the comments — no behaviour change. --- frontend/src/App.tsx | 3 +-- frontend/src/components/GlassTuner.tsx | 11 +++++------ frontend/src/index.css | 6 +++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5dc1d0c..cccc42e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -912,8 +912,7 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} - {/* DEV-ONLY live appearance tuner (localhost-gated; self-returns null on prod). Delete when - the glass-consistency epic bakes the final values into index.css. */} + {/* Local-only live appearance tuner (renders only on localhost; null everywhere else). */}
); diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index eb59791..f922025 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,13 +1,12 @@ import { useEffect, useMemo, useState } from "react"; /** - * DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page. - * It writes the glass CSS custom properties (index.css `:root`) inline on for instant, - * build-free feedback, and persists to localStorage so tweaks survive an F5 during UAT. + * 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. * - * Gated to localhost/127.0.0.1 so it renders on the :8080 UAT build and :5173 HMR but NEVER on - * prod (siftlode.b1fr0st.eu). It is a scaffold for the glass-consistency epic: dial in values - * here, hit "Copy CSS", hand them over to bake into index.css — then this component is deleted. + * 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); diff --git a/frontend/src/index.css b/frontend/src/index.css index f6f7381..233e523 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,10 +6,10 @@ --font-scale: 1.06; /* ===== Glass tunables — every value that materially drives the look lives here so it can be - tuned in ONE place (and live-driven by the dev GlassTuner during UAT). ===== */ + 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). - Baked from UAT 2026-07-12. Rich-backdrop surfaces override these via `.glass-media` below. */ + 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 */ @@ -27,7 +27,7 @@ --bg-fade: 60%; } -/* ===== "Glass over image" — the translucent tier (UAT-tuned 2026-07-12). Applies whenever a +/* ===== "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 From 914cd79db9ff02b32d93bb9f408210b7f84d0aa0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 23:53:56 +0200 Subject: [PATCH 12/20] fix(glass): give light theme its own translucent tier + unify the Plex filter rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light theme has no background image, so the adaptive :root glass (94%) rendered as flat snow-white islands, while the Plex filter rail (still on bg-surface/40) showed the ambient tint — an inconsistency. Fix centrally, not per-component: - index.css: one html[data-theme="light"] token override (surface 62 / card 74 / menu 86) so every .glass* surface is translucent in light and picks up the ambient tint, like it refracts the backdrop in dark. - PlexSidebar: bg-surface/40 -> glass, so all filter rails bind to the same token. One central lever now controls the light-theme glass translucency for the whole app. --- frontend/src/components/PlexSidebar.tsx | 2 +- frontend/src/index.css | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index e797d62..bdd8f47 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -120,7 +120,7 @@ export default function PlexSidebar({ } return ( - - ); -} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9ee22d1..57d7d20 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -103,7 +103,9 @@ export default function Header({ } return ( -
+ // Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) + + // filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change. +
s !== "done").length; + // Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook, + // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. + const listFade = useScrollFade(); + type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift @@ -268,7 +273,7 @@ export default function NavSidebar({ return (
-
+
{userItems.map(renderItem)} {systemItems.length > 0 && (collapsed ? ( diff --git a/frontend/src/components/PanelGroup.tsx b/frontend/src/components/PanelGroup.tsx new file mode 100644 index 0000000..56cb359 --- /dev/null +++ b/frontend/src/components/PanelGroup.tsx @@ -0,0 +1,84 @@ +import { type ReactNode, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse, +// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from +// the feed sidebar's WidgetCard so all three panels share the same island look and behavior. +export default function PanelGroup({ + id, + title, + editing, + collapsed, + hidden, + onToggleCollapse, + onToggleHidden, + children, +}: { + id: string; + title: string; + editing: boolean; + collapsed: boolean; + hidden: boolean; + onToggleCollapse: () => void; + onToggleHidden: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled: !editing, + }); + const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition }; + const showBody = !editing && !collapsed; + + return ( +
+
+ {editing && ( + + )} + + {editing ? ( + + ) : ( + + )} +
+ {showBody &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/PanelGroups.tsx new file mode 100644 index 0000000..be4d270 --- /dev/null +++ b/frontend/src/components/PanelGroups.tsx @@ -0,0 +1,80 @@ +import { type ReactNode } from "react"; +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import type { PanelLayout } from "../lib/panelLayout"; +import PanelGroup from "./PanelGroup"; + +export type PanelItem = { id: string; title: string; body: ReactNode }; + +// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group +// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE +// groups (a caller drops facet-gated / empty ones); the layout order is filtered to them so a +// stored order that references a now-absent group is tolerated. Reordering/hiding is active only +// in `editing` mode. Shared by all three side panels. +export default function PanelGroups({ + items, + layout, + setLayout, + editing, +}: { + items: PanelItem[]; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + editing: boolean; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const byId = new Map(items.map((it) => [it.id, it])); + + const orderedAvailable = layout.order.filter((id) => byId.has(id)); + // Append any available id missing from the stored order (e.g. a group that became available + // after the layout was saved) so it never silently disappears. + for (const it of items) if (!orderedAvailable.includes(it.id)) orderedAvailable.push(it.id); + const renderedIds = editing + ? orderedAvailable + : orderedAvailable.filter((id) => !layout.hidden[id]); + + function toggleCollapse(id: string) { + setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); + } + function toggleHidden(id: string) { + setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); + } + function onDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + const from = layout.order.indexOf(active.id as string); + const to = layout.order.indexOf(over.id as string); + if (from < 0 || to < 0) return; + setLayout({ ...layout, order: arrayMove(layout.order, from, to) }); + } + + return ( + + +
+ {renderedIds.map((id) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index ee7f9b3..bb48586 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,6 +1,6 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { DndContext, PointerSensor, @@ -23,9 +23,7 @@ import { GripVertical, ListPlus, Pencil, - Pin, Play, - Plus, RefreshCw, RotateCcw, Trash2, @@ -33,11 +31,10 @@ import { X, Youtube, } from "lucide-react"; -import { api, type Playlist, type Video } from "../lib/api"; +import { api, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; @@ -95,9 +92,6 @@ const idsKey = (items: Video[]) => .sort() .join(","); -type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean }; -const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; - function Row({ video, index, @@ -185,31 +179,18 @@ function Row({ export default function Playlists({ canWrite, - search, + selectedId, + setSelectedId, }: { canWrite: boolean; - // The playlist-name filter, from the shared top SearchBar (App-owned state). - search: string; + // The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the + // list + its rail controls). This detail pane reacts to the selection. + selectedId: number | null; + setSelectedId: (id: number | null) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). - const [selectedId, setSelectedId] = useState(() => { - const s = getAccountRaw(LS.playlist); - return s ? Number(s) : null; - }); - useEffect(() => { - if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId)); - }, [selectedId]); - const selectedRef = useRef(null); - const scrolledRef = useRef(false); - // How the left playlist rail is ordered (persisted). - const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); - useEffect(() => { - writeAccount(LS.plSort, plSort); - }, [plSort]); - const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); // The item order is undoable: drag, sort and group all go through `order.set`, so each is @@ -244,21 +225,6 @@ export default function Playlists({ }); const playlists = listQuery.data ?? []; - // Keep the selection valid: default to the first playlist, and if the selected one - // disappears (e.g. just deleted) drop to the next available, or clear when none remain. - useEffect(() => { - // Wait for the first load before adjusting the selection — otherwise the empty - // placeholder list would null the restored selection and we'd fall back to the first. - if (!listQuery.data) return; - if (!playlists.length) { - if (selectedId != null) setSelectedId(null); - return; - } - if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { - setSelectedId(playlists[0].id); - } - }, [playlists, selectedId, listQuery.data]); - const detailQuery = useQuery({ queryKey: ["playlist", selectedId], queryFn: () => api.playlist(selectedId as number), @@ -308,48 +274,11 @@ export default function Playlists({ const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; - // The left rail in the chosen order. "custom" keeps the server position order (Array.sort - // is stable); "dirty first" floats playlists with unpushed edits to the top. - const sortedPlaylists = useMemo(() => { - const sign = plSort.dir === "asc" ? 1 : -1; - return [...playlists].sort((a, b) => { - if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; - switch (plSort.key) { - case "name": - return sign * plName(a).localeCompare(plName(b)); - case "count": - return sign * (a.item_count - b.item_count); - case "duration": - return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); - default: - return 0; // custom: server order - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playlists, plSort]); - - // Client-side name filter driven by the shared top SearchBar. - const q = search.trim().toLowerCase(); - const visiblePlaylists = useMemo( - () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), - // eslint-disable-next-line react-hooks/exhaustive-deps - [sortedPlaylists, q] - ); - - // Scroll the restored selection into view once after the rail first renders. - useEffect(() => { - if (!scrolledRef.current && selectedRef.current) { - selectedRef.current.scrollIntoView({ block: "nearest" }); - scrolledRef.current = true; - } - }, [sortedPlaylists]); - const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin. const editable = !builtin; const linked = editable && !!detail?.yt_playlist_id; - const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); const [reverting, setReverting] = useState(false); @@ -428,30 +357,6 @@ export default function Playlists({ } } - async function syncYoutube() { - if (syncing) return; - setSyncing(true); - try { - const r = await api.syncYoutubePlaylists(); - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist"] }); - notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); - } catch (e) { - notifyYouTubeActionError(e, t("playlists.syncFailed")); - } finally { - setSyncing(false); - } - } - - const createMut = useMutation({ - mutationFn: (name: string) => api.createPlaylist(name), - onSuccess: (pl: Playlist) => { - setNewName(""); - qc.invalidateQueries({ queryKey: ["playlists"] }); - setSelectedId(pl.id); - }, - }); - function refreshAll() { qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); @@ -538,123 +443,7 @@ export default function Playlists({ } return ( -
- - -
+
{selectedId == null || !detail ? (
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} @@ -869,7 +658,6 @@ export default function Playlists({ )} )} -
{playingIndex != null && items[playingIndex] && ( diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx new file mode 100644 index 0000000..534f35e --- /dev/null +++ b/frontend/src/components/PlaylistsRail.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; +import { api, type Playlist } from "../lib/api"; +import { formatDuration } from "../lib/format"; +import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; +import { LS, readAccountMerged, writeAccount } from "../lib/storage"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; + +type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean }; +const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; + +// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the +// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail +// controls (sort / new / sync); the selected playlist is App state so the module's detail pane +// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. +export default function PlaylistsRail({ + canWrite, + search, + selectedId, + setSelectedId, + layout, + setLayout, + collapsed, + onToggleCollapse, +}: { + canWrite: boolean; + search: string; + selectedId: number | null; + setSelectedId: (id: number | null) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); + useEffect(() => { + writeAccount(LS.plSort, plSort); + }, [plSort]); + const [newName, setNewName] = useState(""); + const [syncing, setSyncing] = useState(false); + const [editing, setEditing] = useState(false); + const selectedRef = useRef(null); + const scrolledRef = useRef(false); + + const listQuery = useQuery({ + queryKey: ["playlists"], + queryFn: () => api.playlists(), + refetchOnWindowFocus: true, + }); + const playlists = listQuery.data ?? []; + + // Keep the selection valid: default to the first playlist, and if the selected one disappears + // drop to the next available (or clear when none remain). Waits for the first load. + useEffect(() => { + if (!listQuery.data) return; + if (!playlists.length) { + if (selectedId != null) setSelectedId(null); + return; + } + if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { + setSelectedId(playlists[0].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, selectedId, listQuery.data]); + + const plName = (p: { kind: string; name: string }) => + p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + + const sortedPlaylists = useMemo(() => { + const sign = plSort.dir === "asc" ? 1 : -1; + return [...playlists].sort((a, b) => { + if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; + switch (plSort.key) { + case "name": + return sign * plName(a).localeCompare(plName(b)); + case "count": + return sign * (a.item_count - b.item_count); + case "duration": + return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); + default: + return 0; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, plSort]); + + const q = search.trim().toLowerCase(); + const visiblePlaylists = useMemo( + () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sortedPlaylists, q] + ); + + useEffect(() => { + if (!scrolledRef.current && selectedRef.current) { + selectedRef.current.scrollIntoView({ block: "nearest" }); + scrolledRef.current = true; + } + }, [sortedPlaylists]); + + async function syncYoutube() { + if (syncing) return; + setSyncing(true); + try { + const r = await api.syncYoutubePlaylists(); + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); + } catch (e) { + notifyYouTubeActionError(e, t("playlists.syncFailed")); + } finally { + setSyncing(false); + } + } + + const createMut = useMutation({ + mutationFn: (name: string) => api.createPlaylist(name), + onSuccess: (pl: Playlist) => { + setNewName(""); + qc.invalidateQueries({ queryKey: ["playlists"] }); + setSelectedId(pl.id); + }, + }); + + const items: PanelItem[] = [ + { + id: "manage", + title: t("playlists.manageGroup"), + body: ( +
+ + {playlists.length > 1 && ( +
+ + + +
+ )} +
{ + e.preventDefault(); + if (newName.trim()) createMut.mutate(newName.trim()); + }} + className="flex items-center gap-1.5" + > + + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + disabled={!canWrite} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent disabled:opacity-50" + /> + +
+ ), + }, + { + id: "list", + title: t("playlists.listGroup"), + body: ( + <> + {playlists.length === 0 && !listQuery.isLoading && ( +
{t("playlists.noneYet")}
+ )} + {playlists.length > 0 && visiblePlaylists.length === 0 && ( +
{t("playlists.noMatches")}
+ )} +
+ {visiblePlaylists.map((pl) => ( + + ))} +
+ + ), + }, + ]; + + return ( + } + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("playlists"))} + editHint={t("sidebar.editHint")} + > + + + ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index bdd8f47..40c0117 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,16 +1,17 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; -// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the -// full metadata filter set (genre / rating / year / duration / added / content rating + the -// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State -// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the -// backend so the sidebar only offers what the library actually contains. +// The Plex module's left filter column — now the shared floating SidePanel with its filter groups +// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata +// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from +// the backend so the panel only offers what the library actually contains. type Props = { scope: string; // movie | show | both (unified cross-library scope) @@ -22,17 +23,17 @@ type Props = { filters: PlexFilters; setFilters: (f: PlexFilters) => void; onOpenPlaylist: (id: number) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; -// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime). const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const; const RATING_STEPS = [5, 6, 7, 8, 9]; const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; -// Duration buckets → [minSeconds|null, maxSeconds|null]. const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [ { key: "short", min: null, max: 90 * 60 }, { key: "mid", min: 90 * 60, max: 120 * 60 }, @@ -49,12 +50,15 @@ export default function PlexSidebar({ filters, setFilters, onOpenPlaylist, + layout, + setLayout, collapsed, onToggleCollapse, }: Props) { const { t } = useTranslation(); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); + const [editing, setEditing] = useState(false); const qc = useQueryClient(); async function createPlaylist() { const title = newPlaylist.trim(); @@ -64,29 +68,18 @@ export default function PlexSidebar({ qc.invalidateQueries({ queryKey: ["plex-playlists"] }); onOpenPlaylist(pl.id); } - // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters - // (faceted search) — so picking one filter narrows what the others offer. Refetches on any change. - // Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets, - // so keying on the whole `filters` object would re-run the heavy facet computation on every - // sort-direction toggle (or chip-title change) for an identical request. const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ queryKey: ["plex-facets", scope, show, facetKey], queryFn: () => api.plexFacets(scope, filters, show), enabled: !!scope, - placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker) + placeholderData: (prev) => prev, }); const facets = facetsQ.data; - // Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the - // unified scope has no single library). Only fetched while no collection is active (else the chip - // shows instead). Applying one sets the collection filter; the actual filtering already flows through - // /library + /facets by rating_key. const [collSearch, setCollSearch] = useState(""); const collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ - // "union" disambiguates from the editor's per-library ["plex-collections", ] key (a search - // term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working. queryKey: ["plex-collections", "union", collSearchDeb], queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), enabled: !filters.collection, @@ -106,112 +99,83 @@ export default function PlexSidebar({ setSort("title"); }; - // Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime). - // Ordered alphabetically by their localized label. const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) => t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), ); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; + const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0; - if (collapsed) { - return ; - } - - return ( -
+ ), + }, + { + id: "rating", + title: t("plex.filter.rating"), + body: ( + + patch({ ratingMin: null })}> + {t("plex.filter.any")} + + {RATING_STEPS.map((r) => ( + patch({ ratingMin: r })}> + {r}+ + + ))} + + ), + }, + ...(facets && facets.genres.length > 0 + ? [ + { + id: "genre", + title: t("plex.filter.genre"), + body: ( + <> + {filters.genres.length > 1 && ( +
+ {(["any", "all"] as const).map((m) => ( + + ))} +
+ )} + + {facets.genres.map((g) => ( + patch({ genres: toggleIn(filters.genres, g.value) })} > - {t(`plex.filter.match.${m}`)} - + {g.value} + ))} -
- )} - - {facets.genres.map((g) => ( - patch({ genres: toggleIn(filters.genres, g.value) })} - > - {g.value} - - ))} - - - )} - - {/* Year range */} -
-
- patch({ yearMin: v })} - /> - - patch({ yearMax: v })} - /> -
-
- - {/* Duration buckets — movie-only (a show has no single runtime) */} - {scope === "movie" && ( -
+ + + ), + }, + ] + : []), + { + id: "year", + title: t("plex.filter.year"), + body: ( +
+ patch({ yearMin: v })} + /> + + patch({ yearMax: v })} + /> +
+ ), + }, + ...(scope === "movie" + ? [ + { + id: "duration", + title: t("plex.filter.duration"), + body: ( ))} -
- )} - - {/* Added to Plex */} -
- - patch({ addedWithin: "" })}> - {t("plex.filter.any")} - - {ADDED_OPTS.map((a) => ( - patch({ addedWithin: a })}> - {t(`plex.filter.addedOpt.${a}`)} - - ))} - -
- - {/* Content rating (from facets) */} - {facets && facets.content_ratings.length > 0 && ( -
+ ), + }, + ] + : []), + { + id: "added", + title: t("plex.filter.added"), + body: ( + + patch({ addedWithin: "" })}> + {t("plex.filter.any")} + + {ADDED_OPTS.map((a) => ( + patch({ addedWithin: a })}> + {t(`plex.filter.addedOpt.${a}`)} + + ))} + + ), + }, + ...(facets && facets.content_ratings.length > 0 + ? [ + { + id: "contentrating", + title: t("plex.filter.contentRating"), + body: ( {facets.content_ratings.map((c) => ( ))} -
- )} - - )} - - ); -} + ), + }, + ] + : []), + ]; -function Section({ label, children }: { label: string; children: ReactNode }) { return ( -
-
{label}
- {children} -
+ } + count={activeCount} + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("plex"))} + editHint={t("sidebar.editHint")} + actions={ + anyActive ? ( + + ) : undefined + } + > + + ); } diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx new file mode 100644 index 0000000..5ca0593 --- /dev/null +++ b/frontend/src/components/SidePanel.tsx @@ -0,0 +1,127 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronLeft, Pencil, RotateCcw } from "lucide-react"; +import { useScrollFade } from "../lib/useScrollFade"; + +// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read +// as one system: a rounded glass card that floats beside the nav (aligned with the header top, +// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a +// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses +// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav). +export default function SidePanel({ + title, + icon, + count = 0, + collapsed, + onToggleCollapse, + editing = false, + onToggleEditing, + onReset, + editHint, + actions, + children, +}: { + title: string; + icon: ReactNode; + count?: number; + collapsed: boolean; + onToggleCollapse: () => void; + // Customize (reorder/hide) mode — omit onToggleEditing to hide the pencil entirely. + editing?: boolean; + onToggleEditing?: () => void; + onReset?: () => void; + editHint?: string; + // Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view). + actions?: ReactNode; + children: ReactNode; +}) { + const { t } = useTranslation(); + const fade = useScrollFade(); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index eec0175..3b0ee85 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,47 +1,16 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - Check, - ChevronDown, - ChevronLeft, - Eye, - EyeOff, - GripVertical, - Library, - Pencil, - RotateCcw, - Share2, - User, - X, -} from "lucide-react"; -import { - closestCenter, - DndContext, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +import { Library, Pencil, Share2, SlidersHorizontal, User, X } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; -import { - DEFAULT_LAYOUT, - type SidebarLayout, - type WidgetId, -} from "../lib/sidebarLayout"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import TagManager from "./TagManager"; import SavedViewsWidget from "./SavedViewsWidget"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -110,12 +79,10 @@ export default function Sidebar({ }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; - layout: SidebarLayout; - setLayout: (l: SidebarLayout) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; onFocusChannel: (name: string) => void; isDemo?: boolean; - // Full-height collapse (state owned by App, persisted to the user's preferences), mirroring - // the left nav rail. collapsed: boolean; onToggleCollapse: () => void; }) { @@ -123,11 +90,8 @@ export default function Sidebar({ const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; - // Live per-tag channel counts for the current filter context (scope, channel, date, - // search, watch state, other category's tags). Lets us show contextual counts and hide - // chips that no longer match anything. Keyed on filters so it refetches as they change. - // Debounce the search term (matching the feed) so typing doesn't refire facets per - // keystroke, and keep the previous counts on screen during a refetch so chips don't flicker. + // Live per-tag channel counts for the current filter context. Debounce the search term so + // typing doesn't refire facets per keystroke, and keep previous counts during a refetch. const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetsQuery = useQuery({ queryKey: ["facets", facetFilters], @@ -137,12 +101,8 @@ export default function Sidebar({ }); const facetsReady = !!facetsQuery.data; const facetCounts = facetsQuery.data?.counts ?? {}; - // Before facets load, fall back to the static global count so chips don't flash empty. const chipCount = (tag: Tag): number => facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; - // Visible chips: hide zero-count ones once facets are in, but always keep selected ones - // so an active filter can still be cleared. Sorted by count (largest first), then name — - // so scanning top→bottom the smallest counts end up at the very bottom. const visibleChips = (list: Tag[]): Tag[] => { const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) @@ -152,10 +112,6 @@ export default function Sidebar({ ); }; - // After a page refresh the channel name isn't in the URL (only the id is), so - // filters.channelName is undefined and the chip would fall back to "This channel". - // Resolve the title from the (cached) channel list keyed by id. Only fetch when a - // channel filter is active but its name is missing. const needChannelName = !!filters.channelId && !filters.channelName; const channelsQuery = useQuery({ queryKey: ["channels"], @@ -167,7 +123,6 @@ export default function Sidebar({ filters.channelName ?? channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ?? undefined; - // Don't flash the misleading "This channel" fallback while the name is still resolving. const channelChipLabel = resolvedChannelName ?? (needChannelName && channelsQuery.isLoading @@ -175,18 +130,11 @@ export default function Sidebar({ : t("sidebar.thisChannel")); const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); - // The user's own (non-system) tags — those assigned to channels in the Channel manager. - // Facets cover them too now (the "other" category), so they're contextual like the rest: - // zero-count chips hide once facets load, counts reflect the current filter. const userTags = tags.filter((t) => !t.system); const [customDates, setCustomDates] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false); const [editing, setEditing] = useState(false); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) - ); - function toggleTag(id: number) { const has = filters.tags.includes(id); setFilters({ @@ -198,17 +146,12 @@ export default function Sidebar({ const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo; const contentChanged = !filters.includeNormal || filters.includeShorts || filters.includeLive; - // Library (vs the default "my"/Mine) and a non-default provenance Source both narrow the feed, - // so they count as active filters too (Source only applies in Library scope). The header search - // `q` deliberately stays out — it has its own clear (✕) in the search box and isn't reset here. const scopeActive = filters.scope === "all"; const sourceActive = filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic"; const activeCount = filters.tags.length + (filters.show !== "unwatched" ? 1 : 0) + - // "relevance" is auto-applied while searching (no manual dropdown option), so it's a search - // artifact, not a user-chosen sort filter — don't let typing a query inflate the count. (filters.sort !== "newest" && filters.sort !== "relevance" ? 1 : 0) + (contentChanged ? 1 : 0) + (filters.channelId ? 1 : 0) + @@ -228,15 +171,10 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - // Reset every filter dimension to its default — including scope back to "my" (Mine) and the - // provenance Source (dropped with the rest, so it falls back to "organic"). The search `q` is - // intentionally preserved (it has its own ✕ in the search box). setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" }); } - const available: Record = { - // Saved views are personal state; hide the widget for the shared demo account (the API - // also rejects the writes via require_human). + const available: Record = { savedviews: !isDemo, date: true, language: languages.length > 0, @@ -244,22 +182,7 @@ export default function Sidebar({ tags: userTags.length > 0, }; - function toggleCollapse(id: WidgetId) { - setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); - } - function toggleHidden(id: WidgetId) { - setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); - } - function onDragEnd(e: DragEndEvent) { - const { active: a, over } = e; - if (!over || a.id === over.id) return; - const oldIndex = layout.order.indexOf(a.id as WidgetId); - const newIndex = layout.order.indexOf(over.id as WidgetId); - if (oldIndex < 0 || newIndex < 0) return; - setLayout({ ...layout, order: arrayMove(layout.order, oldIndex, newIndex) }); - } - - function widgetBody(id: WidgetId): React.ReactNode { + function widgetBody(id: string): ReactNode { switch (id) { case "savedviews": return ; @@ -447,23 +370,51 @@ export default function Sidebar({
); } + default: + return null; } } - const orderedAvailable = layout.order.filter((id) => available[id]); - const renderedIds = editing - ? orderedAvailable - : orderedAvailable.filter((id) => !layout.hidden[id]); + // Group ids come from the shared default order (panelLayout) so there's one source of truth; + // the actual render order is the user's saved layout (PanelGroups orders by it). + const items: PanelItem[] = defaultLayout("feed") + .order.filter((id) => available[id]) + .map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) })); - // Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that - // carries the active-filter count so you can tell filters are on without opening it. - if (collapsed) { - return ; - } + const actions = ( + <> + + + + ); return ( - + ); } - -function WidgetCard({ - id, - title, - editing, - collapsed, - hidden, - onToggleCollapse, - onToggleHidden, - children, -}: { - id: WidgetId; - title: string; - editing: boolean; - collapsed: boolean; - hidden: boolean; - onToggleCollapse: () => void; - onToggleHidden: () => void; - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - disabled: !editing, - }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - }; - const showBody = !editing && !collapsed; - - return ( -
-
- {editing && ( - - )} -
- {title} -
- {editing ? ( - - ) : ( - - )} -
- {showBody &&
{children}
} -
- ); -} - diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index f9e827a..d4e05aa 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -1,5 +1,7 @@ { "title": "Playlists", + "manageGroup": "Manage", + "listGroup": "Your playlists", "watchLater": "Watch later", "syncYoutube": "Sync from YouTube", "syncedToast": "Synced {{count}} playlists from YouTube", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index c32abdc..57cc4a5 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -1,5 +1,7 @@ { "title": "Lejátszási listák", + "manageGroup": "Kezelés", + "listGroup": "Listáid", "watchLater": "Megnézem később", "syncYoutube": "Szinkron YouTube-ról", "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", diff --git a/frontend/src/index.css b/frontend/src/index.css index 48b913c..4b5a5ec 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -113,6 +113,16 @@ body { background-attachment: fixed; } +/* Hide the native scrollbar while keeping the element scrollable (wheel/trackpad/keyboard). + Used on the nav rail so a high browser-zoom overflow scrolls without showing a scrollbar. */ +.no-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* legacy Edge/IE */ +} +.no-scrollbar::-webkit-scrollbar { + display: none; /* Chromium/WebKit */ +} + /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */ .glass { /* Frosted overlay surface: translucent enough that the blurred backdrop shows diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts new file mode 100644 index 0000000..cc8b1e0 --- /dev/null +++ b/frontend/src/lib/panelLayout.ts @@ -0,0 +1,73 @@ +// Per-user customization of a side panel's group cards: their order, which are collapsed, and +// which are hidden — the same "customize" affordance the feed filter rail had, generalized so +// every panel (feed / plex / playlists) has its own saved layout. Persisted to localStorage and +// the server-side `preferences` blob (schemaless), so it follows the account across devices. +import { LS, readAccount, writeAccount } from "./storage"; + +export type PanelId = "feed" | "plex" | "playlists"; + +export interface PanelLayout { + order: string[]; + collapsed: Record; + hidden: Record; +} + +// The default group order per panel. Group ids must match the ids each panel feeds to PanelGroups. +// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are +// appended by normalizeLayout, so nothing silently disappears. +const PANEL_ORDER: Record = { + feed: ["savedviews", "date", "tags", "language", "topic"], + plex: [ + "scope", + "playlists", + "watch", + "sort", + "people", + "collection", + "rating", + "genre", + "year", + "duration", + "added", + "contentrating", + ], + playlists: ["manage", "list"], +}; + +// localStorage key per panel (feed keeps its original key for backward compatibility). +const LS_KEY: Record = { + feed: LS.sidebarLayout, + plex: LS.plexLayout, + playlists: LS.playlistsLayout, +}; + +// Key on the server `preferences` blob per panel (feed keeps its original key). +export const PREF_KEY: Record = { + feed: "sidebarLayout", + plex: "plexLayout", + playlists: "playlistsLayout", +}; + +export function defaultLayout(panel: PanelId): PanelLayout { + return { order: [...PANEL_ORDER[panel]], collapsed: {}, hidden: {} }; +} + +export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout { + const all = PANEL_ORDER[panel]; + const r = (raw ?? {}) as Partial; + const order = Array.isArray(r.order) ? r.order.filter((x) => all.includes(x)) : []; + for (const id of all) if (!order.includes(id)) order.push(id); + return { + order, + collapsed: { ...(r.collapsed ?? {}) }, + hidden: { ...(r.hidden ?? {}) }, + }; +} + +export function loadLayout(panel: PanelId): PanelLayout { + return normalizeLayout(panel, readAccount(LS_KEY[panel], {})); +} + +export function saveLayoutLocal(panel: PanelId, l: PanelLayout): void { + writeAccount(LS_KEY[panel], l); +} diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts deleted file mode 100644 index 146c7b1..0000000 --- a/frontend/src/lib/sidebarLayout.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Per-user customization of the filter sidebar: order of the widget cards, which are -// collapsed, and which are hidden. Persisted to localStorage and the server-side -// `preferences` blob so it follows the account. - -// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no -// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically. -import { LS, readAccount, writeAccount } from "./storage"; - -export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags"; - -const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"]; - -export interface SidebarLayout { - order: WidgetId[]; - collapsed: Partial>; - hidden: Partial>; -} - -export const DEFAULT_LAYOUT: SidebarLayout = { - order: [...ALL_WIDGETS], - collapsed: {}, - hidden: {}, -}; - -// Tolerate stale/partial data: keep only known widgets and append any that are missing -// (e.g. a widget added in a later version) so nothing silently disappears. -export function normalizeLayout(raw: unknown): SidebarLayout { - const r = (raw ?? {}) as Partial; - const order: WidgetId[] = Array.isArray(r.order) - ? (r.order.filter((x) => ALL_WIDGETS.includes(x as WidgetId)) as WidgetId[]) - : []; - for (const w of ALL_WIDGETS) if (!order.includes(w)) order.push(w); - return { - order, - collapsed: { ...(r.collapsed ?? {}) }, - hidden: { ...(r.hidden ?? {}) }, - }; -} - -export function loadLayout(): SidebarLayout { - return normalizeLayout(readAccount(LS.sidebarLayout, {})); -} - -export function saveLayoutLocal(l: SidebarLayout): void { - writeAccount(LS.sidebarLayout, l); -} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 4d4899d..335f77e 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -6,6 +6,9 @@ import { useCallback, useState } from "react"; export const LS = { theme: "siftlode.theme", sidebarLayout: "siftlode.sidebarLayout", + plexLayout: "siftlode.plexLayout", + playlistsLayout: "siftlode.playlistsLayout", + playlistsCollapsed: "siftlode.playlistsCollapsed", hints: "siftlode.hints", lang: "siftlode.lang", filters: "siftlode.filters", diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts new file mode 100644 index 0000000..9bfc5ec --- /dev/null +++ b/frontend/src/lib/useScrollFade.ts @@ -0,0 +1,39 @@ +import { useEffect, useState, type CSSProperties } from "react"; + +// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`): +// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge +// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref +// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback +// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that +// mounts collapsed and only renders its scroll body once expanded still wires up correctly. +export function useScrollFade(fadePx = 20): { + ref: (node: HTMLDivElement | null) => void; + style: CSSProperties; +} { + const [el, setEl] = useState(null); + const [fade, setFade] = useState({ up: false, down: false }); + + useEffect(() => { + if (!el) return; + const update = () => + setFade({ + up: el.scrollTop > 1, + down: el.scrollTop + el.clientHeight < el.scrollHeight - 1, + }); + update(); + el.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(el); + // Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates. + if (el.firstElementChild) ro.observe(el.firstElementChild); + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + }; + }, [el]); + + const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${ + fade.down ? fadePx : 0 + }px), transparent)`; + return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; +} From 7e40f4f77a6315dda644e99141862b3f4ae44492 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 13 Jul 2026 05:13:52 +0200 Subject: [PATCH 20/20] docs(release): expand v0.42.0 notes (header, side panels, i18n, notifications) --- frontend/src/lib/releaseNotes.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 9fb890e..821dca2 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -16,14 +16,22 @@ export interface ReleaseEntry { export const RELEASE_NOTES: ReleaseEntry[] = [ { version: "0.42.0", - date: "2026-07-12", - summary: "A refreshed “glass over image” look for dark theme, plus two new colour schemes.", + date: "2026-07-13", + summary: + "A big visual refresh: the “glass over image” dark theme, a redesigned top bar, and one unified floating panel system for filters and playlists.", features: [ "New background image: in dark theme a subtle backdrop, tuned to your colour scheme, now sits behind the app — so the frosted-glass surfaces have real depth to refract. Turn it on or off, and dial its strength, with the new controls in Settings → Appearance.", "Two new colour schemes: Starship (deep-space blue) and Matrix (terminal green).", + "Redesigned top bar: the module name sits in a floating pill with back/forward arrows to step through your modules, and each module's search moved into a matching floating search bar.", + "The Feed, Plex and Playlists side panels are now one consistent floating system — collapse any of them to a slim tab beside the navigation, reorder and hide their sections, and your arrangement is saved per panel.", + "The navigation rail is now a floating rounded panel too, matching the rest of the app; at high browser zoom its list scrolls without a scrollbar, with a soft edge fade hinting there's more.", + ], + fixes: [ + "Repeated identical notifications now fold into a single entry with a count, instead of stacking up.", ], chores: [ "Reworked the translucent “glass” surface system so the whole app shares one consistent, tunable look.", + "Retired the German interface language; Siftlode is now available in English and Hungarian.", ], }, {