Commit graph

130 commits

Author SHA1 Message Date
npeter83
007458e61f feat(config): admin Configuration page (registry-driven)
New admin-only Configuration page that renders the system_config registry grouped
(Email/SMTP first), with per-field save/reset, write-only encrypted secret fields
(disabled with a hint when TOKEN_ENCRYPTION_KEY is unset), and a Send-test-email
button. New 'config' page + sidebar nav item + header title; api methods and
EN/HU/DE strings.
2026-06-19 12:23:00 +02:00
npeter83
874520bd8a feat(stats): consolidate per-user sync status into the Stats page
The Settings > Sync tab moves into the Stats module: Stats is now a per-user
page (Overview tab = sync status + your own API usage + manual actions) with the
admin instance-wide quota dashboard + background-sync pause as an admin-only
System tab. The Stats nav item is visible to all users (was admin-only); the
Settings Sync tab is removed. Sync i18n strings move from settings.sync.* to
stats.sync.* (EN/HU/DE).
2026-06-19 11:48:20 +02:00
npeter83
0d48e11d31 feat(discovery): total-videos column + subscribe confirmation
Add a "Videos" column (channel total uploads, already in the discovery
response) to the Discover-from-playlists table, and guard the Subscribe button
with a confirm dialog warning it changes the real YouTube account and spends
quota (mirrors the unsubscribe guard). Strings added in EN/HU/DE.
2026-06-19 11:18:28 +02:00
npeter83
8de1d801c4 refactor(banner): extract reusable Banner base for VersionBanner + DemoBanner
Both top-of-app bars shared the same shell (icon + content + border-b bar);
factor it into a tone-driven Banner with optional action/dismiss. The
version/demo logic stays in the wrappers. Leaves a clean extension point for
a future news-ticker variant.
2026-06-19 11:18:20 +02:00
npeter83
52a5d1fef8 fix(channels): drop a stale column filter when sent to "without full history"
A focus-channel deep-link sets (and persists) the channel-name column filter
via the DataTable's externalFilter. That persisted value then survived into
unrelated visits: clicking the header's "N without full history" link landed
on the right tab with the right status chip, but a leftover name filter (e.g.
a channel focused in a past session) hid every row — "No channels".

Add a resetFiltersToken to DataTable that clears its column filters when it
advances, and bump it from the header intent. The token's ref starts at 0 so
the clear fires even when the table remounts on navigation, while a plain
reload (token still 0) keeps the user's persisted filters.
2026-06-19 03:55:55 +02:00
npeter83
39cd025e79 fix(channels): land on the subscriptions tab for channel-targeted navigation
The Channel manager's tab (subscriptions vs playlist discovery) was persisted
locally, so the header's "N without full history" link and the focus-channel
jump (subscribe notice, tag manager) could dump the user on the Discovery tab
while quietly applying a status/name filter that only affects the subscriptions
table — the targeted channel was there, just on the hidden tab.

Lift the tab state to App alongside the status filter it pairs with, and have
those navigation intents switch it back to "subscribed".
2026-06-19 03:38:57 +02:00
npeter83
00680cae30 perf(header): poll sync status faster while work is in flight
The header polled every 30s, so the live "syncing" state (now gated on a real
running job) was usually missed between ticks. Poll every 8s while a job is
running or channels are pending, easing back to 30s once everything's settled,
via react-query's functional refetchInterval.
2026-06-19 03:24:45 +02:00
npeter83
8c70d72e9a refactor(channels): share one ChannelLink + channelYouTubeUrl helper
The "avatar + name + open-on-YouTube" cell and the @handle-or-/channel/<id>
URL were copy-pasted across the channel manager, the discovery tab, the
subscribe notice and the player. Extract a single ChannelLink component
(optional in-app onView, middle-click opens YouTube) and a channelYouTubeUrl
helper, and route all four through them. Removes the NameCell / DiscoveryNameCell
duplication (the latter introduced with the discovery tab).
2026-06-19 03:22:10 +02:00
npeter83
795f21a1e1 Merge improvement/inbox-nav-links: navigation links on watched + scheduler notices 2026-06-19 03:07:35 +02:00
npeter83
65516dae62 feat(inbox): add navigation links to watched and scheduler notices
Extend the reload-safe meta+link pattern (introduced for "Subscribed") to
more inbox entries, so every notice that can point somewhere does:

- A "Marked watched" activity notice now offers "Find in feed" (jumping to
  the feed filtered to Watched + that channel), alongside Unwatch — matching
  what the "Hidden" notice already does. VideoWatchedMeta carries the
  channel, and locate() handles both hidden and watched.
- A scheduler job-completion notice (System) now offers "Open Scheduler",
  jumping to the dashboard.

Maintenance notices reference already-removed videos, so they stay
informational with nothing to navigate to.
2026-06-19 03:05:01 +02:00
npeter83
cbe3e7c9b5 fix(header): show sync activity only while a job is actually running
The header's sync indicator spun "fetching history" whenever any channel
still lacked full history — i.e. perpetually, even when the scheduler was
idle between its periodic runs. It now spins only while a channel-sync job
(backfill or RSS poll) is actually running; otherwise pending deep-history
work shows as the calm, static "N without full history" link, and recent
work queued for the next run shows a static "N queued". This makes the
header coherent with the Scheduler dashboard's live state.

Backend exposes running_job_ids() from the scheduler activity and a derived
sync_active flag on /api/sync/my-status.
2026-06-19 02:57:45 +02:00
npeter83
7fd335aa90 Merge bug/scheduler-progress-visibility: live progress for any scheduler job + parallel RSS polling 2026-06-19 02:45:36 +02:00
npeter83
cd026aaf79 fix(scheduler): show progress for any running job with reliable live updates
The dashboard now renders a progress bar for any running job — determinate
when counts are reported, an indeterminate "working" sliver otherwise — so a
scheduled run is as visible as a manual one (progress was never manual-only;
the wiring is shared, but only some jobs reported and the display gated on
counts).

Poll faster (1.5s) while any job runs, easing back to 4s when idle, derived
from the freshest data by react-query's functional refetchInterval. The
earlier React-state approach to this stalled the live updates (the row froze
on the first sampled value); useLiveQuery now accepts a function of the data.
Trilingual phase labels for the newly-reporting jobs.
2026-06-19 02:43:46 +02:00
npeter83
064018bc24 feat(notifications): link the subscribe activity to the channel
The "Subscribed on YouTube" inbox entry now names the channel and offers
two convenience links that survive a reload (driven by the typed payload,
not the live callback): "Channel manager" (jumps to the manager focused on
that channel) and "Open on YouTube". Wires the panel to the app's
focus-channel navigation; trilingual strings.
2026-06-19 02:16:50 +02:00
npeter83
a8ba66007f feat(channels): discover & subscribe to channels from playlists
Add a "Discover from playlists" tab to the Channel manager that lists
channels appearing in the user's playlists they don't subscribe to, with
a one-click Subscribe.

- GET /api/channels/discovery: local join (playlist_items -> videos ->
  channels) minus the user's subscriptions and their own channel. Enriches
  stub channels' metadata up front (title/thumbnail/subscriber count via the
  API key) so the user can judge a channel before subscribing; videos are
  not pulled (the scheduler picks those up).
- POST /api/channels/{id}/subscribe: write-scope gated, subscriptions.insert
  + local Subscription with the returned resource id.
- YouTubeClient.insert_subscription / get_my_channel_id.
- users.yt_channel_id (migration 0019) caches the user's own channel id so
  discovery can exclude it.
- Frontend: ChannelDiscovery DataTable, Channels tab toggle (persisted),
  api methods, trilingual strings. Subscribe ships a typed notification
  payload (ChannelSubscribedMeta) for the inbox to act on.
2026-06-19 02:16:42 +02:00
npeter83
989ac65474 fix(settings): size panel to the active tab + drop the native unload prompt
- Render only the active settings tab instead of stacking all four in one grid
  cell. The stack forced the whole panel to the tallest tab's (Account) height,
  leaving the short tabs (Appearance/Notifications) with dead space and a
  pointless scrollbar. Now the panel sizes to its actual content.
- Remove the beforeunload guard: it could only raise the browser's own native
  'Reload site?' prompt (no in-app dialog possible there), which we avoid.
  Unsaved prefs are local-only and revert to the saved server baseline on the
  next load, so a reload/close loses nothing. The in-app confirm still guards
  in-app navigation and browser Back.
2026-06-19 00:04:19 +02:00
npeter83
d24ff9c0f0 feat(settings): explicit Save/Discard for preferences with dirty tracking
Settings-page prefs (theme/scheme/dark-mode/list-view/perf/hints/font + the
notification settings) were each auto-saved to the server on every toggle via
fire-and-forget savePrefs().catch(() => {}) — silent on failure, and no
positive confirmation on success, so the user had zero feedback either way.

Make them a draft instead: changes apply locally for instant preview but
persist only on an explicit Save (or revert on Discard). App owns the live
draft + the last-saved baseline, computes dirty, and exposes a controller to
the panel. The panel shows a Save/Discard bar with 'Saving…' → 'Settings
saved' (auto-clearing) / 'Couldn't save' feedback. Leaving the page with
unsaved changes prompts a confirm (in-app nav + browser Back), and a
beforeunload guards reload/close. savePrefs is now idempotent so the Save
survives a transient gateway blip; failures surface via the connection-lost
status. Language & sidebar layout stay instant (edited outside this page).

New i18n keys settings.save.* / settings.unsaved.* in EN/HU/DE.
2026-06-18 23:59:40 +02:00
npeter83
3f2fb339d3 fix(web): only announce a state change after the server confirms it
The 'Marked watched' / 'Hidden' notices (and resolving a stale notice on
unwatch/unhide) fired synchronously, before the api.setState call resolved.
When the write failed — e.g. the API was unreachable — the optimistic card
override was rolled back in .catch, but the notice had already been emitted
and lingered, falsely claiming a change that never persisted.

Move all three branches into the .then() so the notice is produced only once
the server has actually applied the change; on failure the card reverts and
nothing is announced (the connection-lost status already explains why).
2026-06-18 23:27:59 +02:00
npeter83
a36e9b9124 feat(notifications): unify the two rail indicators into one inbox
Fold the client-side transient bell into the inbox page so there's a single
notification indicator. The inbox now has two groups — "System" (durable,
server-backed) and "Activity" (client-side events with their Unhide/Unwatch/
Find-in-feed actions) — and the nav badge sums both unread counts. The separate
rail bell (NotificationCenter) is removed.

Activity items get a per-item clear (X) alongside the global Clear all. Unhiding
or unwatching a video from anywhere — a card, the toast's Undo, or the inbox —
now quietly resolves the original "Hidden/Watched X" entry (no duplicate "Unhidden
X" toast, no stale entry with a dead action), via a new resolveVideo store helper.
2026-06-18 04:37:20 +02:00
npeter83
51a8b3f24f feat(scheduler): admin-tunable maintenance re-validation batch size
Expose the maintenance job's rolling re-validation batch (videos re-checked per
run) as an admin control on the Scheduler dashboard, stored in app_state
(migration 0018; NULL = the env/config default). The job reads the effective
value each run via a state helper, clamped to a sane range. Trilingual.
2026-06-18 04:37:08 +02:00
npeter83
edafc16dac fix(nav): numeric inbox badge with active-state contrast
Show the unread count as a number on the collapsed nav rail (not just a dot), and
invert the badge colours on the active row so it isn't an accent-on-accent red blob;
centre the number on the circle.
2026-06-18 04:01:19 +02:00
npeter83
5988769cda feat(scheduler): admin run-now/run-all triggers + live progress + completion notices
Add per-job "Run now" buttons and a "Start all now" button to the admin Scheduler
dashboard (admin-gated endpoints). Triggers run the job in a background thread
independent of its interval, refusing a concurrent run (409). While running, the
long jobs (maintenance, enrich, backfill, shorts) report live progress through a
decoupled contextvar sink, shown as a progress bar on the job row via the existing
4s poll. A manually-triggered run posts a completion notification to the triggering
admin's inbox (scheduled runs stay silent to avoid spam); the inbox renders the
"scheduler" type trilingually from type+data. While here, give the maintenance job
its missing dashboard label/description in all three languages.
2026-06-18 04:01:10 +02:00
npeter83
3ae42409b3 feat(notifications): durable per-user inbox (P1) + maintenance schema
Add a server-backed notification center that coexists with the client-side
transient bell: a per-user `notifications` table (type/title/body/data JSON/
read/dismissed), a `/api/me/notifications` CRUD API (list, unread_count, read,
read_all, dismiss, clear), and a left-nav inbox module with a live unread badge
polled via useLiveQuery. Known types render trilingual text from type+data
(English stored text is the fallback); read rows are trimmed past a soft cap.

Also adds the schema the maintenance job builds on: videos.list?part=status
columns (embeddable/privacy_status/upload_status) and the validation lifecycle
columns (last_checked_at, unavailable_since, unavailable_reason).

Page-id validation is centralized in one PAGES source of truth (isPage) so the
new page survives reload without a second allowlist to keep in sync.
2026-06-18 03:20:17 +02:00
npeter83
d33a109ea2 feat(tags): tag UX overhaul + per-card video reset
- Channel-row tags show only what's attached; a '+' opens a per-channel picker. Clicking a tag
  filters the feed by it; a 'Your tags' sidebar widget makes that filter visible/clearable.
- Tag manager dialog (add/rename/delete; delete only confirms when the tag is in use), reachable
  from the Channel manager and the feed sidebar; hovering a tag's count lists its channels, each a
  link that focuses it in the Channel manager (DataTable gains an external filter input).
- Video cards get a reset action that clears all watch state (incl. an in-progress position).
- api.req() now raises the error dialog on 5xx and 400/409/422 with the server's reason.
2026-06-18 01:17:31 +02:00
npeter83
14b8eb6084 fix(ux): modal error dialog for server-refused actions + ESC closes only the topmost
- Duplicate-tag create/rename hit the uq_tags_user_name constraint and 500'd; now caught and
  returned as a clear 409 ('You already have a tag named …').
- New global error dialog (errorDialog store + ErrorDialog modal) for definitive server refusals;
  the api layer wiring + mount land with the rest of the round.
- Modal now keeps a stack so ESC dismisses only the top modal — an error over the tag editor
  closes the error and leaves the editor open.
2026-06-18 01:17:31 +02:00
npeter83
cd28287968 feat(channels): aggregate columns + smarter tag display
- Add Last upload, total Length and a Normal/Shorts/Live breakdown, computed on read in the
  existing grouped query (~35ms over the full catalog — measured, so no denormalization).
- Subscribers shown compact (919K), since the YouTube API already rounds them.
- Tag cell now shows only the tags actually attached to a channel; a '+' opens a per-channel
  picker to attach/detach, instead of listing every user tag on every row.
2026-06-17 23:24:25 +02:00
npeter83
c000ea8bce feat(ui): content-driven column widths in DataTable
Columns auto-size to their content (table-layout: auto) via a per-column 'nowrap' flag;
headers never wrap. Replaces hand-tuned fixed widths so values like 'N / S / L' no longer
wrap, and callers give a min-width only where a column must not collapse.
2026-06-17 23:24:25 +02:00
npeter83
cab700bca5 feat(channels): data-table channel manager
Replace the stacked card rows with the reusable DataTable: sortable columns, in-header
Channel/Tags filters, status chips + page controls in one row, wider layout. Tag toggle
is now optimistic (no full refetch). Sync column collapses to one 'fully synced' chip;
Actions column carries hide/unsubscribe + a backfill control (admin reset / per-user
full-history opt-in). Channel status filter persists across reloads.
2026-06-17 19:16:23 +02:00
npeter83
123b024b19 feat(ui): reusable DataTable component
Generic client-side table: per-column sort, in-header filters (text/select/multi),
built-in pagination with a user-set page size (incl. All) and an editable jump-to-page
box. Sort/filter/page/size persist to localStorage (survive F5). Falls back to a compact
card list below md. Intended for reuse across modules (channels now, playlists next).
2026-06-17 19:16:23 +02:00
npeter83
6b2525353a fix(settings): persist the active tab across reloads
The Settings tab was local React state, so F5 always snapped back to Appearance.
Persist it in localStorage (siftlode.settingsTab), validated against the tab list.
2026-06-17 14:30:56 +02:00
npeter83
1fb34b52d2 feat(toast): surface toasts bottom-left by the bell, brighter dark rim
Toasts rose top-right, far from the notification bell which now lives bottom-left.
Anchor them bottom-left inside the content column (clears the sidebar at any width),
newest nearest the bell. Add a ~50% white border in dark mode so they stand out off
the conventional top-right spot.
2026-06-17 14:28:29 +02:00
npeter83
df0ca24a23 feat(nav): group rail modules + move chrome controls into the sidebar
- Split the rail into a content group (Feed/Channels/Playlists) and an admin
  group (Stats/Scheduler) separated by a divider (system group hidden for non-admins).
- Move the language switcher, About and notification bell out of the top header
  into an icon cluster above Settings (horizontal expanded, vertical collapsed).
  Their popovers portal to <body> and anchor right + above the button, escaping the
  nav's backdrop-filter. About is removed from the account popover (now in the cluster).
- LanguageSwitcher/NotificationCenter gain a 'rail' variant for the above.
- Also relocate the Toaster mount into the (now relative) content column.
2026-06-17 14:28:29 +02:00
npeter83
ea2b9842e1 fix(stats): keep daily usage bars always visible
Each day now has a full-height track behind a solid accent fill, so low-usage
days no longer render as a near-invisible sliver that only stood out on hover.
2026-06-17 13:55:43 +02:00
npeter83
c74ef1e632 feat(playlists): move the new-playlist input to the top of the rail 2026-06-17 13:55:43 +02:00
npeter83
c010abd5a3 feat(channels): YouTube link on channel name + done checkmarks
- Channel name: middle-click opens the channel's YouTube page in a new tab
  (left-click still opens the in-app detail) plus an explicit external-link icon.
- recent/full sync badges show a check icon when the state is reached.
- New i18n key channels.row.openOnYouTube (HU/EN/DE).
2026-06-17 13:55:43 +02:00
npeter83
940b616804 fix(player): persist watched at end-of-playback (atomic upsert)
The in-app player marks a video watched when it reaches the end, firing
POST /videos/{id}/state at the same instant as a progress checkpoint. The
progress endpoint deletes the 'new' video_states row near the end, so the
concurrent state UPDATE matched 0 rows and raised StaleDataError -> 500. The
watched status was never persisted (and Feed swallowed the error), so the
video stayed in the unwatched feed even after a refresh.

- set_video_state: atomic INSERT ... ON CONFLICT (uq_user_video) DO UPDATE for
  watched/hidden, immune to a concurrent delete; tolerate StaleDataError on the
  'new' branch and in the progress endpoint.
- PlayerModal: skip the redundant near-end progress checkpoint when we just
  auto-marked watched, removing the self-inflicted race.
- Feed: drop the optimistic override if the server rejects the change, so a
  failed request can't leave a card phantom-hidden.
2026-06-17 13:38:47 +02:00
npeter83
8cd2e6868a feat(scheduler): per-job help tooltips, status legend, inline interval edit
Each job shows a tooltip (what it does + what happens if it stops), a status
dot legend + per-dot tooltips clarify the colours, and the interval is inline-
editable (pencil -> number -> save) wired to the new PATCH endpoint. HU/EN/DE.
2026-06-16 15:58:23 +02:00
npeter83
db9ee4beab feat(scheduler): live admin Scheduler dashboard + reusable poll hook (HU/EN/DE)
New admin Scheduler page (left-nav entry) with a live, self-refreshing view of
job activity, queued work and quota. Polling is factored into a reusable
useLiveQuery hook (pauses when the tab is unfocused) that the notification bell
and future yt-dlp job queue will reuse instead of re-implementing.
2026-06-16 14:38:51 +02:00
npeter83
68fb3c517a fix(player): make the unavailable overlay fully opaque
The fallback used a translucent background and left the iframe mounted, so
YouTube's own 'Video unavailable' screen bled through and overlapped our
message. Use a solid background and hide the iframe while the error shows.
2026-06-16 11:58:57 +02:00
npeter83
ef4bdee1f7 fix(player): friendly fallback when a video can't be embedded
The in-app IFrame player showed YouTube's bare 'Video unavailable' screen
with no way out when a video couldn't be embedded (auto-generated Topic
art-tracks disable embedding -> error 101/150; removed/private -> 100).
Catch the IFrame onError and overlay a clear message + 'Open on YouTube'
button, with a tailored note for embedding-disabled videos.
2026-06-16 10:45:57 +02:00
npeter83
66049d9eca improvement(demo): permanent banner instead of a re-popping toast
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.
2026-06-16 10:12:10 +02:00
npeter83
ed941904a6 feat(card): show LIVE/upcoming badge where duration is absent
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.
2026-06-16 09:36:51 +02:00
npeter83
cac0ff7489 fix(demo): graceful UX when YouTube actions are unavailable
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.
2026-06-16 09:27:34 +02:00
npeter83
281062fe33 feat(demo): login trigger, feature gating + admin UI (HU/EN/DE)
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.
2026-06-16 09:17:34 +02:00
npeter83
4f1e33e3cc feat(player): exact upload date inline in the player meta too
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.
2026-06-16 03:09:28 +02:00
npeter83
d6693d2c60 feat(card): show the exact upload date inline after the relative time
There's room on the meta line, so append the precise date (locale-aware, e.g. '12 Jan
2017') after '9 yr ago' instead of hiding it in a tooltip.
2026-06-16 03:04:50 +02:00
npeter83
c41fa938ff feat(card): exact view count on hover (disambiguate 9B vs 98)
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.
2026-06-16 02:57:53 +02:00
npeter83
f72eee2583 feat(feed): Show chips in the toolbar + key+direction sort
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.
2026-06-16 02:46:26 +02:00
npeter83
1d5301509c feat(feed): 1D — content-type chips + sort in a toolbar above the cards
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).
2026-06-16 02:34:13 +02:00
npeter83
6300550301 feat(auth): N3 — server-side multi-session account switch
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.
2026-06-16 02:05:38 +02:00