refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a)

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.
This commit is contained in:
2026-07-27 21:48:35 +02:00
parent d0aa1cf9a7
commit ad86d6d126
19 changed files with 169 additions and 107 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api"; import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout"; import ChannelAboutContent from "./ChannelAbout";
@@ -24,7 +25,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null); const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
const { data: ch } = useQuery({ const { data: ch } = useQuery({
queryKey: ["channel", channelId], queryKey: qk.channel(channelId),
queryFn: () => api.channelDetail(channelId), queryFn: () => api.channelDetail(channelId),
enabled: open && !!channelId, enabled: open && !!channelId,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
+5 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
@@ -28,7 +29,7 @@ export default function AddToPlaylist({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const membership = useQuery({ const membership = useQuery({
queryKey: ["playlist-membership", videoId], queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId), queryFn: () => api.playlists(videoId),
enabled: open, enabled: open,
}); });
@@ -75,11 +76,11 @@ export default function AddToPlaylist({
}, [open]); }, [open]);
function refresh() { function refresh() {
qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
// Also invalidate every open/cached playlist detail (["playlist", id]) so the // Also invalidate every open/cached playlist detail (["playlist", id]) so the
// Playlists page reflects the add/remove when navigated to, not only after F5. // Playlists page reflects the add/remove when navigated to, not only after F5.
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: qk.playlist() });
} }
async function toggle(pl: Playlist) { async function toggle(pl: Playlist) {
+5 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { UserPlus } from "lucide-react"; import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api"; import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage"; import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
@@ -52,7 +53,7 @@ export default function ChannelDiscovery({
}; };
const query = useQuery({ const query = useQuery({
queryKey: ["discovered-channels"], queryKey: qk.discoveredChannels(),
queryFn: api.discoveredChannels, queryFn: api.discoveredChannels,
}); });
@@ -61,9 +62,9 @@ export default function ChannelDiscovery({
onSuccess: (_data, c) => { onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start // The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status. // arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: ["discovered-channels"] }); qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
const name = c.title ?? c.id; const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the // Name the channel and ship a typed payload so the inbox can offer "open in the
// Channel manager" / "open on YouTube" links — kept after reload, when live // Channel manager" / "open on YouTube" links — kept after reload, when live
+15 -14
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { import {
ArrowLeft, ArrowLeft,
@@ -73,7 +74,7 @@ export default function ChannelPage({
const [bannerAttempt, setBannerAttempt] = useState(0); const [bannerAttempt, setBannerAttempt] = useState(0);
const detail = useQuery({ const detail = useQuery({
queryKey: ["channel", channelId], queryKey: qk.channel(channelId),
queryFn: () => api.channelDetail(channelId), queryFn: () => api.channelDetail(channelId),
}); });
const ch = detail.data; const ch = detail.data;
@@ -91,10 +92,10 @@ export default function ChannelPage({
.exploreChannel(channelId, token ?? undefined) .exploreChannel(channelId, token ?? undefined)
.then((r) => { .then((r) => {
setNextToken(r.next_page_token); setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the // Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran). // first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
}) })
.finally(() => setExploring(false)); .finally(() => setExploring(false));
}, },
@@ -116,9 +117,9 @@ export default function ChannelPage({
const block = useMutation({ const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)), mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["blocked-channels"] }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
}, },
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case), // No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) — // and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
@@ -128,12 +129,12 @@ export default function ChannelPage({
const subscribe = useMutation({ const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId), mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move. // Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["discovered-channels"] }); qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
}, },
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle // A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
@@ -143,9 +144,9 @@ export default function ChannelPage({
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId), mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
}, },
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
}); });
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { api, type FeedFilters } from "../lib/api"; import { api, type FeedFilters } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
@@ -25,7 +26,7 @@ export default function ChannelPicker({
// Same edge-fade affordance the nav rail and the panel body use to hint at more content. // Same edge-fade affordance the nav rail and the panel body use to hint at more content.
const fade = useScrollFade(); const fade = useScrollFade();
const channelsQuery = useQuery({ const channelsQuery = useQuery({
queryKey: ["channels"], queryKey: qk.channels(),
queryFn: api.channels, queryFn: api.channels,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
+27 -26
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
@@ -106,27 +107,27 @@ export default function Channels() {
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}; };
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels }); const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
const [tagManagerOpen, setTagManagerOpen] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({ const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id), mutationFn: (id: string) => api.unblockChannel(id),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["blocked-channels"] }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
}, },
}); });
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may // FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
// already have videos in the shared catalog, so refresh the feed too (not just the manager). // already have videos in the shared catalog, so refresh the feed too (not just the manager).
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
}; };
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
@@ -139,10 +140,10 @@ export default function Channels() {
// Requesting full history may have just pulled in a channel's recent uploads, so // Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list. // refresh the per-user status and feed too — not only the channel list.
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
}, },
}); });
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list
@@ -150,18 +151,18 @@ export default function Channels() {
// lose your scroll position). The new order takes effect next time the page loads. // lose your scroll position). The new order takes effect next time the page loads.
const bumpPriority = (id: string, current: number, delta: number) => { const bumpPriority = (id: string, current: number, delta: number) => {
const next = current + delta; const next = current + delta;
qc.setQueryData<ManagedChannel[]>(["channels"], (old) => qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
); );
api api
.updateChannel(id, { priority: next }) .updateChannel(id, { priority: next })
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); .catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
}; };
// Tagging is updated optimistically in place (no refetch of the whole channel list, which // Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. // made the toggle feel ~2s slow); only revert by refetching if the server call fails.
const toggleTag = (id: string, tagId: number, hasTag: boolean) => { const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
qc.setQueryData<ManagedChannel[]>(["channels"], (old) => qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) => (old ?? []).map((ch) =>
ch.id === id ch.id === id
? { ? {
@@ -172,7 +173,7 @@ export default function Channels() {
), ),
); );
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
}; };
const syncSubs = useMutation({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
@@ -188,8 +189,8 @@ export default function Channels() {
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
}, },
onError: (e) => onError: (e) =>
@@ -198,8 +199,8 @@ export default function Channels() {
const resetBackfill = useMutation({ const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
}, },
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
@@ -207,8 +208,8 @@ export default function Channels() {
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => { onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
@@ -555,7 +556,7 @@ export default function Channels() {
resetPage, resetPage,
} = useCardPager(sortedChannels, LS.channelCardSize, isCards); } = useCardPager(sortedChannels, LS.channelCardSize, isCards);
// Changing what you're looking at starts over at the first page, the way DataTable resets its own // Changing what you're looking at starts over at the first page, the way DataTable resets its own
// page on a filter/sort change. Deliberately NOT keyed on the row count: the ["channels"] query // page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would // refetches on window focus and after every sync, so a catalogue that merely gained a channel would
// otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is // otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is
// handled by the hook's clamp instead. // handled by the hook's clamp instead.
+12 -11
View File
@@ -6,6 +6,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
@@ -155,7 +156,7 @@ export default function Feed({
const queryFilters: FeedFilters = { ...filters, q: debouncedQ }; const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["feed", queryFilters], queryKey: qk.feed(queryFilters),
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE), queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null, initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined, getNextPageParam: (last) => last.next_cursor ?? undefined,
@@ -168,7 +169,7 @@ export default function Feed({
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a // retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
// revisited search from re-spending. // revisited search from re-spending.
const ytQuery = useInfiniteQuery({ const ytQuery = useInfiniteQuery({
queryKey: ["yt-search", ytSearch, ytCount], queryKey: qk.ytSearch(ytSearch, ytCount),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount), api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
initialPageParam: null as string | null, initialPageParam: null as string | null,
@@ -225,7 +226,7 @@ export default function Feed({
}, [query.dataUpdatedAt]); }, [query.dataUpdatedAt]);
const countQuery = useQuery({ const countQuery = useQuery({
queryKey: ["feed-count", queryFilters], queryKey: qk.feedCount(queryFilters),
queryFn: () => api.feedCount(queryFilters), queryFn: () => api.feedCount(queryFilters),
staleTime: 30_000, staleTime: 30_000,
enabled: !ytActive, enabled: !ytActive,
@@ -250,7 +251,7 @@ export default function Feed({
api api
.setState(id, status) .setState(id, status)
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
if (status === "hidden") { if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id); const v = loadedRef.current.find((x) => x.id === id);
notify({ notify({
@@ -307,7 +308,7 @@ export default function Feed({
setOverrides((o) => ({ ...o, [id]: "new" })); setOverrides((o) => ({ ...o, [id]: "new" }));
api api
.clearState(id) .clearState(id)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] })) .then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch(() => .catch(() =>
setOverrides((o) => { setOverrides((o) => {
const next = { ...o }; const next = { ...o };
@@ -324,8 +325,8 @@ export default function Feed({
setSavedOverrides((o) => ({ ...o, [id]: saved })); setSavedOverrides((o) => ({ ...o, [id]: saved }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: qk.playlist() });
}) })
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
}, },
@@ -355,9 +356,9 @@ export default function Feed({
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale // A live search ingests new catalog videos, so the disabled feed queries still hold their stale
// pre-search cache. Drop them so the feed re-fetches fresh on return. // pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => { const dropFeedCaches = () => {
qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: qk.feed() });
qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: qk.feedCount() });
qc.removeQueries({ queryKey: ["facets"] }); qc.removeQueries({ queryKey: qk.facets() });
}; };
// --- Live YouTube search mode ------------------------------------------------------------- // --- Live YouTube search mode -------------------------------------------------------------
@@ -435,7 +436,7 @@ export default function Feed({
setFilters({ ...filters, librarySource: "organic", sort: "newest" }); setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch(); onExitYtSearch();
dropFeedCaches(); dropFeedCaches();
qc.removeQueries({ queryKey: ["yt-search"] }); qc.removeQueries({ queryKey: qk.ytSearch() });
}} }}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
> >
@@ -1,6 +1,7 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
Activity, Activity,
Bell, Bell,
@@ -107,8 +108,8 @@ export default function NotificationsPanel() {
api api
.setState(meta.videoId, "new") .setState(meta.videoId, "new")
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible). // Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId); resolveVideo(meta.videoId);
}) })
+6 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react"; import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding"; import { beginGrant, endOnboarding } from "../lib/onboarding";
@@ -63,7 +64,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
// Once we have read access, find out whether any subscriptions are imported yet. // Once we have read access, find out whether any subscriptions are imported yet.
const myStatus = useQuery({ const myStatus = useQuery({
queryKey: ["my-status"], queryKey: qk.myStatus(),
queryFn: api.myStatus, queryFn: api.myStatus,
enabled: me.can_read, enabled: me.can_read,
}); });
@@ -72,10 +73,10 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
onSuccess: () => { onSuccess: () => {
// Feed/channels/status now have content — refresh the app behind the wizard. // Feed/channels/status now have content — refresh the app behind the wizard.
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
}, },
}); });
+4 -3
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
@@ -479,7 +480,7 @@ export default function PlayerModal({
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150); closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
}; };
const detail = useQuery({ const detail = useQuery({
queryKey: ["video-detail", currentVideoId], queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId), queryFn: () => api.videoDetail(currentVideoId),
// On hover (for the description) and eagerly when navigated, so we can link the // On hover (for the description) and eagerly when navigated, so we can link the
// linked video's channel. Both share the one cached result. // linked video's channel. Both share the one cached result.
@@ -767,8 +768,8 @@ export default function PlayerModal({
window.removeEventListener("pagehide", onHide); window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session. // Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => { Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: qk.playlist() });
}); });
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.destroy === "function") { if (p && typeof p.destroy === "function") {
+12 -11
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -171,8 +172,8 @@ export default function Playlists() {
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state, // Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
// and the Reset/Unsynced indicators, show without an F5). The membership guard // and the Reset/Unsynced indicators, show without an F5). The membership guard
// keeps the refetch from wiping the undo history on a pure reorder. // keeps the refetch from wiping the undo history on a pure reorder.
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
}) })
.catch((e) => { .catch((e) => {
// Was a bare .then(): a failed reorder left the UI showing an order the server had // Was a bare .then(): a failed reorder left the UI showing an order the server had
@@ -182,7 +183,7 @@ export default function Playlists() {
// the arriving detail is judged "same list" and the rejected order stays on screen — and // the arriving detail is judged "same list" and the rejected order stays on screen — and
// becomes the base for the next drag. // becomes the base for the next drag.
lastSetRef.current = ""; lastSetRef.current = "";
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
}); });
}, },
}); });
@@ -201,14 +202,14 @@ export default function Playlists() {
const [playingId, setPlayingId] = useState<string | null>(null); const [playingId, setPlayingId] = useState<string | null>(null);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["playlists"], queryKey: qk.playlists(),
queryFn: () => api.playlists(), queryFn: () => api.playlists(),
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
const playlists = listQuery.data ?? []; const playlists = listQuery.data ?? [];
const detailQuery = useQuery({ const detailQuery = useQuery({
queryKey: ["playlist", selectedId], queryKey: qk.playlist(selectedId),
queryFn: () => api.playlist(selectedId as number), queryFn: () => api.playlist(selectedId as number),
enabled: selectedId != null, enabled: selectedId != null,
// Refetch when returning to the tab so a change made elsewhere (another tab/device) // Refetch when returning to the tab so a change made elsewhere (another tab/device)
@@ -331,8 +332,8 @@ export default function Playlists() {
const plName = (p: { kind: string; name: string }) => playlistName(p, t); const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll() { function refreshAll() {
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
} }
async function revertToYoutube() { async function revertToYoutube() {
@@ -499,14 +500,14 @@ export default function Playlists() {
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")), (onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
}); });
} }
qc.removeQueries({ queryKey: ["playlist", deletedId] }); qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
} }
function playerState(id: string, status: string) { function playerState(id: string, status: string) {
api.setState(id, status).then(() => { api.setState(id, status).then(() => {
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
}); });
} }
+5 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
@@ -54,7 +55,7 @@ export default function PlaylistsRail({
const scrolledRef = useRef(false); const scrolledRef = useRef(false);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["playlists"], queryKey: qk.playlists(),
queryFn: () => api.playlists(), queryFn: () => api.playlists(),
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
@@ -114,8 +115,8 @@ export default function PlaylistsRail({
setSyncing(true); setSyncing(true);
try { try {
const r = await api.syncYoutubePlaylists(); const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: qk.playlist() });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
} catch (e) { } catch (e) {
notifyYouTubeActionError(e, t("playlists.syncFailed")); notifyYouTubeActionError(e, t("playlists.syncFailed"));
@@ -128,7 +129,7 @@ export default function PlaylistsRail({
mutationFn: (name: string) => api.createPlaylist(name), mutationFn: (name: string) => api.createPlaylist(name),
onSuccess: (pl: Playlist) => { onSuccess: (pl: Playlist) => {
setNewName(""); setNewName("");
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
setSelectedId(pl.id); setSelectedId(pl.id);
}, },
}); });
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react"; import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react";
import { import {
closestCenter, closestCenter,
@@ -56,7 +57,7 @@ export default function SavedViewsWidget({
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const viewsQuery = useQuery({ queryKey: ["saved-views"], queryFn: api.savedViews }); const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
const views = viewsQuery.data ?? []; const views = viewsQuery.data ?? [];
const [naming, setNaming] = useState(false); const [naming, setNaming] = useState(false);
const [draftName, setDraftName] = useState(""); const [draftName, setDraftName] = useState("");
@@ -69,7 +70,7 @@ export default function SavedViewsWidget({
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data); if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
}, [viewsQuery.data]); }, [viewsQuery.data]);
const refresh = () => qc.invalidateQueries({ queryKey: ["saved-views"] }); const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
const currentKey = keyOf(filters); const currentKey = keyOf(filters);
async function save() { async function save() {
@@ -124,7 +125,7 @@ export default function SavedViewsWidget({
const next = arrayMove(ids, oldIndex, newIndex); const next = arrayMove(ids, oldIndex, newIndex);
// Optimistic reorder so the drop sticks visually before the round-trip. // Optimistic reorder so the drop sticks visually before the round-trip.
qc.setQueryData<SavedView[]>( qc.setQueryData<SavedView[]>(
["saved-views"], qk.savedViews(),
next.map((id) => views.find((v) => v.id === id)!), next.map((id) => views.find((v) => v.id === id)!),
); );
await api.reorderViews(next); await api.reorderViews(next);
+3 -2
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react"; import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api"; import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
@@ -80,14 +81,14 @@ export default function Sidebar({
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions(); const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const tags = tagsQuery.data ?? []; const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context. Debounce the search term so // Live per-tag channel counts for the current filter context. Debounce the search term so
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch. // typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
const facetsQuery = useQuery({ const facetsQuery = useQuery({
queryKey: ["facets", facetFilters], queryKey: qk.facets(facetFilters),
queryFn: () => api.facets(facetFilters), queryFn: () => api.facets(facetFilters),
staleTime: 30_000, staleTime: 30_000,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
+8 -7
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react"; import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format"; import { formatEta, quotaActionLabel } from "../lib/format";
@@ -55,14 +56,14 @@ export default function Stats() {
function Overview({ me }: { me: Me }) { function Overview({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage }); const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data; const s = status.data;
const syncSubs = useMutation({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => { onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
notify({ notify({
level: "success", level: "success",
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }), message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
@@ -73,8 +74,8 @@ function Overview({ me }: { me: Me }) {
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => { onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
notify({ notify({
level: "success", level: "success",
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }), message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
@@ -200,10 +201,10 @@ function SystemStats() {
const qc = useQueryClient(); const qc = useQueryClient();
const [days, setDays] = useState<number>(30); const [days, setDays] = useState<number>(30);
const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) }); const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) });
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const pauseResume = useMutation({ const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
}); });
const data = q.data; const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total)); const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
+3 -2
View File
@@ -1,6 +1,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api"; import { api, type MyStatus } from "../lib/api";
@@ -27,7 +28,7 @@ export default function SyncStatus({
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }); const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
useDismiss(open, () => setOpen(false), [chipRef, popRef]); useDismiss(open, () => setOpen(false), [chipRef, popRef]);
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["my-status"], queryKey: qk.myStatus(),
queryFn: api.myStatus, queryFn: api.myStatus,
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and // Track the scheduler near-real-time: poll even when the tab is backgrounded, and
// refresh immediately on tab focus (the global default disables focus refetch), so the // refresh immediately on tab focus (the global default disables focus refetch), so the
@@ -47,7 +48,7 @@ export default function SyncStatus({
const toggle = useMutation({ const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()), mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
}); });
if (!data) return null; if (!data) return null;
+6 -5
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api"; import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@@ -116,8 +117,8 @@ export default function TagManager({
const confirm = useConfirm(); const confirm = useConfirm();
const listFade = useScrollFade(); const listFade = useScrollFade();
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) => const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? []) (channelsQuery.data ?? [])
@@ -125,9 +126,9 @@ export default function TagManager({
.map((c) => ({ id: c.id, title: c.title ?? c.id })); .map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
}; };
const create = useMutation({ const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }), mutationFn: (name: string) => api.createTag({ name, category: "other" }),
+44
View File
@@ -0,0 +1,44 @@
import type { FeedFilters } from "./api";
// Single source of truth for React Query keys. Every key root lives here exactly once, so a typo
// at one of the ~200 call sites can no longer mint an orphan key that silently never invalidates
// (the R7 driver: `["feed"]` was hand-written in 15+ files). CONTRACT: each function returns the
// SAME array shape the call sites used before, so TanStack's partial (prefix) matching is
// unchanged — `qk.feed()` (`["feed"]`) still invalidates `qk.feed(filters)` (`["feed", filters]`).
// Deliberately NOT bundling co-invalidations into helpers here: the clusters differ per call site
// (some invalidate `feed` alone, others `feed` + `feed-count`), so collapsing them would change
// which queries refetch. Grouped invalidation stays opt-in per domain (see `invalidateDownloads`).
// Extended per domain as R7 S2 migrates each area (S2a: feed/channels/playlists + shared roots).
export const qk = {
// --- shared roots (invalidated from more than one domain) ---
me: () => ["me"] as const,
myStatus: () => ["my-status"] as const,
tags: () => ["tags"] as const,
// --- feed / video ---
feed: (filters?: FeedFilters) => (filters ? (["feed", filters] as const) : (["feed"] as const)),
feedCount: (filters?: FeedFilters) =>
filters ? (["feed-count", filters] as const) : (["feed-count"] as const),
facets: (filters?: FeedFilters) =>
filters ? (["facets", filters] as const) : (["facets"] as const),
// `q`/`id` accept null (not just undefined) because the call sites key on nullable state
// (`selectedId`, the search string): a NULL is a real key segment and must be preserved
// (`["playlist", null]`), while a genuinely absent arg is the bare prefix key. The guard is
// `!== undefined`, so only the no-arg call collapses to the prefix.
ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const,
// --- channels ---
channels: () => ["channels"] as const,
channel: (id: string) => ["channel", id] as const,
discoveredChannels: () => ["discovered-channels"] as const,
blockedChannels: () => ["blocked-channels"] as const,
// --- playlists ---
playlists: () => ["playlists"] as const,
playlist: (id?: number | null) =>
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
savedViews: () => ["saved-views"] as const,
};
+3 -2
View File
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type FeedFilters } from "./api"; import { api, type FeedFilters } from "./api";
import { parseSort } from "./feedSort"; import { parseSort } from "./feedSort";
@@ -45,9 +46,9 @@ export function useActiveFilters(
setFilters: (f: FeedFilters) => void, setFilters: (f: FeedFilters) => void,
): ActiveFilter[] { ): ActiveFilter[] {
const { t } = useTranslation(); const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const channelsQuery = useQuery({ const channelsQuery = useQuery({
queryKey: ["channels"], queryKey: qk.channels(),
queryFn: api.channels, queryFn: api.channels,
enabled: filters.channelIds.length > 0, enabled: filters.channelIds.length > 0,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,