Commit Graph
448 Commits
Author SHA1 Message Date
peter 644d65a3fd feat(feed): removable active-filter chips above the feed
With the filter panel now collapsed to a tab, there was no way to see what was
narrowing the feed without opening it. Every applied filter is now a chip you
can drop on its own, with a Clear all when more than one is on.

The panel's count badge and the chip row read the SAME list (lib/useActiveFilters),
so they can't disagree about what's applied; that list also owns the reset both
"Clear all"s share. Replaces the hand-summed activeCount in Sidebar.
2026-07-15 22:11:44 +02:00
peter 837b48cbfa refactor(feed): lift the sort vocabulary out of Feed into lib/feedSort
The active-filter chips need to label a sort, and parseSort/SORT_MAP were
module-private in Feed. Pure move, no behaviour change.
2026-07-15 22:03:40 +02:00
peter 3596d83acd fix(panels): stop the tab bleeding through the card's rounded corner
The tab stays mounted under the open card on purpose, but the card's 17px corner
radius cuts away its own pixels and the tab's square docked corner sits right
under that arc — showing a hairline of tab glass. Fade the tab out while the
card is open; opacity-0 rather than invisible/hidden, so it stays hoverable and
focusable (that is the whole reason it stays mounted).
2026-07-15 21:44:22 +02:00
peter 532a1c2842 fix(nav): give the rail and the side panel a dead zone between them
The peek handlers sat on the rail's layout slot, which is 80px wide while the
card it holds is only 56px — so the card's own margin was a live hover zone with
nothing visible in it. Leaving the filter tab or panel to the left put the
cursor straight into it and the rail popped open unprompted; the gap between the
two hover zones was literally 0px. Moving the handlers onto the card leaves a
16px no man's land (x=69..85) where neither opens.
2026-07-15 21:38:22 +02:00
peter 79d9bca290 feat(panels): peek-open side panels with a docked tab
The shared SidePanel now follows the nav rail's model, so Filters, Plex and
Playlists all inherit it: unpinned it sits as a small tab docked flush against
the rail (replacing the full-height vertical strip) and expands into the card on
hover or keyboard focus without pushing content; pinned it stays expanded
in-flow. The collapse toggle became pin/unpin, and panels now default to
unpinned. The tab carries the active-filter count and an accent ring.

The tab stays mounted under the open card on purpose: swapping it out from under
the pointer drops the hover that opened the card, and unmounting it while
focused strands keyboard focus.

Measured: feed content starts at 123px instead of 336px when unpinned.
2026-07-15 20:57:21 +02:00
peter 11451c1af2 refactor(nav): extract the peek-open hook for reuse
The rail's hover/focus-to-peek logic — including the portal-proof focus tracking
(React-tree onFocus/onBlur plus a safety net for portals that unmount while
focused) — is about to be needed verbatim by the side panels. Lift it to
useHoverFocusWithin now that a second consumer exists. No behaviour change.
2026-07-15 20:57:21 +02:00
peter 9d9c6894f4 chore(release): 0.43.1 — sidebar no longer latches open after a portaled menu 2026-07-15 20:29:41 +02:00
peter bcec462ef6 fix(nav): stop the rail latching open after a portaled menu closes
Opening the language switcher (or the account popover) and then dismissing it
left the unpinned rail stuck expanded. Those menus are portaled and unmount
while focused, and browsers fire no blur when a focused node is removed — so the
wrapper's onBlur never arrived and `focused` stayed true forever.

Keep the React-tree onFocus/onBlur (portals bubble through it, which is what
correctly holds the rail open while its own menu has focus) and add a safety net
for the one case they miss: when focus has landed nowhere, clear it.
2026-07-15 20:24:40 +02:00
peter 44926f1c23 chore(release): 0.43.0 — nav overlay sidebar, account chrome, opt-in glass tuner 2026-07-14 21:41:44 +02:00
peter 6a9bf948f9 feat(appearance): opt-in glass tuner toggle in Settings
Graduate the floating glass tuner from a localhost-only dev tool to a
user-facing opt-in: it's now gated on a per-user `theme.showTuner` pref (off by
default) with a "Show the glass tuner" switch in Settings → Appearance, riding
the existing theme draft-save. Softened the dev-only framing. Behaviour and look
are unchanged; the full preset system is a separate epic.
2026-07-14 21:39:53 +02:00
peter ad1cc4586a feat(nav): slim hover-expand sidebar, role tooltip, logout button
The rail now sits slim by default and expands to a labelled overlay on hover
or keyboard focus without pushing content; the manual toggle became a pin
(pinned = the old in-flow expanded rail). The header no longer reserves a fixed
504px left offset — it's absolutely positioned inside the content column, so it
tracks the actual left zone and reclaims the dead gap. The role badge is gone;
role now shows via a title tooltip on the username, and a logout quick-button
joins the language/about cluster.
2026-07-14 21:39:30 +02:00
peter 27b9e83af6 docs(release): expand v0.42.0 notes (header, side panels, i18n, notifications) 2026-07-13 05:13:52 +02:00
peter facb3ac9cb feat(ui): unified floating side panels for filters and playlists
Replace the full-height filter rails with one shared floating glass SidePanel
used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav;
its sections are reorderable "islands" (drag / hide / per-group collapse) with a
per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge
fade. The nav rail becomes a matching floating rounded card.

- New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook.
- Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps
  its storage key; plex/playlists get their own; persisted per account).
- Lift the Playlists rail to App level (selected playlist is now App state) so it
  floats and collapses like the filter rails; remove the old CollapsedFilterRail.
- The floating top header's fixed offset accounts for the panels' margins.
2026-07-13 05:13:40 +02:00
peter c2d914bacb feat(notifications): coalesce identical notifications with a repeat count
Fold a burst of identical notifications (same level+title+message within a
short window) into one entry with a ×N count instead of stacking copies — tames
a crash-retry loop or a repeatedly-failing poll. Shown in the toast and centre.
2026-07-13 05:13:18 +02:00
peter 07024de6c2 feat(i18n): retire the German interface language
Only English and Hungarian remain. Drops the de locale files, the LANGUAGES
entry, the Google-locale mapping in auth, and the German welcome-message
template. A legacy "de" preference falls back to English gracefully. Content-
language detection and subtitle languages are untouched (media, not UI).
2026-07-13 05:13:08 +02:00
peter 076a2fbe6f fix(header): restore inner scroll broken by the floating-header wrapper
The wrapper that pads content below the fixed header lacked min-h-0, so as a
flex child with min-height:auto it grew to its content's height instead of
staying bounded to the viewport. That let the whole document scroll (the fixed
header/nav stayed put while content ran off-screen). Add min-h-0 so the wrapper
stays viewport-height and the inner <main> keeps its own overflow scroll.
2026-07-13 02:39:43 +02:00
peter d159f35f31 feat(header): floating module header with cyclic nav and shared search
Replace the full-width top bar with a fixed floating header of glass pills: a
fixed-position ModuleName (accent dot + label + prev/next cyclic module stepper)
and a shared SearchBar. The header's left edge is anchored as if the nav rail
and filter sidebar were both open, so it never shifts on collapse or module
change; the right edge leaves a 10% margin.

- ModuleName label width is measured live from the reachable modules' titles in
  the current language, recomputing on a language or account change.
- Module names come from a shared moduleLabelKey (the same short labels as the
  nav rail); NavSidebar and the header now read from one source (lib/modules.ts).
- The arrows step cyclically through moduleOrder(me) — dynamic, no hard-coded
  targets, wrapping at both ends.
- Search is hoisted into the SearchBar for feed (YouTube search), plex, channels
  (including the Discovery tab) and playlists (new); Go button, Enter triggers it.
- The Playlists left rail now reaches the top; header clearance via --hdr-h.
2026-07-13 02:28:06 +02:00
peter a7f9032e0e feat(glass): light-theme background images + enable the toggle in light
Add a light set of the 6 per-scheme backdrops (public/backdrops/light/*.svg) —
soft, pastel-mid, non-figurative washes tuned to each accent so they read through
the light translucent glass. Wire them up centrally:
- App: the backdrop is on whenever bgImage && !perf (was dark-only), so light
  gets it too.
- index.css: per-scheme rules are now [data-theme]-qualified — dark uses the dark
  set, light the /light/ set; shared cover/fixed size rule for both.
- Settings: the Background image toggle + Image-fade slider work in light now
  (removed the dark-only gates); hint no longer says 'dark only'. Trilingual.
The light glass (62% translucent tier) refracts the light backdrop just like dark
refracts its own; one fade slider controls both.
2026-07-13 00:14:20 +02:00
peter 8019b97ca8 feat(glass): make the dev tuner theme-aware (Sync to current theme)
The tuner writes inline vars (global), so its sliders always showed the dark/
backdrop-on baseline even when viewing light. Add a 'Sync to current theme'
button that clears the inline overrides, reads the live theme-driven values into
the sliders, and headers the Copy-CSS export for the active scope (:root for
dark, html[data-theme="light"] for light) — so light-theme glass can be dialled
in and exported straight into the right block.
2026-07-13 00:02:04 +02:00
peter 89b6d8a7bb fix(glass): give light theme its own translucent tier + unify the Plex filter rail
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.
2026-07-12 23:53:56 +02:00
peter ec23dafffe chore(glass): scrub dev-process/hostname refs from tracked comments
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.
2026-07-12 23:30:50 +02:00
peter 06b4411aa4 feat(glass): re-theme the public WatchPage onto the token system (Phase 3)
The standalone /watch share page was on a hard-coded slate/teal palette (it
renders outside <App> with no theme). Add a baseline theme (dark/midnight) in
main.tsx so the token CSS vars resolve on every route incl. the public pages,
then swap all of WatchPage's slate/teal/hex to theme tokens (bg-bg/text-fg/
text-muted/accent, surface/border), with the password card as glass. Now
consistent with the app's default look; the app still overrides with the user's
saved theme when it mounts.
2026-07-12 23:00:36 +02:00
peter cb2ad684bd feat(glass): glassify modal inner panels, dropdowns & list rows (Phase 2)
Medium container surfaces -> the glass system so they refract the backdrop
consistently: Playlists row (bg-card->glass-card), DownloadCenter user-picker +
ShareDialog share-picker dropdowns (bg-card->glass-menu), DownloadCenter stat
cards / download rows / usage block, ShareDialog share-link block, VideoEditor
selection bar, ProfileEditor profile rows (bg-card/40|bg-surface/60->glass-card).
Tight admin rows and tiny inputs left solid on purpose.
2026-07-12 22:54:13 +02:00
peter a625974471 feat(glass): glassify the side rails + back-to-top (Phase 1, feed chrome)
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.
2026-07-12 22:44:57 +02:00
peter 88e6b6a2a0 chore(release): 0.42.0 — glass-over-image look + Starship/Matrix schemes 2026-07-12 22:28:50 +02:00
peter 3f91453254 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.
2026-07-12 22:24:09 +02:00
peter a8b1b26901 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 <body> (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"].
2026-07-12 21:20:40 +02:00
peter e1b8916be4 feat(glass): dev-prototype feed backdrop (blurred rotating thumbnail)
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 <main> (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.
2026-07-12 20:40:20 +02:00
peter 96b7650d6a fix(glass): soften the Matrix scheme accent (neon phosphor → muted emerald)
UAT: #2fe36e read as too vivid/stabby. Drop to #46b87c (lower saturation) +
slightly less green-tinted fg; update the swatch to match.
2026-07-12 20:27:37 +02:00
peter cfd02ba471 feat(glass): bake two-tier defaults (Adaptive chrome + media scope), add Starship/Matrix schemes
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).
2026-07-12 20:12:25 +02:00
peter 2004ab004e feat(glass): variable-drive the glass system + dev GlassTuner (Phase 0/0b)
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.
2026-07-12 19:14:40 +02:00
peter 08919199c2 chore(release): 0.41.0 — admin audit log + channel About enrichment/fixes 2026-07-12 17:57:39 +02:00
peter 3c104458d1 fix(channel): no-banner fallback bar matches the banner height (avatar/Back overlap)
The no-banner placeholder was a short h-14 (56px) bar while a real banner renders
~150px, so the -mt-8 avatar rode up into the absolutely-positioned Back button on
banner-less channels (e.g. Billet Box). Give the fallback the same aspect-ratio +
maxHeight as the banner container so the overlapping avatar has the same room and
the header layout is consistent whether or not a channel has a banner.
2026-07-12 17:01:29 +02:00
peter 26230b52e8 fix(channel): recover a throttled channel banner instead of leaving it broken
The banner URL is valid, but googleusercontent intermittently drops the banner
request when the channel page fires the banner + avatar + ~16 thumbnails at once;
the browser then negative-caches the miss, so the banner stays broken with no
auto-retry (even though the same URL loads fine on a fresh request). On error we
now retry a few times with a ?r= cache-buster (a fresh URL that bypasses the
negative cache), and only fall back to the blank bar if it keeps failing (a
genuinely dead URL). Fixes the broken-banner on channel pages like BASSHUNTER.
2026-07-12 16:54:55 +02:00
peter 763d8533f2 fix(channel): hide the Source (search-results) filter on the channel-scoped feed
The Source selector (organic / include-search / search-only) is a global-catalog
concept — it picks how videos ENTERED the library. On a single channel's page the
view is pinned to librarySource=all and would only offer a near-empty, confusing
slice, so hide the selector there (the main feed keeps it).
2026-07-12 16:36:32 +02:00
peter 05aa70ed33 fix(channel): harden About-enrich edge cases from review
- apply_channel_details: (branding.get("channel") or {}) so a "channel": null
  in brandingSettings can't AttributeError-abort the enrich batch.
- topicLabels: drop empty labels so a malformed topic URL can't render a blank chip.
2026-07-12 16:17:17 +02:00
peter 0c26464c90 feat(channel): enrich the About tab + fix the tab-switch header shift
- fix: reserve the scrollbar gutter (scrollbar-gutter:stable) on the channel
  page's scroll container, so switching between the tall Videos tab and the
  short About tab no longer shifts the banner/avatar/buttons a few px sideways
  (the vertical scrollbar was appearing/disappearing between the two tabs).
- About tab now shows Country (flag + localized name), Language, Topics
  (topicCategories → readable chips), and Keywords (brandingSettings keywords
  parsed into chips, quoted multi-word tags kept whole). country/language/topics
  were already stored; keywords is new (migration 0055_channel_keywords, mapped
  in apply_channel_details, returned by channel_detail).
- Discovery → channel page: the Channel-manager "Discover from playlists" tab now
  links each channel name to its in-app channel page (ChannelLink onView), so a
  discovered channel's About/videos can be inspected BEFORE subscribing (was
  subscribe-only, plain text before).

Note: the channel info-card epic itself was already delivered in v0.19.0; this is
the About-tab enrichment follow-up + the header-shift fix. external_links stays
empty by design (YouTube removed the field from the Data API ~2023).
2026-07-12 16:12:39 +02:00
peter 201b8b00b3 fix(audit): address /code-review findings
- scheduler _run_changed: count a run as "changed" only on a truthy NUMERIC
  value — status-string no-op dicts like {"skipped":"disabled"} (Plex off, the
  default) or {"skipped":"no demo account"} were flooding the trail every interval.
- audit.record: truncate target_id to the column width (128) like summary[:255],
  so an over-long target (e.g. a 128+ char demo email) can't abort the mutation
  the audit row is committed alongside.
- config set/reset: redact URL userinfo (user:pass@) from logged non-secret
  values — a proxy setting can embed credentials that shouldn't land in the trail.
- config set: skip the audit row when a non-secret value didn't actually change
  (no-op re-save shouldn't add noise); secrets are write-only so always logged.
- audit page title (pageMeta): add the missing "audit" case so the browser tab
  reads "Audit log · Siftlode" instead of falling back to "Feed".
2026-07-12 07:47:07 +02:00
peter 911f5100d8 feat(audit): admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries,
generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never
per-row (a scheduler run touching thousands of videos is ONE row).

Backend:
- migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action,
  target_type/id, summary, before/after JSON, created_at).
- app/audit.py: AuditAction constants (single source of truth, i18n'd on the
  client) + record() (adds to the caller's txn) + gc(retention_days).
- producers wired: role change / suspend / delete / invite approve-deny-add /
  demo add-remove-reset / discovery purge (admin.py); config set/reset — secret
  VALUES never logged, key only (config.py); scheduler interval + maintenance
  tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job()
  choke point writes ONE run-summary row per run — manual runs + errors always,
  automatic runs only when they changed something (no no-op-poll spam).
- retention: audit_gc scheduler job prunes rows older than audit_retention_days
  (sysconfig, default 180; 0 = keep forever).
- admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails
  resolved, System for background) + clear (leaves one tamper-evidence row).

Frontend:
- new admin-only "Audit log" nav page (ScrollText) using the shared DataTable
  (first admin page to reuse it) — sort/filter/paginate/persist, action select
  filter, actor text filter, before→after detail, Clear-all with confirm.
- AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type.
- trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group +
  scheduler job labels; Audit config group (retention days).
2026-07-12 07:32:41 +02:00
peter 2c4d4fa193 chore(release): 0.40.0 — deferred-bugs closeout 2026-07-12 06:54:49 +02:00
peter 64a97ede3d fix(deferred): address /code-review findings on the closeout diff
- channels CB3: sort discovery rows NULLs-last (title is None) to match the DB
  ORDER BY exactly — coercing NULL title to "" floated untitled channels to the top.
- AdminUsers SC1: track in-flight rows in a Set, not a single id — per-row disabling
  now lets the admin start concurrent row actions, and a shared id was cleared by
  whichever settled first (re-enabling a still-pending row → possible double-submit).
- ChatThread CT4: reset the incoming-count ref when partnerId changes — the Messages
  page reuses one ChatThread across conversation switches, so a same-count switch
  could skip the open-marks-read badge refresh.
- messages MB3: push a bare {type:"unread"} (drop the now-dead count query — the
  client re-fetches on the signal and ignored the number anyway).
- api.ts: extract the shared keepalive `beacon()` helper (saveProgressBeacon +
  plexProgressBeacon were near-verbatim copies).
2026-07-12 06:21:29 +02:00
peter 5ac30abe1f fix(deferred): close frontend UI/UX tails from the hygiene sweep
- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
  position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
  auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
  api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
  + 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
  the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
  post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
  (+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
  SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
  count actually changed, not on every 20s poll; MB3 (client): react to the live
  unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
  CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
2026-07-12 05:59:25 +02:00
peter 216200eebf release: v0.39.1
OAuth scope-clobber fix (sign-out/in no longer drops YouTube access) + admin
access-request email accuracy/multi-admin + auth loose-ends (register/reset timing,
messages_ws dedup, revoke decrypt).
2026-07-12 04:57:06 +02:00
peter ca10b88bf5 fix(auth): preserve OAuth scopes across re-login + accurate multi-admin request emails
Two user-reported signin bugs:

1) A plain re-login (or logout→login) wiped the user's YouTube grant: login requests
   only BASE_SCOPES and Google's returned `scope` can list just those, but _store_token
   wrote it verbatim — dropping a previously-granted youtube.readonly/youtube scope, so
   can_read flipped to false and the feed demanded a reconnect. The underlying grant
   (refresh token) survives such a login, so UNION the scopes instead of narrowing; they
   only shrink on full disconnect (purge deletes the token row).

2) Admin 'new access request' email: (a) it named the wrong menu ('Settings → Account'
   instead of Users → Access requests); (b) the requester address was invisible so the
   'reply reaches them' note looked wrong (Reply-To is in fact set to the requester) —
   now spelled out + a deep-link straight to the approve view (?admin=access-requests,
   handled in App); (c) it emailed only the static env ADMIN_EMAILS — now notifies the
   ACTUAL admins (active role=admin users) unioned with env, so a UI-promoted admin (who
   CAN approve — the approve UI is role-gated) is notified too.
2026-07-12 04:34:07 +02:00
peter 9d3624d51b release: v0.39.0
Auth security round: SB3 (email tokens out of URL query → fragment), SA4 (server-side
session revocation + 'Log out other sessions'), SA3 (trusted-proxy X-Forwarded-For so
rate limits can't be bypassed via a forged header).
2026-07-12 03:49:55 +02:00
peter bfcee41f2a fix(auth): address SA4 review findings (WS epoch check, commit ordering, verify guard)
Adversarial re-review of the session-epoch work surfaced:
- WS auth (messages_ws) skipped the epoch check, so a revoked-but-unexpired cookie
  could still open the live push channel after a reset/logout-others. Now mirrors
  current_user: loads the user once, rejects a stale-epoch cookie before connecting.
- set_password + logout_others re-stamped the cookie BEFORE db.commit(); a failed
  commit would strand the current session at a newer epoch than the DB and wrongly
  401 it. Commit first, then re-stamp.
- Welcome verify effect could double-POST the single-use token (StrictMode/remount)
  and flip the banner to a false 'invalid'. Fire-once useRef guard.

Left as-is (low value, documented): the Plex image proxy authenticates without a DB
load / epoch check (poster/art fetches only); adding one would cost a DB hit per image.
2026-07-12 03:07:13 +02:00
peter bfcacb518f feat(auth): SA4 — server-side session revocation via per-user session epoch
Signed client-side session cookies had no server-side kill switch: logout + password
reset couldn't evict a stolen/copied cookie (valid until expiry). Add User.session_epoch
(migration 0053), record it in the cookie at every login, and reject in current_user any
cookie whose recorded epoch is behind the account's current one.
- Bump the epoch on: password reset (kills ALL sessions — a reset is a compromise response),
  password change + a new 'Log out other sessions' action (both re-stamp the CURRENT cookie
  so the caller stays signed in, evicting only the others).
- Per-account epoch map in the session so one account's revocation doesn't evict the other
  signed-in accounts in the same browser wallet.
- Missing epoch (pre-SA4 cookie) is treated as 0, so the first bump revokes grandfathered
  sessions too.
- New POST /auth/logout-others + a Settings → Account 'Active sessions' button (trilingual).
2026-07-12 03:00:16 +02:00
peter e99ec65721 fix(auth): SB3 — keep reset/verify tokens out of URL query strings
Secret email tokens now ride the URL fragment (#reset=/#verify=), never the query
(?reset=/?token=): a fragment isn't sent to the server, so the token can't leak into
proxy/access logs or a Referer header.
- Reset: link → /#reset=; the SPA reads the token from location.hash and POSTs it
  (unchanged /password-reset/confirm).
- Verify: link → /#verify=; new POST /auth/verify (token in body). The legacy GET
  /auth/verify?token= is kept so pre-deploy emails in flight still work until they
  expire. The SPA reads the fragment token, POSTs it, shows ok/invalid.
- Welcome: read secret tokens from the fragment, status flags from the query; strip
  both after capture so nothing lingers in history.
2026-07-12 02:48:40 +02:00
peter c7ef5a87b2 release: v0.38.0
Plex filter-sidebar collapse + infinite-scroll fix; the whole code-hygiene sweep
(Phases 1-4: dead-code/duplication cleanup, watch_sync/player fixes, guardrails).
2026-07-12 02:26:42 +02:00
peter 5c29a383cf chore(hygiene): Phase 4 guardrails — noUnusedLocals/Parameters + dead-code sweep
Enable tsconfig noUnusedLocals + noUnusedParameters (permanent guardrail: every tsc/
build now flags re-accumulated dead code). Fixed the 8 violations that surfaced, all
genuine dead code:
- Sidebar: deleted unused SORT_IDS/SHOW_IDS/rollSeed consts + the dead Toggle component
  (+ its now-unused Switch import).
- PlexBrowse: dropped the unused onClearSearch prop (Props + destructure + App call site).
- VideoEditor: dropped the unused map index; notifications: deleted the dead back-compat
  toast(); storage: deleted the unused non-account usePersistedState (+ readJSON import).
- SavedViewsWidget: deleted the dead loadDefaultViewFilters export (App loads the default
  view inline; resolves the last knip unused-export → knip now clean).

Backend: added backend/ruff.toml (ignore E402 — intentional pre-import setup in main.py)
and fixed the 6 E702/E741 style items (_vtt_s_to_ts semicolons, ambiguous `l` loop vars)
so ruff is green. localdev boots; Feed/Plex/sidebar render; 0 console errors.
2026-07-12 02:15:52 +02:00