Commit Graph
100 Commits
Author SHA1 Message Date
peter e2ad2ab798 Merge: promote dev to prod 2026-07-01 14:32:25 +02:00
peter 9151e0432f chore(release): 0.20.2 2026-07-01 14:30:50 +02:00
peter 6884f9ef8f Merge: Release Notes modal flash fix + public source-code link in footer 2026-07-01 14:27:52 +02:00
peter 53bc7c62fa feat(welcome): link to the public source repository from the footer
Siftlode is open source; add a discreet 'Source code' link (trilingual) in the
landing-page footer next to Privacy/Terms, pointing at the public repo.
2026-07-01 14:27:42 +02:00
peter d4d069c321 fix(modal): stop Release Notes modal flashing shut on modal-to-modal handoff
useBackToClose eagerly pushed a history entry on mount and called history.back()
on unmount. During a modal->modal handoff (e.g. About -> Release Notes) the two ran
interleaved in one React commit, so the entering modal's popstate listener mistook the
leaving modal's back() for a genuine user Back and closed itself instantly. It also left
the history pointer behind the surviving entry, so a later browser Back walked off the app.

Replace the eager per-mount push/pop with a single shared popstate handler plus a
coalesced microtask that reconciles history depth to the live overlay count once per tick.
A handoff's -1/+1 nets to zero, so the new modal simply reuses the old entry -- no churn,
no flash, and Back closes the modal in-app.
2026-07-01 14:27:41 +02:00
peter 75ebb6d602 Merge: prepare repo for public release (scrub + README) 2026-07-01 12:47:13 +02:00
peter 71aa51a1b3 chore: prepare repo for public release — scrub internal infra, rewrite README
- Remove owner-specific / legacy deploy files (home/prod/server compose, deploy/).
  The home compose stays as a local untracked file for the maintainer's own deploy.
- Genericise infra-specific code comments (egress-proxy examples) to neutral wording.
- Replace the hardcoded contact email on the legal pages with the instance operator's
  configured admin email, served via the public /auth/config and shown with a neutral
  fallback — so each self-hosted instance shows its own contact.
- Rewrite README for the current app + a copy-paste self-hosting quick start (prebuilt
  image + first-run wizard) with a build-from-source alternative; tidy .env.example.
2026-07-01 12:46:50 +02:00
peter 870fff2196 Merge: promote dev to prod 2026-07-01 12:08:48 +02:00
peter 006d56eaa8 chore(release): 0.20.1 2026-07-01 12:07:58 +02:00
peter 56fb5767a6 Merge: refresh Welcome page copy + screenshots 2026-07-01 12:01:42 +02:00
peter 1ba344445b improvement(welcome): refresh landing copy + real npeter83 sample screenshots
Tighten the hero + feature cards to punchy one-liners and surface current
capabilities: swap the standalone channel-manager card for live YouTube
search (folding tagging into the readable card). Regenerate the three
preview images (feed, channels, playlists) from a rich real account instead
of the sparse demo, so the landing shows what a full instance looks like.
EN/HU/DE.
2026-07-01 12:01:42 +02:00
peter a13d24d249 Merge: promote dev to prod 2026-07-01 11:29:48 +02:00
peter 1139bb3ac2 chore(release): 0.20.0 2026-07-01 11:28:56 +02:00
peter 7702b26e87 Merge: new default sidebar widget order 2026-07-01 04:29:38 +02:00
peter 23a8c78413 polish(sidebar): default widget order — Saved views, Upload date, Your tags, Language, Topic
Moves the Tags widget up (after Upload date) in the default sidebar layout.
Only affects fresh layouts; existing users keep their persisted arrangement.
2026-07-01 04:29:38 +02:00
peter e2c9adb1e1 Merge: unify filter reset — Source/scope count + clear, search ✕ 2026-07-01 04:22:39 +02:00
peter d0051b0fde improvement(filters): count Source + Library scope as active; unify Clear all; add search clear (X)
The "Filters N active" badge and Clear all only tracked the sidebar facets
(tags/show/sort/content/date/channel), so the toolbar-level Source and the
Mine/Library scope neither bumped the count nor reset — inconsistent with
filtersToParams (which treats them as filters, so Save view/Share already
captured them).

- activeCount now includes Library scope and a non-default Source; "relevance"
  (auto-applied while searching, no manual option) is excluded from the sort
  count so typing a query no longer inflates the badge.
- Clear all resets scope back to "my" (Mine) and drops Source to organic,
  alongside the facets. The search q is intentionally preserved.
- The header search box gets a clear (X) button when it holds a value, so the
  search has its own reset (clearing it reverts the relevance sort to newest).
2026-07-01 04:22:39 +02:00
peter dce88533ad Merge: saved smart views (named FeedFilters snapshots) 2026-07-01 03:17:47 +02:00
peter 77faf98d5f feat(views): saved-views UI — sidebar widget, apply/rename/default/reorder/share
A new "Saved views" filter-sidebar widget (SavedViewsWidget): save the
current filters under a name, apply one with a click (active-highlighted
when it matches), rename, delete (confirm), drag-reorder, share by link
(reuses shareUrl), and star one as the default. The default view's filters
are mirrored to localStorage so loadInitialFilters applies it synchronously
on load/F5 (an api/url share link still wins; no default → last-session
filters as before). Hidden for the demo account. EN/HU/DE.
2026-07-01 03:17:36 +02:00
peter fb719edc7b feat(views): saved-views backend — table, model, CRUD + reorder API
Per-user named snapshots of the feed's FeedFilters. New saved_views table
(migration 0035, unique per user+name, position, is_default) + SavedView
model, and a /api/saved-views router (list/create/patch/delete/reorder)
gated behind require_human so the shared demo account can't pollute it.
Setting is_default clears the flag on the user's other views (one default).
2026-07-01 03:17:36 +02:00
peter 7b18fdc47a Merge: promote dev to prod 2026-07-01 02:38:15 +02:00
peter 124260f3d2 chore(release): 0.19.0 2026-07-01 02:37:07 +02:00
peter c9398ace5a Merge: keep scrape-sourced YouTube search results across a reload 2026-07-01 01:55:05 +02:00
peter 036491b88b feat(search): keep scrape-sourced YouTube results across a reload
A live YouTube search was always dropped on reload so it could never
re-spend quota. But the default scrape source costs no quota, so there's
no reason to bounce the user back to the feed — they can stay on their
results.

Feed now stamps history.state._ytScrape=true once a search resolves via
the scrape source; App restores _yt on reload only when that flag is set
(and re-runs the search, which is free). An api-source search is left
unmarked and still drops to the feed on reload, since re-fetching it would
cost ~100 units. The flag is cleared when a new search starts (Feed
re-stamps it once the new results' source is known).
2026-07-01 01:55:05 +02:00
peter c7c3ebe2d2 Merge: fix back button swallowed after a button/ESC overlay close 2026-07-01 01:42:00 +02:00
peter f6f1018234 fix(history): stop the next overlay's first Back being swallowed after a button/ESC close
useBackToClose closes an overlay programmatically (X button or ESC) by
setting the module-global suppressPop=true and calling history.back(), so
the remaining overlays' popstate handlers ignore that synthetic pop. But
the flag was only ever reset by one of those handlers — when the closed
overlay had no overlay underneath it (the common single-player case),
nothing consumed it and suppressPop leaked true.

The next overlay's first real Back then hit the stale true, reset it and
returned early without closing — the Back was silently swallowed. Symptom
in the YouTube-search flow: close the player with the X button, open
another, press Back once -> nothing happens; a second Back closes it AND
discards the search results, dumping you on the normal feed.

Fix: register a one-shot popstate listener alongside suppressPop=true so
the flag is cleared on that very pop even when no overlay remains to
consume it. Nesting is unaffected (an underlying overlay still short-
circuits first and the one-shot is a redundant no-op).
2026-07-01 01:42:00 +02:00
peter 58d39ee47e Merge: ephemeral live-search — auto-purge, count selector, channel blocklist, new-first
Live YouTube search results are now ephemeral previews: the discovery-cleanup job auto-removes
un-kept ones (no watch state / not playlisted / channel not subscribed) after a grace period, plus a
'Clear now' button + admin 'Purge discovery' for on-demand cleanup. A results-count selector sets the
batch size (Load more pulls another batch). Per-user channel blocklist (migration 0034) excludes
blocked channels from search ingest + feed/Library/explore/channel page, with Block/Unblock on the
channel page and a manager section. Results the user already has sink below genuinely-new discoveries.
2026-07-01 01:18:33 +02:00
peter 466bb202e1 Merge: Channels search + clickable tag chips, contextual Your-tags, card polish
Channel manager gets a prominent channel-name search box and clickable 'Your tags' chips that
filter the table (replacing the hidden per-column popovers); the per-row tag picker flips up when
it would clip below. Feed 'Your tags' sidebar is now contextual (counts the 'other' tag category in
the facet endpoint, hides zero-count chips). Video card titles show a full-title tooltip only when
clamped.
2026-07-01 01:18:33 +02:00
peter ebfe462967 fix(search): keep Load more; the count selector sets the batch size
Clarified per intent: the results-count selector controls how many results each fetch gathers,
and Load more pulls another batch of that size (restored). The infinite query already threads the
count into every page, so Load more honours the selected size. Added a tooltip on the selector.
2026-07-01 01:17:04 +02:00
peter 18a6cdff6c feat(search): ephemeral results UX, count selector, channel blocklist (frontend)
- Live-search view: a results-count selector (20/40/60/100) replaces manual load-more (the free
  scrape source pages until that many are gathered); an 'these results are temporary' banner with
  a 'Clear now' button that discards them 'as if never added' (api.clearSearch) and returns to
  the feed.
- Channel blocklist: a Block/Unblock toggle + 'Blocked' badge on the channel page (blocked
  channels don't auto-explore and their videos are hidden), and a 'Blocked channels' section in
  the Channel manager with one-click unblock. ChannelDetail.blocked from the backend.
- Admin: a 'Purge discovery' button on the Scheduler page (immediate un-kept search/explore
  cleanup). EN/HU/DE throughout.
2026-07-01 01:00:32 +02:00
peter 37bb773a12 feat(search): ephemeral results, count selector, blocklist, new-first ordering (backend)
- Live-search results stay ephemeral: the discovery-cleanup job now also reclaims un-kept
  via_search videos (no watch state / not playlisted / channel not subscribed) after a grace
  period (search_grace_days), and POST /api/search/clear discards a given result set 'as if
  never added' (drops the user's search-finds + deletes the now-orphaned, un-kept videos).
  Admin POST /api/admin/purge-discovery runs it on demand (grace 0).
- Count selector: GET /api/search/youtube gains a 'limit' — the free scrape source pages
  through continuations until that many results are gathered (no manual load-more); the API
  source stays one ≤50 page (cost).
- Per-user channel blocklist (migration 0034 blocked_channels): block/unblock/list endpoints;
  blocked channels' videos are dropped from live search before ingest and hidden from the feed
  / Library / explore / channel page; explore refuses a blocked channel.
- New-first ordering: results you already have (subscribed channel, watched/in-progress/saved/
  playlisted) sink below genuinely-new discoveries, preserving relevance within each group.
2026-07-01 00:42:32 +02:00
peter a870bb5610 fix(channels): flip the row tag picker upward when it would clip below
The per-row tag '+' menu always opened downward (top-full) and clipped against the scroll
container for rows near the viewport bottom. Measure the button on open and flip it upward
(bottom-full) when there isn't room below but there is above; cap its height with an internal
scroll so a long tag list can't overflow either way.
2026-07-01 00:01:26 +02:00
peter 03e0c68c7c feat(card): tooltip the full video title only when it's clamped
The card title is line-clamped to 2 lines; measure overflow (ResizeObserver, re-checks on
responsive resize) and set the native title attribute with the full text only when it's actually
truncated, so hovering reveals the rest without a redundant tooltip on short titles.
2026-06-30 23:53:44 +02:00
peter 4cd7d4403e feat(channels): search box + clickable tag chips; fix(feed): contextual Your-tags
- Channels manager: prominent channel-name search box and the 'Your tags' chips are now
  clickable to filter the table by tag (replacing the hidden per-column DataTable popovers).
  Both filter client-side over the status-filtered list; a focus-channel intent seeds the
  search, the reset intent clears both.
- Feed 'Your tags' sidebar: count user tags in the facet endpoint (the 'other' category) and
  make the widget contextual like language/topic — counts reflect the current filter and
  zero-count chips hide (e.g. a source=search view with no tagged channels shows 'no matching
  tags' instead of the full static list). EN/HU/DE searchPlaceholder.
2026-06-30 22:30:35 +02:00
peter 22ee9becac Merge: channel page + ephemeral exploration of un-subscribed channels
Dedicated channel page (inset banner, inline-stats header, Videos/About tabs) reusing the feed
scoped to one channel; ephemeral per-user browsing of un-subscribed channels (auto-ingest recent
uploads flagged via_explore, kept private to the explorer until they subscribe; grace-period
cleanup job). Channel name opens the in-app page everywhere (cards + player), Back-aware and
F5-persistent. Backend: about-metadata enrich, explore provenance + per-user feed gate,
subscribe=keep, explore_cleanup scheduler job (migration 0033).

General feed/UI polish (app-wide): one-row toolbar (count after Source, sort right-aligned),
channel-scoped sort options, nav-rail closes the channel page, and a filter-resistant floating
back-to-top button.
2026-06-30 05:20:06 +02:00
peter 56dc1d8ed8 fix(ui): make the back-to-top button resist ad-block cosmetic filters
Ad-block annoyance lists (e.g. Brave Shields) hide floating corner buttons via attribute
selectors like [aria-label="Back to top"] / [title=...]. Drop both attributes and provide the
accessible name with a visually-hidden child span instead, which those selectors can't match.
2026-06-30 05:18:07 +02:00
peter 06dda5338f feat(ui): floating back-to-top button on long scrollable pages
A reusable BackToTop fades in once the active page's <main> is scrolled past ~600px and
smooth-scrolls it back to the top. Rendered once at the App root, portaled to <body> for
viewport-fixed placement (bottom-right, below the chat dock), and re-binds to the live <main> on
navigation (page or channel change) so it works on every scrollable page incl. the channel page.
EN/HU/DE.
2026-06-30 05:02:42 +02:00
peter 607bef1400 polish(feed): one-row toolbar — count after Source, sort right-aligned
Collapse the feed toolbar's two rows into one: the video count sits right after the Source
dropdown, and the sort controls move onto the same row, pushed right with ml-auto (wraps
gracefully on narrow widths). Applies everywhere the Feed renders (main feed + channel page).
2026-06-30 04:56:08 +02:00
peter 421ba09a19 polish(channel): inset, rounded banner card (option C)
Instead of a full-bleed banner, inset it with a margin and rounded corners so it reads as a
contained card; the avatar overlaps its bottom-left. Airier and more designed than edge-to-edge.
2026-06-30 04:48:46 +02:00
peter a1aaa8bff3 fix(channel): let the nav rail navigate away from a channel page
The channel page overlays the content column via channelView, which setPage didn't clear — so
clicking a rail item did nothing (and the next===page early-return blocked 'Feed' when a channel
was opened from the feed). setPage now clears channelView and proceeds even when the underlying
page is unchanged.
2026-06-30 04:47:54 +02:00
peter 859c354150 polish(channel): compact header — stats inline, slimmer banner
- Move the channel stats (subscribers / videos / views / joined) onto one meta line beside the
  handle under the name, dropping the separate stats row.
- Cap the banner at 150px tall so it no longer dominates the page; the wide YouTube-style crop
  is kept. Net: the video grid starts much higher.
2026-06-30 04:43:37 +02:00
peter fb74a31dd3 fix(channel): drop channel-constant sort options on the channel page
Subscribers and Channel-priority sorts are meaningless when the feed is scoped to one channel
(both are constant across its videos), so hide them there via a channelScoped flag on Feed.
The main feed keeps all sorts.
2026-06-30 04:37:00 +02:00
peter 7608e897d8 fix(channel): render the banner wide like YouTube
The stored bannerExternalUrl is the full 16:9 banner template at a low default size (512x288),
so object-contain showed the whole padded image tiny and centered. Now request a crisp wide
version (=w1707) and object-cover the desktop safe-area band (centre 2560x423, ~6:1) at full
width — matching YouTube's banner crop.
2026-06-30 04:32:43 +02:00
peter af2d80ed1f fix(channel): persist across reload, keep banner aspect, fix avatar z-order
- F5 on a channel page kept bouncing to the feed — the App init dropped history.state._chan
  like the (intentionally non-replayed) YouTube search. Now channelView restores from _chan on
  load and the stamp preserves it, so a reload stays on the channel page.
- The banner was stretched/cropped (bg-cover). Render it as an <img object-contain> with a
  capped height, letterboxed on the surface colour, so its real aspect ratio is kept.
- The round avatar's top was clipped by the banner: the banner's position:relative container
  painted above the static identity row. Give that row relative z-10 so the overlapping avatar
  sits on top.
2026-06-30 04:24:52 +02:00
peter 6209016227 fix(channel): show the Exploring badge on first visit
Refetch the channel detail after the auto-explore ingest so `explored` flips true and the
"Exploring" badge appears immediately — the GET that drove the first render predated the
explore that just created the ExploredChannel row.
2026-06-30 04:17:49 +02:00
peter 9810e43cf7 fix(player): open the channel page from the player without it bouncing back
Two bugs made clicking the channel name in the video modal just close the player:
- the second PlayerModal mount (main feed path) was missing onOpenChannel, so the handler
  hit its no-op early return;
- opening the channel synchronously pushed the _chan history entry, which the player's own
  useBackToClose teardown (history.back on unmount) then immediately popped. Now the open is
  deferred to a one-shot popstate listener that fires AFTER that teardown, so the channel
  entry lands at the feed level. Verified: player → channel name → channel page; Back → feed
  (player does not reappear).
2026-06-30 04:14:14 +02:00
peter 3db4aef653 feat(player): channel name opens the in-app channel page
The PlayerModal channel name now opens our channel page (closing the player first); a
small external-link icon beside it keeps the open-on-YouTube behaviour. Threaded
onOpenChannel from Feed into both PlayerModal mounts.
2026-06-30 03:12:44 +02:00
peter c8123dd9ae feat(channels): dedicated channel page + ephemeral explore UI
Frontend for the channel-explore feature:
- ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge
  while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the
  channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests
  recent uploads on first visit (background, with a loading note) + "Load more from
  YouTube" to page deeper; skipped for demo / already-subscribed channels.
- App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the
  YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays.
- ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter →
  onOpenChannel); the in-card "only this channel" filter button is dropped (the page
  subsumes it). PlayerModal channel-name wiring follows in the next commit.
- api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types.
- i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group,
  channels_explore quota label.
2026-06-30 03:08:52 +02:00
peter cd6d17c58c feat(channels): channel-explore backend — about metadata, ephemeral provenance, cleanup
Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed
channels:
- migration 0033: channels.{total_view_count,published_at,banner_url,external_links,
  from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked).
- enrich channels with part=brandingSettings (banner/links) + statistics.viewCount +
  snippet.publishedAt — no extra quota.
- GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new
  About fields (published_at sentinel).
- POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the
  exploration, flags the channel ephemeral unless followed, ingests one page of uploads
  (via_explore) + enriches; returns next_page_token for load-more.
- feed: per-explorer gate — a via_explore video is visible only to users who explored or
  subscribe to its channel, so exploration never leaks into everyone's catalog.
- subscribe = keep: clears from_explore + via_explore on the channel's videos.
- scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but-
  unkept channels and their untouched ephemeral videos after a grace period.
2026-06-30 02:52:44 +02:00
peter 7e9f70a81a Merge: Mine search finds, relevance search, and a weighted search document
feature/mine-search-finds-and-fts:
- per-user search finds surface in the Mine feed + Source filter there
- full-text relevance ranking (new Relevance sort, auto-selected when searching)
- search broadened to a weighted document (title + creator keywords + the
  queries that surfaced a video + description), DB-generated search_vector.
2026-06-30 02:16:32 +02:00
peter 4abeb651e1 feat(feed): broaden search to a weighted document (title + keywords + queries + description)
Makes the local search behave more like YouTube's — finding videos by the
uploader's own keywords or the query that surfaced them, not only words in the
title. A DB-generated, weighted search_vector (migration 0032) replaces the
title-only FTS index:
- keywords: the creator's snippet.tags (free — already in the snippet we fetch),
  stored on enrich.
- search_terms: distinct live-search queries that surfaced the video (across all
  users), appended by the search route — folds YouTube's relevance into local
  search (a video YT returned for a query becomes findable by it even without a
  title match), the user's own idea.
- description (truncated) for broad recall on the existing catalog.
Weighted title(A) > keywords+queries(B) > description(C) so ts_rank keeps title
hits on top. A plain GIN index on the generated column guarantees index use (no
expression/param matching). Verified on localdev: recall 146->213 for one query;
7 'eurovision' hits via the document but not the title; index scan confirmed.
2026-06-30 02:00:38 +02:00
peter 183e23055e fix(feed): reliably switch to relevance sort when a search starts
The relevance auto-select ran in a Feed effect that raced per-keystroke query
updates, so fast typing left the sort on the default. Move the switch into the
header input's onChange so it's set atomically with the query (only overriding
the default newest sort); the Feed effect now only reverts to newest when the
term is cleared. 'Back to feed' also sets relevance explicitly.
2026-06-30 01:36:12 +02:00
peter 12ee577660 feat(feed): Source filter in Mine, Relevance sort, auto search-source
Frontend for the Mine search finds + relevance search:
- The Source filter (organic / include search / search-only) now shows in Mine
  scope too, not just the Library.
- Returning from a YouTube search via 'Back to feed' switches the Source filter
  to 'search' so your just-found videos show in the feed you land on (filtered
  by the kept term).
- New 'Relevance' sort, offered while a search term is present and auto-selected
  when you start searching (reverts to newest when you clear it). EN/HU/DE.
2026-06-30 00:39:21 +02:00
peter 5d2b7d985d feat(feed): per-user search finds in Mine scope + full-text relevance search
Two related search improvements:

1) Your own live-search results now belong to your Mine feed. A new per-user
   search_finds table (migration 0030) records each video you surface via your
   YouTube search (the route inserts them idempotently). The Mine feed becomes
   'your non-hidden subscriptions OR your search finds', and the Source filter
   now applies in Mine too: organic = subscriptions, search = your search finds,
   all = both (default stays organic, so the main feed is unchanged). The shared
   Library keeps using the global via_search flag.

2) Feed search ranks by relevance instead of a whole-phrase substring. A custom
   unaccent_simple text-search config + GIN index (migration 0031) back a
   YouTube-like fuzzy match: word-order-independent, multi-word AND, prefix on
   the word being typed, accent-insensitive. A new 'relevance' sort orders by
   ts_rank; the channel name still matches as a substring. The rank is scaled to
   an integer so the keyset cursor pages it exactly (a raw float4 breaks paging).
   _filtered_query returns the rank expr so only the feed list uses it.
2026-06-30 00:39:09 +02:00
peter d3d1760f15 Merge: promote dev to prod 2026-06-29 23:43:44 +02:00
peter 83a7d27338 chore(release): 0.18.0 2026-06-29 23:41:53 +02:00
peter 313454ad88 Merge: video-player keyboard + scroll-wheel controls
feature/player-keyboard-controls: F=fullscreen, Space=play/pause, scroll wheel=
volume (with a volume-bar overlay), keeping keyboard focus on the modal.
2026-06-29 23:41:00 +02:00
peter 4f5f2920f0 Merge: zero-quota live-search scrape source + search back-nav fix
feature/search-scrape-zero-quota: live YouTube search can use YouTube's internal
InnerTube endpoint (admin-selectable search_source, default scrape) so it spends
no API quota; and the live-search results view now owns a history entry so Back
closes the player first and keeps the results.
2026-06-29 23:41:00 +02:00
peter 291da99a2c feat(player): keyboard + scroll-wheel controls in the video modal
Adds player-modal shortcuts and keeps keyboard focus on the modal (not the
cross-origin player iframe) on open and on player-ready, so they work until you
click into YouTube's native controls:
- F: toggle fullscreen (the player stage element, so the volume flash stays
  visible; a :fullscreen CSS rule drops the 16:9 letterbox). Esc in fullscreen
  exits fullscreen only, without also closing the modal.
- Space: play/pause (ignored while a button/input is focused).
- Scroll wheel over the video: volume up/down with an auto-fading volume-bar
  overlay. A transparent interaction layer over the iframe catches the wheel
  (the cross-origin iframe otherwise swallows it) and maps click to play/pause;
  it leaves the bottom strip uncovered so YouTube's native control bar (seek /
  settings / captions / fullscreen) stays usable, and is hidden on an embed
  error so the 'Open on YouTube' CTA stays clickable.
i18n shortcutsHint EN/HU/DE.
2026-06-29 23:37:18 +02:00
peter 1eb580f6fa fix(search): make the YouTube-search view its own history entry
The live-search results view had no browser-history entry of its own, so a
player opened over the results sat directly on the feed page entry. Pressing
Back (e.g. the mouse back button over the player) could pop past both the
player and the search in one step, bouncing from the search results to the
normal feed instead of just closing the player.

The search is now a feed sub-view that owns a history entry (history.state._yt):
entering a search pushes it, the popstate handler derives ytSearch from it, and
"Back to feed" pops it. Back now steps player -> search -> feed: the first Back
closes only the player (results stay), the second returns to the normal feed.
A reload drops any stale _yt so the first Back can't resurrect a gone search.
2026-06-29 23:03:25 +02:00
peter dca339bd3d feat(search): drop quota wording when the live search source is scrape
The search response now reports its source; in scrape mode (zero quota) the
results banner and Load-more button drop the 'uses quota' wording. Adds the
search_source toggle's labels/hints and updates the per-user-limit hint to note
the cost only applies to the api source. EN/HU/DE.
2026-06-29 22:30:03 +02:00
peter 44379f52a7 feat(search): zero-quota InnerTube scrape source + search_source toggle
Live YouTube search can now use YouTube's internal InnerTube endpoint instead
of search.list, materialising results through the same enrich/provenance path
at zero API quota (search.list costs 100 units/page; scrape costs nothing, only
the cheap shared videos.list enrich is charged).

- youtube/search_scrape.py: InnerTube search returning the same page shape as
  YouTubeClient.search_videos (items + continuation cursor); SOCS consent cookie,
  videoRenderer walk (channels/playlists/Shorts shelves naturally excluded),
  type:video filter, cached InnerTube key/version with constant fallbacks.
- routes/search.py: admin-selectable source (search_source, default scrape).
  Scrape path skips the 100-unit budget pre-check and logs a zero-cost search
  event so the per-user daily cap still counts it; api path unchanged. Response
  carries the active source.
- sysconfig/config: new search_source key (scrape|api).
- quota.log_action: record a zero-cost action event for per-user rate limits.
2026-06-29 22:29:54 +02:00
peter 1e1460266f Merge: live YouTube search
Search YouTube live from the app's search box (explicit action, not per-keystroke),
materialise results into the shared catalog so they render with the normal feed cards +
in-app player and gain per-user state. Provenance flags (via_search/from_search) let the
Library hide search-discovered videos by default with an organic/all/search-only Source
filter. Shorts and live/upcoming are never ingested this way; per-user daily cap (default
70) + budget pre-check gate the 100-unit search.list calls; demo excluded.

Also: accent-insensitive feed search (unaccent), debounced+keepPreviousData search box
(no flicker), source filter in share URLs, and a fresh feed on returning from search.

Migrations 0028 (provenance) + 0029 (unaccent). Not shipped — staying on dev.
2026-06-29 02:46:14 +02:00
peter ae7e077852 fix(search): refresh feed after returning from YouTube search
A live search ingests new catalog videos, but the normal feed query was disabled during
search mode and kept its pre-search cache — so 'Back to feed' showed the stale (often empty)
result until a manual reload. Drop the feed / feed-count / facets caches when leaving search
mode so the feed re-fetches fresh and immediately reflects the just-ingested videos.
2026-06-29 02:40:32 +02:00
peter 378fc70285 feat(feed): accent-insensitive search (unaccent)
Feed text search used plain ILIKE, which is case- but not diacritic-insensitive, so 'tiesto'
missed the many titles spelled 'Tiësto' — a search that ingested ~45 results showed only ~12.
Enable the postgres unaccent extension (migration 0029) and wrap both sides of the title/
channel match in unaccent(), so 'tiesto' now matches 'Tiësto'. Applies to feed, count and
facets alike.
2026-06-29 02:30:37 +02:00
peter 6ea0972899 fix(search): include Library source filter in shareable URL
The new library_source provenance filter wasn't serialised into the Share-view URL, so a
copied link lost the 'search results only' (or 'include search') selection. Add it as the
'source' param (emitted only in 'all' scope, omitted for the default 'organic').
2026-06-29 02:30:37 +02:00
peter ad921f0233 fix(feed): stop search-box flicker (keepPreviousData + debounced query)
Typing in the search box changed the feed/count/facets query keys on every keystroke, so
each query dropped to its loading state and blanked the content — the whole feed area
flickered. Debounce the search term feeding the queries (the input still updates instantly)
so they only re-run after a pause, and keep previous results on screen during a refetch via
placeholderData: keepPreviousData, so the feed and tag counts update in place without blanking.
2026-06-29 02:19:18 +02:00
peter 18cf277061 feat(search): Library provenance filter (organic / all / search-only)
Replace the binary 'show search-discovered' toggle with a 3-way Source selector in the
Library toolbar, so users can also see ONLY search-discovered videos — not just hide or
mix them. Backend: feed param library_source = organic (default, hides via_search) | all
(both) | search (only via_search), applied in scope=all. Strings in HU/EN/DE.
2026-06-29 02:11:53 +02:00
peter fb57abdf6a feat(search): live YouTube search UI
Surface live YouTube search in the existing feed, triggered explicitly so the
expensive API call is never per-keystroke.

- Header: the search box still filters the local catalog as you type; Enter or a
  YouTube button escalates the term to a live search (hidden for the demo account).
- Feed: a dedicated infinite query renders results in the same VirtualFeed cards +
  in-app player, under a banner with a back button and a quota note. No auto-paginate
  (each page spends 100 units) — an explicit 'Load more (uses quota)' button instead;
  quota/limit errors (incl. 429) shown inline. The empty local feed offers a
  'Search YouTube for <q>' CTA.
- Library: a 'Search-discovered' toggle reveals search-ingested videos (hidden by
  default); sent as exclude_search_discovered.
- Admin: search_daily_limit_per_user config field; new videos_search quota label.
- All new strings translated in HU/EN/DE.
2026-06-29 02:01:44 +02:00
peter b068cf7d43 feat(search): live YouTube search backend
Add a live YouTube search that materialises results into the shared catalog so
they render with the normal feed cards + in-app player and gain per-user state.

- YouTubeClient.search_videos(): search.list (100 units), embeddable-only, returns
  flat stubs + nextPageToken; surfaces liveBroadcastContent for live filtering.
- routes/search.py GET /api/search/youtube: require_human + per-user daily cap
  (search_daily_limit_per_user, default 70) + can_spend pre-check (429 on either);
  drops live/upcoming, upserts channel stubs (channels.list) + video stubs, enriches
  (videos.list), runs the youtube.com/shorts probe, then excludes Shorts/live and
  returns feed cards in relevance order with the YouTube pageToken as the cursor.
- Provenance: videos.via_search / channels.from_search (migration 0028) flag
  search-discovered rows; the feed hides them from the Library (scope=all) by default
  via exclude_search_discovered, leaving the Mine feed untouched.
- quota.actions_today() counts a user's per-action events today for the cap; only the
  search.list call is attributed VIDEOS_SEARCH so the counter is exactly 1 per search.
2026-06-29 02:01:31 +02:00
peter 3d2755d138 Merge: promote dev to prod 2026-06-29 01:13:52 +02:00
peter cd12374ceb chore(release): 0.17.0 — maintenance epic + messaging account-switch fix 2026-06-29 01:12:22 +02:00
peter fddec626d5 Merge bug/messaging-stale-thread-on-account-switch
Fix: switching accounts no longer lands the new user on a stale chat thread inherited
from the previous account (an empty self-thread where new messages never appeared).
Clear _sub/_ov from history.state before the switch reload + guard partnerId===meId.
User-confirmed on localdev with two real accounts.
2026-06-29 00:52:52 +02:00
peter 47860337a1 fix(messages): don't inherit a stale chat thread across account switch
Switching accounts does location.reload(), which preserves history.state — so the new
account re-mounted the Messages module from the previous account's _sub sub-view, whose
partnerId belongs to the OLD identity (often the new account's own id → an empty
self-thread). Symptom: after switching to the recipient you land on a blank thread and
the just-received message never shows (F5 keeps history.state); only going back to the
list and reopening the real conversation works.

Fix: (1) strip _sub/_ov from history.state before the switch reload so the new account
starts at the module root; (2) guard Messages so a thread view with partnerId === meId
falls back to the conversation list (can't message yourself).
2026-06-29 00:48:18 +02:00
peter 95ae104294 Merge improvement/frontend-decompose
Maintenance epic phase 4: formatter/i18n consolidation + a god-component split.
- formatEta + Scheduler countdown 'now' now translated (time.eta.* in EN/HU/DE),
  fixing a trilingual-rule violation; relative-time + total-duration formatters
  consolidated into lib/format.ts; orphaned notifications.time.* keys removed.
- PlayerModal's ~140 lines of pure description link-parsing extracted to
  lib/descriptionLinks.tsx (testable; slims the 659-line component).
Deferred as readability-only / lower-ROI (no duplication removed): AuthCard split
from Welcome, PlaylistRail/usePlaylistYoutubeSync from Playlists, EmptyState/Loading
+ Badge unification, pause/resume+sync query hooks.
Verified: tsc + vite build clean; localdev browser smoke (player opens, Stats renders,
zero console errors / no missing-key warnings).
2026-06-29 00:27:59 +02:00
peter 4736641f6d refactor(player): extract description link-rendering into lib/descriptionLinks
Moves the ~140 lines of pure URL/timestamp/hashtag parsing + renderDescription out
of the 659-line PlayerModal into a standalone, testable module. No behavior change.
2026-06-29 00:24:49 +02:00
peter 3f3d64a977 refactor(i18n,format): translate ETA strings; consolidate time/duration formatters
- formatEta was hardcoded English (violated the trilingual rule) -> time.eta.* keys
  in EN/HU/DE; Scheduler's countdown 'now' likewise.
- relativeFromMs added to format.ts; NotificationsPanel drops its duplicate
  relativeTime/relativeFromMs and the now-orphaned notifications.time.* keys.
- Channels' fmtTotalDuration moved to format.ts as formatTotalHours.
2026-06-29 00:22:21 +02:00
peter fd82973ef2 Merge improvement/frontend-ui-primitives
Maintenance epic phase 3: shared UI primitives.
- components/ui/form.tsx: Switch, Section (+card), SettingRow, HintLabel
  (replaced copies in SettingsPanel/ConfigPanel/Stats/Sidebar/AdminUsers)
- components/ui/DraftSaveBar.tsx: one Save/Discard bar (Settings inline + Config floating)
- lib/useDismiss.ts: outside-click/Escape close hook (DataTable/Channels/AddToPlaylist)
Deferred as lower-ROI: EmptyState/Loading + Badge unify (cosmetic, multi-screen visual
churn) and the pause/resume+sync query hooks (leaky-abstraction risk).
Verified: tsc + vite build clean; localdev browser smoke — Settings toggles+save bar,
Channels filter popover dismiss, zero console errors.
2026-06-29 00:14:31 +02:00
peter a6c2357930 refactor(ui): useDismiss hook for outside-click/Escape close
lib/useDismiss.ts replaces the identical mousedown-outside + Escape effect that
DataTable (filter popover), Channels (tag picker) and AddToPlaylist each hand-rolled.
Positioning stays with each caller (it genuinely varies); AddToPlaylist keeps its
own resize/scroll reposition effect.
2026-06-29 00:11:44 +02:00
peter e04ba3fb7c refactor(ui): shared DraftSaveBar (Settings + Config save bars)
One Save/Discard bar with an inline (Settings card) and floating (Config page)
variant, replacing the two near-identical hand-rolled bars + their state machine.
2026-06-29 00:07:37 +02:00
peter aae123faf6 refactor(ui): shared form primitives (Switch, Section, SettingRow, HintLabel)
components/ui/form.tsx replaces the per-panel copies:
- Switch — was duplicated in SettingsPanel (Switch) + ConfigPanel (Toggle) +
  Sidebar (inline labeled toggle).
- Section (with a card variant) — was in SettingsPanel + Stats (plain) and
  AdminUsers (glass card).
- SettingRow + HintLabel — the label+hint+control row was identical in SettingsPanel + Stats.
No visual change intended (Sidebar's toggle gains the standard knob shadow).
2026-06-29 00:04:45 +02:00
peter dcee5b38e6 Merge improvement/frontend-state-foundation
Maintenance epic phase 2: frontend state/persistence foundation.
- lib/store.ts createStore<T> (errorDialog + hints adopt it)
- lib/storage.ts: LS key registry + readMerged/readJSON/writeJSON + usePersistedState
- migrated every siftlode.* localStorage site to the registry/helpers; Stats,
  SettingsPanel and App channel tabs now use usePersistedState (were 3 inline reinventions)
Verified: tsc + vite build clean; localdev browser smoke — theme/filters/tabs persist
across F5 (readMerged boot path applies them), zero console errors.
2026-06-26 03:33:00 +02:00
peter 8456b2245d refactor(frontend): adopt store/storage helpers; centralize persistence
- errorDialog + hints now use createStore (drop their bespoke listener arrays).
- theme/sidebarLayout/notifications/App filters/Playlists plSort use readMerged/
  readJSON/writeJSON instead of inline try/JSON.parse/catch.
- Stats, SettingsPanel and App's channel filter/view tabs use usePersistedState
  (the 3 sites that reinvented usePersistedTab inline).
- Every siftlode.* key now sourced from the LS registry (no scattered literals).
2026-06-26 03:30:19 +02:00
peter 7ae3802f38 feat(frontend): add createStore factory + central localStorage registry
- lib/store.ts: createStore<T> — one observable-value primitive (get/set/subscribe/use)
  replacing the hand-rolled listeners/subscribe/emit triad several modules each had.
- lib/storage.ts: LS key registry (every siftlode.* key in one place), readMerged/
  readJSON/writeJSON (the try/JSON.parse/merge/catch done once), and usePersistedState
  (the reactive persisted-string hook, generalizing usePersistedTab).
2026-06-26 03:30:19 +02:00
peter 3e58790c90 Merge improvement/backend-consolidation
Maintenance epic phase 1: backend duplication cleanup + one correctness fix.
- fix: maintenance no longer scans the removed status=="saved" (under-counted impact)
- DRY: shared feed-row projection, playlist mirror/position/add-item helpers,
  token hashing, email validation, admin_user dep + count_admins, quota.measured,
  is_messageable_user, Timestamp/UpdatedAt model mixins.
Verified: compile + full app import + zero alembic schema drift + localdev smoke
(demo login, feed, playlist CRUD, sync status all green).
2026-06-26 03:17:51 +02:00
peter 1b3b56031a refactor(models): extract Timestamp/UpdatedAt mixins; fix stale status comment
TimestampMixin (created_at) + UpdatedAtMixin (updated_at) replace the repeated
column blocks across 9 + 3 models. Models that index created_at or default it
Python-side keep their own column on purpose. Verified zero schema drift via an
identical alembic-check diff before/after.
2026-06-26 03:15:53 +02:00
peter 776cc61c1f refactor(playlists,feed): share feed-row projection & playlist helpers
- feed_columns() is the single feed-row projection, consumed by both /feed and the
  playlist-detail query (was copy-pasted, drift risk on every new column).
- _replace_items_from_youtube() dedups the YouTube playlist-mirror block shared by
  sync_user_playlists and repull_playlist.
- _next_playlist_position/_next_item_position/_add_item replace the append-position
  idiom (x4) and the exists-then-insert blocks (x2).
2026-06-26 03:15:53 +02:00
peter c42ea641fc refactor(backend): quota.measured helper, consistent admin gating, messageable predicate
- quota.measured() context manager folds the before/after units diff that the
  manual sync routes each re-spelled (also makes quota_remaining_today consistent).
- sync pause/resume now use Depends(admin_user) instead of inline role checks.
- is_messageable_user() unifies the 'real, active, non-suspended human' rule that
  was encoded three ways (WS auth, send recipient, and the SQL _messageable()).
2026-06-26 03:15:53 +02:00
peter 1758b50062 refactor(auth): centralize token hashing, email validation & admin gating
- app/utils.py: shared valid_email() + now_utc() (one email regex, was duplicated
  in auth.py and admin.py with a looser "@"-in check elsewhere).
- security.hash_token(): one SHA-256 token hasher (was duplicated in auth.py + state.py).
- auth.admin_user dependency + count_admins() helper, replacing the inline role
  checks and the last-admin count query repeated across admin.py/me.py/tags.py.
2026-06-26 03:15:36 +02:00
peter 4e426606dd fix(maintenance): drop dead status=="saved" scan
Watch later became a built-in playlist, so "saved" is no longer a VideoState
status — the scan matched nothing and under-counted users impacted by a video
removal. The playlist loop already covers Watch later, so impact is now correct.
Route the local _now through the shared utils.now_utc.
2026-06-26 03:15:36 +02:00
peter a146a353f7 Merge: promote dev to prod 2026-06-26 01:42:54 +02:00
peter b0eef60878 chore(release): 0.16.2 — access-requests UX + back-navigation fixes 2026-06-26 01:42:12 +02:00
peter 06f2380483 Merge bug/access-review-tab — access-requests UX + general back-navigation fixes 2026-06-26 01:41:46 +02:00
peter 795569530b fix(nav): Back steps through sub-views and closes modals before leaving a page
In-app history only tracked the top-level page, so Back from a module sub-view
(e.g. a Messages thread) or with a modal open jumped straight to the previous
module. Add two history primitives: useHistorySubview (a module's sub-view rides
in history.state, so Back returns to its root first) and useBackToClose (a
mounted overlay occupies one history entry; Back closes the topmost, nesting-safe
so a button-close doesn't trip the modals underneath). Apply to the Messages page
views and to PlayerModal + the shared Modal. setPage now pushes a clean entry so
each page starts at its root.
2026-06-26 01:37:43 +02:00
peter 661a7bcc31 fix(notifications): access-requests nudge auto-clears on approval
The admin access-requests notice was issued from the on-user-load effect (keyed
on user id), so it only re-resolved on a fresh load — it lingered until F5 after
an approval dropped the pending count to 0. Move it to its own effect keyed on
the pending count, so approving/denying (which refetches /api/me) clears or
re-counts the notice live.
2026-06-26 01:19:01 +02:00
peter 88e2d3721f fix(url): strip leftover query string once signed in
The pre-login ?access=requested (and ?verify/?reset) lingered in the address bar
after login. Strip the query string on auth (the logged-in app reads nothing from
the URL); Welcome still reads its params while logged out. stripUrlParams now
preserves history.state so it's safe to call after the in-app page stamp.
2026-06-26 01:13:26 +02:00
peter 7f4469beea fix(notifications): Review link opens the Access requests tab, not the last-used one
The access-requests notice's Review link navigated to the Users page but the
persisted tab (often Demo) loaded instead. Pre-select the access tab before
navigating (focusAccessRequestsTab writes the persisted-tab key, which the page
reads on its fresh mount).
2026-06-26 01:05:14 +02:00
peter e1f8bf0bd8 Merge: promote dev to prod 2026-06-26 00:37:25 +02:00
peter 1dd251440e chore(release): 0.16.1 — notification context + YouTube API egress proxy 2026-06-26 00:36:46 +02:00
peter 547f114aa5 Merge bug/notif-context-and-api-proxy — notification context + YouTube API egress proxy 2026-06-26 00:36:22 +02:00