Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.
S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.
Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.
Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
navigator.clipboard.writeText is intermittent on iOS Safari (bug 9: the
download share-link copy 'often does neither'). Add lib/clipboard.ts
copyText(): a gesture-safe execCommand path FIRST (before any await can
spend the transient user activation), async Clipboard API as fallback.
Migrate all five copy call sites to it.
Pick any number of channels; they OR together and every other filter applies
across the lot — "the last week's unwatched from these three".
`channelId`/`channelName` are replaced outright by `channelIds: string[]`, no
legacy reader (standing rule: no back-compat unless asked — a saved view written
against the old field simply loses its channel filter). The wire and the share
URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via
`Video.channel_id.in_()`; the count endpoint shares the same params object.
Two things the migration exposed:
- SavedViewsWidget fed DB blobs straight into serializers that now index an
array — the FeedFilters type says nothing about what an older build wrote, so
the blob is coerced where it enters. It white-screened the app before this.
- Chips carried channelName to label themselves. Without it they resolve names
from the channel cache, so they now fall back to the id, not a shared
"This channel" that would render N identical chips while the cache loads.
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 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.