Commit graph

130 commits

Author SHA1 Message Date
npeter83
a00779a1c9 feat(playlists): rail sorting, consolidated item sort, persist selection
1) Fix: the selected playlist is now persisted (localStorage) and scrolled into view,
   so F5 keeps it instead of jumping to the first one.
2) Consolidate the in-detail sort: one key select (Title/Duration/Channel) + an
   asc/desc direction toggle (was separate A-Z / Z-A / shortest / longest options);
   add Channel as a sort key. Direction also orders the channel groups when grouping.
3) Left rail sorting: by name / item count / total length, asc/desc, plus an
   'unsynced first' toggle that floats playlists with unpushed edits to the top.
   Backend: list/summary now return total_duration_seconds (grouped sum). The rail
   also shows each playlist's total length. Sort prefs persist to localStorage.
2026-06-15 22:13:32 +02:00
npeter83
40c0cbfc5a feat(playlists): sort + group-by-channel with reusable undo/redo
Add a sort toolbar to the Playlists page: Title A-Z/Z-A, shortest/longest first,
and a 'group by channel' toggle that groups items by channel (A-Z) and applies the
chosen sort within each group. Applying a sort reorders the items and persists via
the existing reorder API.

The item order is now an undoable value: drag, sort and grouping all go through it,
so every change is reversible via undo/redo buttons and Ctrl/Cmd+Z / Ctrl+Y
(Ctrl+Shift+Z). Built on two reusable pieces, not playlist-specific:
- useUndoable<T> — a generic past/present/future snapshot hook (set/undo/redo/reset
  + canUndo/canRedo), ref-backed so callbacks are stable and never see a stale snapshot;
  onApply runs the side effect (here: persist order).
- UndoToolbar — undo/redo buttons + keyboard shortcuts, plain props so any undo
  source can use it.

Undo history is reset only when the item set actually changes (different playlist or
add/remove), so a refetch confirming our own reorder doesn't wipe it; membership
changes (remove) clear history since they aren't reorder-undoable. Trilingual.
2026-06-15 21:52:28 +02:00
npeter83
9acea11010 feat(playlists): make YouTube-mirrored playlists editable + syncable
Mirrors (source='youtube') were read-only; per the original two-way design they can
now be edited locally (add/remove/reorder/rename) and pushed back. The read-sync
skips a dirty mirror so it won't clobber unpushed edits; a successful push clears
dirty and lets the mirror refresh. _mark_dirty now covers any YouTube-linked list
(exported local or mirror), and rename marks dirty too. push/delete no longer reject
mirrors. Push reconciles the title as well (cheap playlists.list compare, then
playlists.update only if changed) so a local rename doesn't revert on the next pull.
Frontend: editable = not-built-in (was also excluding mirrors); rows/reorder enabled
for all owned lists; AddToPlaylist popover lists mirrors too (with a YT marker); the
origin chip now reads 'edits sync back'. Trilingual.
2026-06-15 21:40:13 +02:00
npeter83
8911e9dc89 feat(playlists): Sync to YouTube UI + delete-on-YouTube choice
Editable local playlists get an Export/Sync to YouTube button (write-scope gated):
it fetches a dry-run plan, shows a confirm with the change counts, quota estimate
and divergence warning, then pushes. An 'unsynced changes' badge and an accented
YouTube icon mark linked playlists with local edits. Deleting a linked playlist
offers 'delete on YouTube too' vs 'here only'. Trilingual strings (HU/EN/DE).
2026-06-15 21:23:13 +02:00
npeter83
29696c549c fix(notifications): use the success level for success toasts
The 'Synced N playlists' and 'View link copied' toasts used the default info level,
which renders in the accent colour and read as an error/alert. Mark them level:success
so they show the green check — clearly positive and theme-independent.
2026-06-15 19:46:37 +02:00
npeter83
84b56de21e feat(playlists): YouTube-mirrored playlists in the UI (read-only) + sync button
Show YouTube-sourced playlists with a YouTube icon; they're read-only for now (no
rename/delete/reorder/remove, excluded from the add-to-playlist popover) with a
'synced from YouTube' note — editing comes with the write-back phase. Add a 'Sync from
YouTube' button in the Playlists rail (api.syncYoutubePlaylists) with a result toast.
Trilingual strings.
2026-06-15 19:37:17 +02:00
npeter83
4ff0b9705c fix(header): keep the sync-status count live (focus refetch + background poll)
The header 'N without full history' (and sync state) polled every 30s but only while
the tab was focused, and the global refetchOnWindowFocus is off — so after switching
tabs the count looked frozen until a manual reload. Poll in the background too and
refetch on focus so it tracks the scheduler's progress without F5.
2026-06-15 17:00:06 +02:00
npeter83
ddb1890e84 feat(playlists): keep the player queue live across tabs
Track the playing item by id instead of a frozen index, deriving the index from the
live queue so the N / M counter and prev/next neighbours stay correct when the playlist
changes underneath (e.g. an item removed in another tab) without disrupting playback of
the current video. Refetch the playlist list/detail on window focus so such changes flow
in, updating the player's queue length in near-real-time.
2026-06-15 16:27:28 +02:00
npeter83
4689d2cbbd feat(playlists): queue playback in the player (auto-advance + prev/next)
PlayerModal now accepts an optional queue + startIndex. The active item drives the
player; it recreates per item (reusing the single-video resume / auto-watch / progress
logic), auto-advances to the next item when one ends, and shows Previous / Next controls
with an N / M indicator. The Playlists page passes the playlist as the queue from Play all
or a clicked row. Trilingual previous/next strings.
2026-06-15 16:11:37 +02:00
npeter83
dea740b728 feat(playlists): unify Saved into a built-in Watch later playlist
The old per-video status='saved' becomes membership in a built-in, undeletable
'watch_later' playlist. Migration 0012 moves existing saved videos into each user's
Watch later list and demotes the states to 'new'. The feed/playlist serializers now
expose a 'saved' boolean (EXISTS in watch_later); the card bookmark toggles watch_later
via new /api/playlists/watch-later add/remove endpoints (create-on-demand) instead of
setting a status. Removed the 'saved' show-filter and status. Watch later shows a
localized name and hides rename/delete in the Playlists page and add-to-playlist popover.
2026-06-15 15:33:53 +02:00
npeter83
ef11c6c605 fix(playlists): refresh playlist detail after add/remove from the feed popover
Adding a video to a playlist via the AddToPlaylist popover invalidated the membership
and the playlist list, but not the playlist *detail* query, so the Playlists page
served stale cached items (the new video only appeared after F5). Invalidate the
["playlist"] detail queries too.
2026-06-15 15:20:28 +02:00
npeter83
4ed52cd244 fix(playlists): correctly clear/reselect after deleting a playlist
The post-delete auto-select effect re-picked the just-deleted id from the still-stale
playlists cache, so its detail lingered. Select the next remaining playlist directly on
delete (or null), drop the deleted detail from the cache, and make the effect re-validate
the selection against the current list (clear when empty, reselect when the current id is
gone). Also fix the header title showing "Channel manager" on the Playlists page.
2026-06-15 15:15:56 +02:00
npeter83
48cb11434d feat(ui): reusable app-styled confirm dialog, replacing window.confirm
Add a promise-based ConfirmProvider + useConfirm() hook (built on the Modal shell,
liquid-glass styling, danger variant, Esc/backdrop = cancel, Enter = confirm) so
confirmations match the app instead of the native browser dialog. Wire it in at the
app root and replace both window.confirm call sites (playlist delete, channel
unsubscribe). Trilingual common.confirm/confirmTitle strings.
2026-06-15 15:06:47 +02:00
npeter83
50bc35d78f fix(nav): persist the current page across reloads
Since filters/page are no longer mirrored to the URL (de-URL refactor), pressing F5
on the Channels/Playlists/Stats page dropped back to the feed because the initial
page was read only from the (now-absent) ?page= param. Persist page to localStorage
and restore it on load; a share link's ?page= still takes precedence.
2026-06-15 14:59:24 +02:00
npeter83
d7fc237d66 fix(playlists): clear the detail panel after deleting the selected playlist
The detail pane rendered from react-query's cached data, so deleting the selected
playlist emptied the rail but left its stale contents on the right. Gate the detail
view on the current selection so it falls back to the empty state when nothing (or a
just-deleted playlist) is selected.
2026-06-15 14:56:39 +02:00
npeter83
fb5a2f1ed2 fix(playlists): flip the add-to-playlist popover up when it would overflow
The popover was always positioned below its trigger, so on cards near the bottom
of the viewport it ran off-screen. Measure the panel's real height and open it
upward (clamped to the viewport) when there isn't room below; also clamp the
horizontal position.
2026-06-15 14:52:57 +02:00
npeter83
40a44cdde2 feat(playlists): frontend foundation — page, add-to-playlist, nav
Add the Playlists page (left rail of playlists + detail with drag&drop reorder,
inline rename, one-step delete, remove item, Play all / row-click playback via the
existing PlayerModal) and an AddToPlaylist popover (portaled, multi-toggle +
inline new-playlist) wired into the VideoCard hover overlay and the PlayerModal.
New api client methods + Playlist types, a 'playlists' page route + account-menu
entry, and trilingual strings. Local only — YouTube sync comes in later phases.
2026-06-15 14:45:18 +02:00
npeter83
34c41f7a41 chore(release): 0.2.0 — shared library, dynamic facets, share view
Bump VERSION to 0.2.0 and add release notes covering the Mine/Library shared-feed
scope, dynamic faceted filter chips (live counts, hide non-matching, Any/All topic
match), surprise-me reshuffle, share-view links, and the deep-backfill status
clarity fixes.
2026-06-15 13:58:22 +02:00
npeter83
e18be73d46 feat(filters): "Share view" link button in the sidebar
Add a share button next to Clear all that copies a link reproducing the current
filter view (filters, sort, scope) to the clipboard, with a confirmation toast.
This is the opt-in replacement for the old always-on URL mirroring. Trilingual.
2026-06-15 12:29:51 +02:00
npeter83
3e90fe2e5b refactor(filters): stop mirroring filters into the URL; localStorage is canonical
Filters/sort/search/scope were written to the address bar on every change (a leftover
from sharing reproducible examples), giving two sources of truth. Make localStorage the
single source: drop the automatic syncUrl from setFilters/setPage. A "Share view" link
still hydrates filters on first load, after which the query is stripped from the URL
(stripUrlParams) so it stays clean. syncUrl is replaced by shareUrl (builds the link on
demand); the serializer now also round-trips scope.
2026-06-15 12:29:43 +02:00
npeter83
aa4b069567 feat(filters): make the topic Any/All match toggle prominent
The AND/OR ("Any"/"All") control for topic chips was a single faint corner link
that was easy to miss. Replace it with a labelled segmented control ("Match:
[Any][All]") so the AND option is discoverable. New trilingual 'match' label.
2026-06-15 12:20:17 +02:00
npeter83
5656875a07 feat(filters): sort facet chips by count, then name
Order topic/language chips by their (contextual) count descending, name as the
tiebreaker, so the most-populated tags sit at the top and the smallest counts
fall to the bottom as you scan down.
2026-06-15 12:10:09 +02:00
npeter83
79f53ccf59 feat(filters): dynamic faceted chips driven by /api/facets
Topic and language chips now show live channel counts for the current filter
context instead of the static global count, and chips that match nothing are
hidden (selected chips stay so they can be cleared). Selecting a channel (or any
filter) drops the now-irrelevant chips and updates the rest. Extract a shared
filterParams() so the feed and facets queries see identical filters; the facets
query is keyed on filters so it refetches as they change. Trilingual empty-state
string when a category has no matching tags.
2026-06-15 12:06:02 +02:00
npeter83
cb35b0e86e improvement(sync): reflect deep backfill as active in the header status
The header status only considered recent-sync pending, so it read "all synced"
while the scheduler was still backfilling full history — directly contradicting
the adjacent "N without full history" notice. Add an active "fetching history"
state (spinner) shown when recent sync is done but deep backfill is still
pending, so "all synced" appears only when nothing is pending at all. The admin
pause button now also shows during deep backfill (it's pausable work). Trilingual.
2026-06-15 04:30:27 +02:00
npeter83
5fba327d24 improvement(channels): distinct label for full-history queued by another user
The channel manager already styled the two deep-backfill-pending states
differently (solid clickable chip when you requested full history vs a faint
outline when another subscriber did), but both used the same "full history
queued" label, so a channel queued by someone else looked identical to one you
queued yourself. Give the by-other case its own label ("full history coming")
so the distinction is legible at a glance, not just on hover. Trilingual.
2026-06-15 04:23:49 +02:00
npeter83
db4a7c0440 feat(feed): shared-library scope toggle in the header
Add a "Mine / Library" segmented toggle (shown on the feed page) that switches
FeedFilters.scope between the user's own subscriptions and the whole shared
catalog. A read-scope-less user (signed in but no YouTube grant) can now browse
and manage the shared library on their own account — the empty "my feed" state
offers a "browse the shared library" shortcut alongside the connect-YouTube CTA.
scope is preserved across "Clear all" (it's a mode, not a filter) and kept out
of the URL state. Trilingual strings (HU/EN/DE) for the toggle and the CTA.
2026-06-15 04:06:22 +02:00
npeter83
5a96607557 feat(feed): reshuffle button for the "surprise me" sort
The backend shuffle sort already accepts a seed param. Add a circular
reshuffle button next to the sort control (shown only when shuffle is
active) that re-rolls the seed and re-queries the feed; selecting shuffle
also seeds a fresh order instead of the deterministic seed-0 one. The seed
lives in FeedFilters but is intentionally kept out of the URL state.
2026-06-15 03:08:10 +02:00
npeter83
b325893b22 feat(filters): show channel counts on topic and language chips
The per-tag channel_count was already returned by GET /api/tags but only
surfaced in the chip tooltip. Render it as a small count badge on the chip
face so the relative weight of each topic/language is visible at a glance.
2026-06-15 03:08:03 +02:00
npeter83
53708891b3 docs(release): note multilingual UI in v0.1.0 release notes 2026-06-15 02:09:22 +02:00
npeter83
cdc15ad1f9 feat(channels): clearer error when a YouTube action lacks the scope
api: HttpError now carries the server's detail. Channels sync/backfill/unsubscribe
now detect a 403 (no YouTube grant) and show a 'connect your YouTube account'
message with a Connect action that opens the onboarding wizard, instead of a vague
'failed' toast. Translated HU/EN/DE.
2026-06-15 02:02:05 +02:00
npeter83
ea317c0009 feat(i18n): translate remaining components (HU/EN/DE)
Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel,
OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime
helper are now fully translated in Hungarian, English and German, each with its own
locale area file (auto-loaded). Key parity verified across all three languages.
2026-06-15 00:47:04 +02:00
npeter83
941fb7d756 feat(i18n): translate login and app chrome (HU/EN/DE)
Login screen (with a language picker), header, account menu, sync status, About and
Release Notes dialogs, and the version banner are now fully translated in Hungarian,
English and German.
2026-06-15 00:30:34 +02:00
npeter83
7aa068061d feat(i18n): foundation — react-i18next, language switcher, server-persisted choice
Set up react-i18next with locale files auto-loaded per area (Vite glob), a compact
LanguageSwitcher, and language as a server-persisted preference (preferences.language)
mirrored to localStorage. On first login the default UI language is guessed from the
Google-reported locale (hu/en/de, else English). vite-env.d.ts types the build-time env.
2026-06-15 00:30:34 +02:00
npeter83
82f0936ca7 feat(ui): About dialog, Release Notes, and new-version banner
About (in the account menu) shows frontend/backend/database versions + build.
Release Notes renders per-version highlights with a commit-SHA reference; a
dismissible banner appears once after the running build's version changes and
links into the notes. Adds a reusable Modal shell and the release-notes data
(detailed v0.1.0).
2026-06-15 00:06:57 +02:00
npeter83
6683720ab8 Merge: header video-count clarity + idle pause hidden 2026-06-14 18:43:11 +02:00
npeter83
b4109909db feat(header): clarify video counts and hide idle pause button
Show 'N yours / M total' in the header (your subscriptions vs. the whole shared
catalog) with a tooltip, backed by a new total_videos field on /sync/my-status.
The admin pause button now only appears when there's sync work to pause; Resume
still shows whenever sync is paused.
2026-06-14 18:42:55 +02:00
npeter83
04c971f623 feat(feed): resume progress bar, play/continue/restart, in-progress filter
Video cards show a resume progress bar for started-but-unfinished videos and a
hover overlay: Play on every card, Continue + Restart on in-progress ones. The
in-app player now resumes from (and checkpoints to) the server position instead
of localStorage, accepts an explicit startAt (Restart -> 0), and refreshes the
feed on close so the card bar reflects the session. Sidebar gains an
'In progress' show filter.
2026-06-14 18:40:12 +02:00
npeter83
3f7298cca0 feat(channels): status filter + header full-history link + stable priority
- header: per-user "N without full history" count (channels_deep_pending),
  clickable with a hint -> opens the channel manager filtered to those.
- channel manager: status filter chips (All / Needs full history / Fully synced
  / Hidden); the header link deep-links to "Needs full history".
- fix: priority up/down is now an optimistic in-place cache update (no refetch /
  re-sort), so the list no longer jumps to the top and loses your scroll position;
  the new order applies on the next page load.
2026-06-14 07:08:59 +02:00
npeter83
6d2f84f791 fix(header): show per-user sync status instead of the global catalog
The header status bar read the global /api/sync/status (videos_total +
channels_backfilling), so every user saw the whole catalog's numbers —
confusing and a small cross-user info leak (e.g. "3 syncing" for a user with 2
channels). It now uses /api/sync/my-status: the user's own available video count
and how many of their own channels are still being fetched
(channels_recent_pending). The pause control stays admin-only via an isAdmin prop.
2026-06-14 06:55:18 +02:00
npeter83
4f80262046 feat(onboarding): auto-import subscriptions after read grant + empty-feed guidance
After read access is granted the wizard now imports the user's YouTube
subscriptions automatically (with a "Building your feed…" progress state), so a
new user lands on a populated feed — channels already in the shared catalog show
up instantly, new ones backfill in the background. The empty feed now prompts
users without read access to set up via the wizard instead of a bare message.
2026-06-14 06:36:12 +02:00
npeter83
9ace042510 chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).

Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00
npeter83
6a71e3d943 feat(legal): public privacy policy, terms, and homepage for OAuth verification
Adds login-free /privacy and /terms pages (rendered outside the authenticated
tree via a pathname switch in main.tsx) carrying the Google API Services Limited
Use disclosure, YouTube ToS / Google Privacy links, and contact + data-deletion
info. The sign-in screen now describes the app and links to both, satisfying
Google's homepage + privacy-policy requirements for the OAuth consent screen.
2026-06-14 01:29:44 +02:00
npeter83
e0980487af feat(onboarding): first-login wizard for incremental YouTube consent
After the clean name/email sign-in, a wizard walks the user through granting
YouTube read (then optionally write) one step at a time, each with a plain
rationale and an up-front heads-up about Google's "unverified app" screen.

The visible step is derived from the granted scopes (can_read/can_write) so the
flow resumes correctly across the full-page consent redirect; it's dismissible
and reopenable from Settings -> Account, which now lists read and write as
separate, individually-grantable access rows.
2026-06-14 01:11:29 +02:00
npeter83
a8e98d03ae perf(feed): smoother scrolling for long feeds
Three low-risk wins for large filtered feeds:
- content-visibility:auto on cards (.cv-card/.cv-row) so the browser skips
  layout/paint for off-screen cards; contain-intrinsic-size keeps the scrollbar
  stable and is remembered per card after first render.
- memo(VideoCard) + stable onState/onChannelFilter callbacks (onState reads the
  loaded list via a ref) so appending a page only renders the ~60 new cards
  instead of reconciling every card already on screen.
- Prefetch the next page earlier (sentinel rootMargin 800px → 1500px) so the
  'Loading more…' flash is far less likely during fast scrolling.
2026-06-12 18:17:03 +02:00
npeter83
a64a8830eb fix(notifications): don't resurrect stale toasts on reload
Persisted notifications were restored with their saved 'dismissed' state, so any
that were still active when the page closed came back as active toasts — but
their auto-dismiss timers aren't re-armed on load, leaving them stuck on screen
forever (a burst of errors could pile up dozens). Mark restored entries as
dismissed on load: they stay in the bell history but no longer reappear as live
toasts. The toast surface is session-transient; history persists.
2026-06-12 18:07:31 +02:00
npeter83
a640b181ee fix(ui): robust avatars — no-referrer + graceful fallback
Channel/account avatars come from Google's image CDNs (yt3.ggpht.com,
lh3.googleusercontent.com). On a feed page dozens load at once; the CDN
rate-limits the referrer-bearing burst (429), so a random subset rendered the
browser's broken-image icon (the URLs themselves are valid — verified 200).

Add a shared <Avatar> that sets referrerPolicy="no-referrer" (which the CDNs
serve without throttling) and falls back to a neutral initial placeholder on
error instead of the broken-image icon. Use it for video-card, player, channel
manager, header and settings avatars.
2026-06-12 18:01:43 +02:00
npeter83
db0ffc3717 fix(feed): clear optimistic overrides on refetch
The feed kept per-video optimistic status overrides until filters changed, so a
status reverted elsewhere (e.g. Unwatch from the notification center) was masked
by the stale override and stayed filtered out of the current view. Also clear
overrides when fresh feed data arrives — the server is then authoritative.
2026-06-12 17:39:28 +02:00
npeter83
d5dab5377f feat(notifications): notify on 'watched' with an Unwatch action
Mirror the hide flow for watched: marking a video watched (card, modal toggle,
or auto-watch) raises a toast and a bell-history entry with an Unwatch action —
no Find-in-feed, just revert. NotifMeta becomes a discriminated union
(video-hidden | video-watched); unhide/unwatch share one revert-to-new helper.
2026-06-12 17:39:28 +02:00
npeter83
1bdde051c1 feat(player): linkify descriptions and play YouTube links inline
- Narrow the title hover target to the actual text, not the whole row.
- Linkify descriptions: timestamps (mm:ss / hh:mm:ss) seek the player; emails
  become mailto:; hashtags link to YouTube's hashtag feed; other URLs open in a
  new tab. Blank lines are stripped so the popover isn't mostly whitespace.
- YouTube links play in the inline player: a link to the current video seeks
  (honoring t=), a link to another video navigates the player to it, with a Back
  button to the original. While on a linked video the title/author come from the
  player and its views/date/duration + a clickable channel come from the detail
  endpoint, which falls back to the YouTube API (videos.list, attributed to the
  user) for videos not in our DB.
2026-06-12 17:39:20 +02:00
npeter83
195044042f feat(player): polish the description popover (opaque, portaled, opens upward)
The popover bled through (it used the translucent glass surface), was clipped by
the modal card's overflow, and ran off the bottom of the viewport. Make it an
opaque surface, render it through a portal to <body> with fixed positioning so
nothing clips it, and anchor it above the title so it grows upward where there's
room. Add a small hover grace so the pointer can travel title → popover.
2026-06-12 17:39:08 +02:00