The shared-account warning was a requiresInteraction toast that re-fired
on every reload. Replace it with a permanent, non-dismissible banner (like
the version banner) shown above the content while in the demo account.
enrich_pending only touches enriched_at IS NULL, so a video first seen
while live was stamped live + duration-null and never revisited — staying
'live' with no duration forever after the broadcast ended. Add refresh_live
(run after each enrich pass) that re-fetches anything still live/upcoming,
plus just-ended was_live videos that haven't got their duration yet, until
they settle. Cheap: videos.list is 1 unit per 50 ids.
Live streams and upcoming/premiere videos have no fixed duration, so the
playlist row and video card left a blank where the runtime usually sits.
Show a LIVE (red) or upcoming badge instead, so it's clear the missing
time is by design, not a sync gap.
The shared demo account no longer hits YouTube affordances: the onboarding
wizard never opens (or renders) for it, the empty-feed prompt nudges into
the shared library instead of the connect wizard, and the browser-facing
/auth/upgrade redirects the demo home instead of returning a raw 403 JSON.
Login page quietly probes /auth/demo (debounced) as a valid email is
typed/pasted and reloads into the app on a match — no visible button.
Demo sessions default to the whole library, get a one-time shared-account
warning, never see the YouTube-connect onboarding/access UI or sync
actions, and admins get a demo whitelist + reset panel in Settings.
Admin endpoints to manage the demo email whitelist (DB-backed, no env)
and a manual reset that wipes the demo account's per-user state and
re-seeds a few sample playlists from the shared catalog.
Whitelisted emails enter the shared demo user via /auth/demo (lazily
created, no OAuth token/scope), rate-limited per IP and answering
uniformly so it can't be hammered as an enumeration oracle. Add a
require_human dependency that blocks the demo account from quota-spending
sync endpoints and the OAuth upgrade flow, and surface is_demo on /api/me.
Add the shared demo-account plumbing: users.is_demo marks the single
shared demo user, demo_whitelist holds the admin-curated emails that may
enter it without Google sign-in, and a small in-process RateLimiter
(generic groundwork) for throttling the demo-login endpoint per IP.
Bump VERSION to 0.4.1 and add the 0.4.1 release-notes entry (feed view/content-type chips
above the videos, key+direction sort, exact view count + upload date on cards/player).
Mirror the card change in PlayerModal: append the precise locale-aware date after the
relative time, for both the active video and a linked (navigated) video's stats.
The abbreviated count (e.g. '9B' for ~9 billion) can read like '98' since the B glyph
resembles an 8. Add a title tooltip with the full localized count on the views label.
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
Move content type and ordering out of the filter sidebar into a feed toolbar (approved
proposal 2): a prominent content-type chip row (Normal/Shorts/Live toggle chips) with the
sort control + count + reshuffle in a quieter row beneath it. The toolbar renders even on
'no matches' so content-type can be turned back on after it filtered everything out. The
sidebar keeps Show / Upload date / Language / Topic; 'sort' and 'content' widgets removed
from the layout (normalizeLayout drops them from any saved layout). New feed.sortLabel
string (HU/EN/DE).
Bump VERSION to 0.4.0 and add the 0.4.0 release-notes entry (Epic N: left nav sidebar,
multi-account switch, Settings page, in-app back, frosted-glass refresh).
Sessions created before multi-session existed had no account_ids, so adding a second
account left the first one (e.g. the long-lived session) absent from the switcher.
current_user now ensures the active user_id is always present in account_ids, so the
switch list is never missing the account you're using.
Track every account that completes OAuth in a browser session (session 'account_ids',
~14-day cookie), so the user can switch between them without another Google round-trip.
New GET /api/me/accounts (switchable accounts, active flagged) and POST /api/me/switch
(guarded: target must be in the session list — proof it signed in here — and re-checked
against is_allowed in case the invite was revoked). Logout now signs out the active
account and falls back to the most recent remaining one, else clears the session. UI: the
account popover lists the other signed-in accounts (click to switch, reloads) plus an
'Add another account' action (-> /auth/login, which uses prompt=select_account).
Trilingual. Third step of Epic N.
Dark glass looked nearly flat because the blurred backdrop is itself dark. Add a
brightness lift to backdrop-filter in dark mode for .glass/.glass-menu so the frosted
sheen reads even over dark surfaces (over colourful content it just looks richer).
Perf-mode still disables the blur (later rule wins).
The full-screen transparent dismiss backdrop sat directly behind the popover and acted as
a compositing layer, so the popover's backdrop-filter sampled it (empty) instead of the
page — making the glass look fully solid. Drop the backdrop and dismiss via document
mousedown/Escape listeners (like the other menus). Now backdrop-filter samples real
content and the frost shows.
Now that the popover is portaled to body its backdrop-filter actually samples the content
behind it, so the near-opaque glass-menu (needed only as a workaround while blur was
trapped) made it look solid. Switch it back to the frosty .glass (78%); the working blur
softens the background so text stays readable without the bleed-through. Trapped header
menus (language/notifications) keep glass-menu.
The nav's .glass backdrop-filter makes it a containing block for fixed descendants and a
stacking context, so the account popover's fixed inset-0 backdrop only covered the nav (not
the viewport) and the popover sat below the main content — controls behind it stayed
clickable. Portal the popover + its dismiss backdrop to <body> with fixed coords computed
from the trigger rect, so it's truly top-most (z-50) and blocks clicks underneath.
Floating menus hover over undimmed content (no backdrop scrim like dialogs), so the
frosted .glass (78%) let the content bleed through and hurt readability. Add a near-opaque
.glass-menu (surface 92%, keeps blur) and use it for the account, language, notification
and add-to-playlist popovers. Dialogs/chrome keep the frostier .glass (they sit over a dark
scrim / the ambient bg). perf-mode disables its blur too.
The dark-mode experiment made .glass globally more translucent (surface 66%), which
also hit content-overlapping surfaces like the account popover — text became hard to
read over the feed behind it. Drop that override so .glass is back to the readable 78%
everywhere; keep the richer dark ambient (harmless to readability). Docked chrome loses
the marginal extra frost (which was barely noticeable anyway).
Push an in-app history entry on each page switch (page rides in history.state, URL stays
clean — filters still never go in the URL) and sync the page from history.state on
popstate. The initial entry is stamped after the strip-params effect (which nulls
history.state). Now the mouse/browser Back button steps through visited pages instead of
jumping straight to the Google consent/redirect. Second step of Epic N.
In dark mode the faint accent pools left the docked frosted chrome (nav/header) looking
flat — no content behind it to refract, unlike dialogs over the feed. Boost the dark-mode
ambient pools and make .glass a bit more translucent there so the chrome reads as glass
near the accent pools. Light mode unchanged. The full app-wide dark effect still needs
Phase 2 (ambient content/mosaic), deferred.
Apply the real frosted .glass surface (blur) to the few large chrome surfaces — the nav
sidebar, the header and all Modal-based dialogs (was glass-card / plain surfaces) — and
enrich the ambient backdrop a touch (three soft accent pools) so the glass has more to
refract app-wide. Popovers (notifications, language, account, add-to-playlist) were
already glass. Bulk feed cards stay glass-card (no per-card blur) for performance; the
existing perf-mode still disables blur. Phase 2 (ambient thumbnail mosaic / bg image +
toggle) deferred to end-of-project polish.
Move Settings out of the right-side overlay into a left-nav page (page='settings'),
so it opens where you're already looking — no cross-screen mouse travel. The Settings
rail item now sets the page (with active highlight) instead of opening a dialog; the
panel is refactored to an in-flow .glass card (keeps the frosted look over the ambient
backdrop), with the page title shown in the header. Removed the overlay + Esc/backdrop
close path.
Move the modules (Feed/Channels/Playlists/Stats) out of the avatar dropdown into a
persistent left sidebar with icon+label entries; collapses to a thin icon rail
(persisted in localStorage). Account actions (About, Sign out, admin badge) move to a
popover at the sidebar bottom; Settings is a rail entry. Header is now a contextual top
bar (sync status, feed scope + search or page title, language, notifications) — logo and
account menu removed from it. New nav.json strings (HU/EN/DE). First step of Epic N.
Bump VERSION to 0.3.0 and add the 0.3.0 release-notes entry (local playlists, watch
later as a playlist, YouTube mirror + two-way sync, sort + undo/redo, rail sorting).
The session has autoflush off, so recompute_dirty's query read the last-committed
state, not the current request's pending item/order edits — so the first reorder
after a sync compared against itself and missed dirty, and only the next edit showed
it. Flush pending changes before computing the current fingerprint. Verified: a swap
now flips dirty immediately, and reverting to the original clears it.
Store a SHA-256 of each playlist's name + ordered video ids as last synced from /
pushed to YouTube (migration 0013, backfilled for already-clean linked playlists).
'dirty' is now recomputed on every edit by comparing the current fingerprint to that
baseline instead of being stickily set true. So manually restoring the original order
(or undo back to it) clears dirty on its own — the Reset to YouTube button disappears
and no YouTube read is needed. Baseline is set on read-sync, repull (reset), and a
clean push; a missing baseline counts as dirty (unknown YT state). Verified the
migration's fingerprint matches the app's runtime fingerprint exactly.
A reorder only invalidated the sidebar query, so detail.dirty stayed stale and the
Reset to YouTube button + Unsynced indicator appeared only after F5. Also invalidate
the detail query on reorder; the membership guard keeps the refetch from wiping the
undo history on a pure reorder.
The detail header overflowed the items column. Make the secondary actions icon-only
with tooltips (Sync/Export, Reset to YouTube, Delete) keeping only Play all labeled;
replace the long 'Unsynced changes' / 'Synced from YouTube' chips with a compact
accent 'Unsynced' dot (tooltip explains). Add an open-on-YouTube external-link button
next to the name + edit pencil (tooltip), shown for any YouTube-linked playlist.
The in-session undo/redo is lost when switching playlists, leaving no way to undo
local edits to a YouTube-linked list afterwards. Add a per-playlist 'Reset to YouTube'
action (shown when a linked playlist is dirty) that force-repulls that single playlist
from YouTube, replacing its items/order/name and clearing dirty — even though the bulk
read-sync skips dirty playlists. Backend: repull_playlist() + POST /api/playlists/
{id}/revert-youtube (read-scope gated). Confirm dialog (destructive). Trilingual.
The auto-select effect ran while the playlist query was still loading (empty list),
nulling the localStorage-restored selection so F5 fell back to the first playlist.
Wait for the first load (listQuery.data) before adjusting the selection.