Every download now records the clean source page URL and shows it on the
Downloads page (open in a new tab, or copy to clipboard). The worker stores
yt-dlp's canonical webpage_url on the asset (migration 0043 adds
media_assets.source_webpage_url); the serializer prefers it and falls back to a
URL derived from source_kind+source_ref, so YouTube, external YouTube links and
external URLs (e.g. Facebook reels) all get a correct reference, and queued/older
rows work before the worker fills it. Edit clips return null (a clip's source is
the user's own earlier download, not a web page). i18n en/hu/de.
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
DownloadDialog (DownloadButton — kept out of the feed chunk), and the
VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
callers can pre-select the admin tab without statically importing the now
lazy-loaded AdminUsers page (which would defeat the split).
Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
Give the feed card thumbnail width/height (16:9) so the browser reserves the
aspect box before load (CLS), and decoding=async so image decode never blocks
the main thread. loading=lazy was already set.
Lighthouse a11y across the authenticated module pages flagged: the clickable
Siftlode logo had aria-label='Feed' (accessible name didn't include its visible
text); the shared Switch, theme swatches, sort selects and several number/range
inputs had no accessible name/label. Give Switch role='switch'+aria-checked and
an optional label (passed at every call site), aria-label the swatches, selects
(feed/playlists sort) and the settings/config/scheduler inputs, and drop the
mismatched logo aria-label so its visible text is the name. All 11 module pages
now score 100 accessibility (settings 86->100, playlists 89->100, others 92-95->100).
The 3 landing screenshots were 2.3 MB of PNG (feed.png alone 1.6 MB, decoded
at 2400x1350 for a ~400px slot) — the biggest LCP/transfer cost on the public
page. Re-encoded to WebP capped at 1600px (~284 KB total, feed 91% smaller).
Also set Cache-Control: content-hashed /assets/* are immutable for a year,
other SPA-root static files (welcome images, favicon, robots) get a 7-day TTL;
index.html stays no-cache. Register image/webp+avif mimetypes so FileResponse
serves the right Content-Type. Lighthouse (dev): Perf 80->93, LCP 1.8s->1.2s.
Session-close cosmetics:
1. Favicon — an 'S' monogram (indigo→violet rounded square, path-drawn so it's font-independent)
at public/favicon.svg, linked in index.html (tab had none before).
2. Dynamic document.title — reflects the current module ('Downloads · Siftlode'); Feed = brand
only; an open channel page shows the channel name. Shared pageTitleKey() in lib/pageMeta.ts
keeps the tab title and the top-bar header in lockstep.
3. The 'Siftlode' logo already navigated to Feed but read as static text — added a hover
background + cursor affordance so it's clearly clickable.
4. Module header — new shared PageTitle component (used by every non-feed module via Header) with
a tracked small-caps 'eyebrow' + leading accent dot (user-picked style B), styled in one place.
Verified in a real browser: favicon served (image/svg+xml), title updates per module, logo→feed,
header restyled; no console errors.
The 'Your storage' footprint used a one-shot query, refetched only at enqueue time — when the job
is still queued (0 bytes) and the file lands asynchronously via the worker. So the footprint stayed
stale (0 B) until a manual reload. Switch the UsageBar to useLiveQuery(2s), like the library list,
so the footprint updates in lockstep the moment a clip/download completes (and after a delete).
A shared-with-me item only had a download button. Add two actions (Share is intentionally NOT
offered — no chain re-sharing of someone else's file):
- Edit: the editor now accepts an accessible (owned OR shared) source, so editing a shared video
produces the editor's OWN clip in their library (counts against their quota, fully theirs
including share); the source file is only read. Route uses _accessible_job.
- Remove from my list: DELETE /api/downloads/shared/{job_id} deletes only the recipient's share
grant — the owner's job and physical file are untouched (per-user dismissal).
i18n en/hu/de. Verified in a real browser (edit a shared 70-min video → own 2:31 clip; remove
confirm shows 'won't delete the owner's file').
The admin System tab only listed users with an existing footprint, so a quota could be set only
after someone downloaded something. Add a 'Set a user's quota…' picker (over /api/admin/users,
excl. demo) beside the Per-user footprint heading that opens the quota editor for ANY user — the
quota GET/PUT endpoints already resolve defaults + create a row on demand, so no backend change.
Also show an empty-state line when no one has a footprint yet. i18n en/hu/de.
The download-center GC job showed its raw id 'download_gc' with a raw-key tooltip, unlike every
other job. Add its label ('Download cleanup') + description to scheduler i18n (en/hu/de), and make
the job tooltip fall back to empty for any job lacking a description (so a future/edge job never
shows a raw 'scheduler.jobDesc.*' key — also fixes demo_reset).
The tooltip clamped its centre to a fixed 92px margin, but the caption is up to 240px wide
(120px half), so a left-edge anchor (e.g. the nav-rail counts icon at x~40) pushed the centred box
~28px off the left of the viewport. Clamp with the caption's max half-width so it can't overflow on
first paint, then a useLayoutEffect re-centres on the anchor using its actual width. Verified: the
nav-rail counts tooltip now sits fully on-screen.
In the left-nav rail SyncStatus, the pause button rendered as its own bottom block, so with only
deep-history pending (idle) it dropped to a lonely line under 'N without full history'. Now it sits
right-aligned on the primary status row — the sync-state line when there is one, otherwise the
'N without full history' row — via flex justify-between. No change to the all-synced state.
Rework the share dialog into two clear modes and add the public /watch player page:
- ShareDialog: (A) 'Share with a user' — autocomplete picker over registered users (was a blind
email box that 404'd on non-users); (B) 'Share a link' — create/list/copy/revoke public links
with allow-download toggle, optional expiry (1/7/30d), optional password; per-link view count.
- WatchPage: standalone login-free player at /watch/<token> (routed in main.tsx like /privacy),
self-contained mini-i18n (en/hu/de by browser language); password gate → unlock → play; shows a
Download button only when the link allows it.
- api: ShareLink/ShareRecipient types + link CRUD + recipients; share i18n (en/hu/de).
Verified end-to-end in a real browser: user picker, link create, public playback, stream-only vs
downloadable, password gate + unlock, no console errors.
The hover-scrub thumbnail was centered on the cursor with a fixed -translate-x-1/2, so near the
filmstrip's right (or left) edge it overflowed the modal and triggered a horizontal scrollbar. Now
its left edge is clamped to [0, trackW - popoverW] so it shifts inward at the edges and stays fully
within the viewport. Verified at both edges in a real browser.
Rework VideoEditor around a segment cut-list: N draggable cut markers → segments, per-segment
keep/drop (eye toggle + dropped hatch), output = Separate files (one job per kept segment) or
Join into one (segments cut-list → one concatenated file). Per-segment numeric Start/End inputs.
Filmstrip fixed: aspect-correct tiles (no vertical squish) + YouTube-style hover-scrub thumbnail
from the sprite. EditSpec.segments type; editor v2 i18n (en/hu/de). Verified end-to-end in a real
browser (3-segment split, drop middle, join → 0:12 clip from a 0:19 source).
VideoEditor modal on a finished Library download: HTML5 <video> scrubber, filmstrip timeline
(lazy server storyboard sprite) with draggable in/out handles, draggable/resizable crop overlay,
per-edit Precise (re-encode) vs Fast (stream-copy) cut toggle, split-into-N fan-out (N trim jobs),
optional clip name. Edited clips show a 'Clip' badge; 'editing' phase gets a % bar. New api
enqueueEdit/downloadStoryboard/storyboardImageUrl + EditSpec type; editor i18n (en/hu/de).
ffmpeg post-steps have no byte-progress, so instead of a silent 'Processing' the row now names
the current step (Merging / Extracting audio / Embedding thumbnail / Removing sponsors / Writing
metadata) with an indeterminate pulse. Byte-progress phases (video/audio) keep the % bar.
i18n en/hu/de.
The progress bar looked broken (95% -> 5% -> recount) because yt-dlp downloads the video and
audio streams separately (each 0->100%) then merges — with no indication of which step is
running. Now:
- worker labels the phase from the stream codecs (video / audio) and via a postprocessor hook
(merging / processing), so the user sees what's happening
- DownloadCenter shows the phase as the status and renders an indeterminate pulse (no bogus %)
during merge/processing; phase i18n en/hu/de
- yt-dlp retries (3) + fragment_retries (5) so transient network blips self-heal instead of
failing the job (seen once as a DNS 'No address associated with hostname' error)
Verified: phase transitions queued -> video -> processing -> done.
Add an accent-tinted 'Free and open source' band between the app preview and the feature grid
— a short line about reading the code / self-hosting / filing an issue, linking to the public
repo. The compact footer link stays. Trilingual.
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
Feed filters and the default-saved-view mirror lived under shared localStorage keys, so one
account's view (incl. its starred default) leaked into another account signed into the same
browser — visible with per-tab accounts as a fresh account showing the previous account's
'N active' filters instead of a clean default.
Key both by the tab's active account (siftlode.filters.<id> / siftlode.defaultViewFilters.<id>);
load a tab's filters from its own account on login/switch/add, falling back to defaults when the
account isn't known yet. A share link's filters still win and are persisted to the account. The
old shared keys are simply no longer read (a one-time reset to defaults on the first load after
this change; migrating them would risk re-leaking across accounts).
A tab that never explicitly picked an account rode the session's default, so when ANOTHER
tab changed that default (adding or switching an account), this tab silently swapped identity
on its next refetch — the account chip updated while the feed/saved-views lagged until reload.
- Pin every tab to whatever account it first loaded as (write the sessionStorage override on
load when none is set), so its requests always carry its own account header and cross-tab
default changes can't reach it.
- 'Add account' now clears this tab's pin before the Google redirect, so on return the tab
adopts the freshly-added account (and pins that) — the current tab switches to it while other
tabs keep their own, matching the Gmail add-account model.
Two tabs in one browser can now run two different signed-in accounts at once.
- The signed session cookie stays the browser's account WALLET (account_ids). Which account a
given tab acts as is a per-tab choice held in sessionStorage and sent per-request via the
X-Siftlode-Account header; current_user honours it only for an account already in the wallet,
without mutating the cookie's default account. Switching accounts sets the header + reloads
THIS tab only, instead of the old cookie-wide switch that changed every tab.
- WebSocket can't send headers, so the per-tab account rides in the ?account= query param
(validated against the wallet).
- Logout is per-tab aware: it signs the requesting tab's active account out of the wallet
(promoting a new default only if the removed one was the default), and the tab drops its
override. A stale per-tab header account 401s just that tab instead of clearing the session.
- Serve index.html with Cache-Control: no-cache so a deploy's new hashed bundle is picked up
immediately instead of the browser running a heuristically-cached stale index.html.
- Filter header: show the active-filter count as a compact number pill instead of
'{n} active' text, and keep the action buttons on one line (shrink-0 + nowrap) so
nothing truncates or wraps in a narrow / zoomed-in sidebar (e.g. 125% browser zoom).
- Sync status: anchor the 'all synced' state with a small check icon so it no longer
reads as orphaned text at the top of the nav rail.
Restructure the app shell into three top-level columns:
- The per-user sync status (video counts + live sync state) moves from the top bar to a
compact block at the top of the left nav rail (icon-only with a tooltip when collapsed).
- The feed's Mine/Library scope toggle moves to the top of the filter sidebar.
- The filter sidebar becomes a full-height sibling column with its own collapse control
(a thin rail carrying the active-filter count), mirroring the nav rail. The top bar is
now just the feed search / page title.
- Both panels' collapsed state is persisted to the user's preferences (server-side, so it
follows the account across devices), seeded from a localStorage cache to avoid a flash.
Default: both panels open.
The icon-only rail switcher now shows the active language code as a small corner badge,
so the current language reads at a glance without opening the menu.
- A clearer 'Admin' section header (thicker centred rule when collapsed) above the
admin-only modules (Scheduler/Configuration/Users), replacing the faint hairline.
- A small role chip (admin/user/demo) next to the account name — an avatar-corner dot
when the rail is collapsed.
- 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.
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.
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).
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.
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).
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.
- 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.
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.
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.
- 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.
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.
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.
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).
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.
- 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.
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.
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.
- 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.
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.
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).
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.