diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index f980814..e889178 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { api } from "../lib/api"; 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 { data: ch } = useQuery({ - queryKey: ["channel", channelId], + queryKey: qk.channel(channelId), queryFn: () => api.channelDetail(channelId), enabled: open && !!channelId, staleTime: 5 * 60_000, diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 4a84bff..8d8fc58 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; import { useDismiss } from "../lib/useDismiss"; @@ -28,7 +29,7 @@ export default function AddToPlaylist({ const [busy, setBusy] = useState(false); const membership = useQuery({ - queryKey: ["playlist-membership", videoId], + queryKey: qk.playlistMembership(videoId), queryFn: () => api.playlists(videoId), enabled: open, }); @@ -75,11 +76,11 @@ export default function AddToPlaylist({ }, [open]); function refresh() { - qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); - qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) }); + qc.invalidateQueries({ queryKey: qk.playlists() }); // Also invalidate every open/cached playlist detail (["playlist", id]) so the // 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) { diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 46c69ad..e122a3b 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { UserPlus } from "lucide-react"; import { api, type DiscoveredChannel } from "../lib/api"; import { accountKey, LS, readAccount, writeAccount } from "../lib/storage"; @@ -52,7 +53,7 @@ export default function ChannelDiscovery({ }; const query = useQuery({ - queryKey: ["discovered-channels"], + queryKey: qk.discoveredChannels(), queryFn: api.discoveredChannels, }); @@ -61,9 +62,9 @@ export default function ChannelDiscovery({ onSuccess: (_data, c) => { // The channel moves from "discovery" to "subscribed", and its videos will start // arriving — refresh both lists plus the per-user status. - qc.invalidateQueries({ queryKey: ["discovered-channels"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: qk.discoveredChannels() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); const name = c.title ?? c.id; // 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 diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 4005cc2..320e800 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { useNavigationActions } from "./NavigationProvider"; import { ArrowLeft, @@ -73,7 +74,7 @@ export default function ChannelPage({ const [bannerAttempt, setBannerAttempt] = useState(0); const detail = useQuery({ - queryKey: ["channel", channelId], + queryKey: qk.channel(channelId), queryFn: () => api.channelDetail(channelId), }); const ch = detail.data; @@ -91,10 +92,10 @@ export default function ChannelPage({ .exploreChannel(channelId, token ?? undefined) .then((r) => { 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 // 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)); }, @@ -116,9 +117,9 @@ export default function ChannelPage({ const block = useMutation({ mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)), onSuccess: () => { - qc.invalidateQueries({ queryKey: ["channel", channelId] }); - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["blocked-channels"] }); + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.blockedChannels() }); }, // 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) — @@ -128,12 +129,12 @@ export default function ChannelPage({ const subscribe = useMutation({ mutationFn: () => api.subscribeChannel(channelId), onSuccess: () => { - qc.invalidateQueries({ queryKey: ["channel", channelId] }); - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.channels() }); // Match ChannelDiscovery: the channel leaves discovery and the manager's stats move. - qc.invalidateQueries({ queryKey: ["my-status"] }); - qc.invalidateQueries({ queryKey: ["discovered-channels"] }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + qc.invalidateQueries({ queryKey: qk.discoveredChannels() }); 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 @@ -143,9 +144,9 @@ export default function ChannelPage({ const unsubscribe = useMutation({ mutationFn: () => api.unsubscribeChannel(channelId), onSuccess: () => { - qc.invalidateQueries({ queryKey: ["channel", channelId] }); - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.channels() }); }, onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), }); diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index 38bdb1a..1cdc975 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { X } from "lucide-react"; import { api, type FeedFilters } from "../lib/api"; 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. const fade = useScrollFade(); const channelsQuery = useQuery({ - queryKey: ["channels"], + queryKey: qk.channels(), queryFn: api.channels, staleTime: 5 * 60_000, }); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 3784f5b..054befc 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { ArrowDown, ArrowUp, @@ -106,27 +107,27 @@ export default function Channels() { notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); }; - const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); - const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); - const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); - const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels }); + const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); + const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); + const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); + const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels }); const [tagManagerOpen, setTagManagerOpen] = useState(false); const unblock = useMutation({ mutationFn: (id: string) => api.unblockChannel(id), onSuccess: () => { - qc.invalidateQueries({ queryKey: ["blocked-channels"] }); - qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: qk.blockedChannels() }); + qc.invalidateQueries({ queryKey: qk.feed() }); }, }); const invalidate = () => { - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["tags"] }); - qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.tags() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); // 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). - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["feed-count"] }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.feedCount() }); }; 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 // refresh the per-user status and feed too — not only the channel list. onSuccess: () => { - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["my-status"] }); - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["feed-count"] }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.feedCount() }); }, }); // 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. const bumpPriority = (id: string, current: number, delta: number) => { const next = current + delta; - qc.setQueryData(["channels"], (old) => + qc.setQueryData(qk.channels(), (old) => (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), ); api .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 // made the toggle feel ~2s slow); only revert by refetching if the server call fails. const toggleTag = (id: string, tagId: number, hasTag: boolean) => { - qc.setQueryData(["channels"], (old) => + qc.setQueryData(qk.channels(), (old) => (old ?? []).map((ch) => ch.id === id ? { @@ -172,7 +173,7 @@ export default function Channels() { ), ); 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({ mutationFn: () => api.syncSubscriptions(), @@ -188,8 +189,8 @@ export default function Channels() { const unsubscribe = useMutation({ mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), onSuccess: (_d, v) => { - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); }, onError: (e) => @@ -198,8 +199,8 @@ export default function Channels() { const resetBackfill = useMutation({ mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), onSuccess: (_d, v) => { - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), @@ -207,8 +208,8 @@ export default function Channels() { const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "success", message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), @@ -555,7 +556,7 @@ export default function Channels() { resetPage, } = useCardPager(sortedChannels, LS.channelCardSize, isCards); // 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 // 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. diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ad1a3b5..e8e7ff9 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -6,6 +6,7 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { ArrowDown, ArrowUp, @@ -155,7 +156,7 @@ export default function Feed({ const queryFilters: FeedFilters = { ...filters, q: debouncedQ }; const query = useInfiniteQuery({ - queryKey: ["feed", queryFilters], + queryKey: qk.feed(queryFilters), queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE), initialPageParam: null as string | null, 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 // revisited search from re-spending. const ytQuery = useInfiniteQuery({ - queryKey: ["yt-search", ytSearch, ytCount], + queryKey: qk.ytSearch(ytSearch, ytCount), queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount), initialPageParam: null as string | null, @@ -225,7 +226,7 @@ export default function Feed({ }, [query.dataUpdatedAt]); const countQuery = useQuery({ - queryKey: ["feed-count", queryFilters], + queryKey: qk.feedCount(queryFilters), queryFn: () => api.feedCount(queryFilters), staleTime: 30_000, enabled: !ytActive, @@ -250,7 +251,7 @@ export default function Feed({ api .setState(id, status) .then(() => { - qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: qk.feed() }); if (status === "hidden") { const v = loadedRef.current.find((x) => x.id === id); notify({ @@ -307,7 +308,7 @@ export default function Feed({ setOverrides((o) => ({ ...o, [id]: "new" })); api .clearState(id) - .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) + .then(() => qc.invalidateQueries({ queryKey: qk.feed() })) .catch(() => setOverrides((o) => { const next = { ...o }; @@ -324,8 +325,8 @@ export default function Feed({ setSavedOverrides((o) => ({ ...o, [id]: saved })); (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) .then(() => { - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist"] }); + qc.invalidateQueries({ queryKey: qk.playlists() }); + qc.invalidateQueries({ queryKey: qk.playlist() }); }) .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 // pre-search cache. Drop them so the feed re-fetches fresh on return. const dropFeedCaches = () => { - qc.removeQueries({ queryKey: ["feed"] }); - qc.removeQueries({ queryKey: ["feed-count"] }); - qc.removeQueries({ queryKey: ["facets"] }); + qc.removeQueries({ queryKey: qk.feed() }); + qc.removeQueries({ queryKey: qk.feedCount() }); + qc.removeQueries({ queryKey: qk.facets() }); }; // --- Live YouTube search mode ------------------------------------------------------------- @@ -435,7 +436,7 @@ export default function Feed({ setFilters({ ...filters, librarySource: "organic", sort: "newest" }); onExitYtSearch(); 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" > diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 3661ef0..80623b5 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { Activity, Bell, @@ -107,8 +108,8 @@ export default function NotificationsPanel() { api .setState(meta.videoId, "new") .then(() => { - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["feed-count"] }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.feedCount() }); // Quietly resolve the now-stale notice (no confirmation toast — the change is visible). resolveVideo(meta.videoId); }) diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index b7678f8..6552291 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { Trans, useTranslation } from "react-i18next"; 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 { api, type Me } from "../lib/api"; 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. const myStatus = useQuery({ - queryKey: ["my-status"], + queryKey: qk.myStatus(), queryFn: api.myStatus, enabled: me.can_read, }); @@ -72,10 +73,10 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () mutationFn: () => api.syncSubscriptions(), onSuccess: () => { // Feed/channels/status now have content — refresh the app behind the wizard. - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["feed-count"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.feedCount() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); }, }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index a720f13..55ce6ee 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useNavigationActions } from "./NavigationProvider"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { AlertTriangle, ArrowLeft, @@ -479,7 +480,7 @@ export default function PlayerModal({ closeTimer.current = window.setTimeout(() => setShowDesc(false), 150); }; const detail = useQuery({ - queryKey: ["video-detail", currentVideoId], + queryKey: qk.videoDetail(currentVideoId), queryFn: () => api.videoDetail(currentVideoId), // On hover (for the description) and eagerly when navigated, so we can link the // linked video's channel. Both share the one cached result. @@ -767,8 +768,8 @@ export default function PlayerModal({ window.removeEventListener("pagehide", onHide); // Final flush, then refresh the feed + playlists so resume bars reflect this session. Promise.resolve(persist()).finally(() => { - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["playlist"] }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.playlist() }); }); const p = playerRef.current; if (p && typeof p.destroy === "function") { diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 2d7ee7f..3cdb381 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { DndContext, KeyboardSensor, @@ -171,8 +172,8 @@ export default function Playlists() { // 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 // keeps the refetch from wiping the undo history on a pure reorder. - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + qc.invalidateQueries({ queryKey: qk.playlists() }); + qc.invalidateQueries({ queryKey: qk.playlist(selectedId) }); }) .catch((e) => { // 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 // becomes the base for the next drag. 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(null); const listQuery = useQuery({ - queryKey: ["playlists"], + queryKey: qk.playlists(), queryFn: () => api.playlists(), refetchOnWindowFocus: true, }); const playlists = listQuery.data ?? []; const detailQuery = useQuery({ - queryKey: ["playlist", selectedId], + queryKey: qk.playlist(selectedId), queryFn: () => api.playlist(selectedId as number), enabled: selectedId != null, // 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); function refreshAll() { - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + qc.invalidateQueries({ queryKey: qk.playlists() }); + qc.invalidateQueries({ queryKey: qk.playlist(selectedId) }); } async function revertToYoutube() { @@ -499,14 +500,14 @@ export default function Playlists() { (onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")), }); } - qc.removeQueries({ queryKey: ["playlist", deletedId] }); - qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.removeQueries({ queryKey: qk.playlist(deletedId) }); + qc.invalidateQueries({ queryKey: qk.playlists() }); } function playerState(id: string, status: string) { api.setState(id, status).then(() => { - qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); - qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: qk.playlist(selectedId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); }); } diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 01fbdb7..f0c7287 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; 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 { api, type Playlist } from "../lib/api"; import { formatDuration } from "../lib/format"; @@ -54,7 +55,7 @@ export default function PlaylistsRail({ const scrolledRef = useRef(false); const listQuery = useQuery({ - queryKey: ["playlists"], + queryKey: qk.playlists(), queryFn: () => api.playlists(), refetchOnWindowFocus: true, }); @@ -114,8 +115,8 @@ export default function PlaylistsRail({ setSyncing(true); try { const r = await api.syncYoutubePlaylists(); - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist"] }); + qc.invalidateQueries({ queryKey: qk.playlists() }); + qc.invalidateQueries({ queryKey: qk.playlist() }); notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); } catch (e) { notifyYouTubeActionError(e, t("playlists.syncFailed")); @@ -128,7 +129,7 @@ export default function PlaylistsRail({ mutationFn: (name: string) => api.createPlaylist(name), onSuccess: (pl: Playlist) => { setNewName(""); - qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: qk.playlists() }); setSelectedId(pl.id); }, }); diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index 76b3ee8..cc2dae4 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; 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 { closestCenter, @@ -56,7 +57,7 @@ export default function SavedViewsWidget({ const { t } = useTranslation(); const qc = useQueryClient(); 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 [naming, setNaming] = useState(false); const [draftName, setDraftName] = useState(""); @@ -69,7 +70,7 @@ export default function SavedViewsWidget({ if (viewsQuery.data) syncDefaultMirror(viewsQuery.data); }, [viewsQuery.data]); - const refresh = () => qc.invalidateQueries({ queryKey: ["saved-views"] }); + const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() }); const currentKey = keyOf(filters); async function save() { @@ -124,7 +125,7 @@ export default function SavedViewsWidget({ const next = arrayMove(ids, oldIndex, newIndex); // Optimistic reorder so the drop sticks visually before the round-trip. qc.setQueryData( - ["saved-views"], + qk.savedViews(), next.map((id) => views.find((v) => v.id === id)!), ); await api.reorderViews(next); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6afbdcd..a4520f7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; @@ -80,14 +81,14 @@ export default function Sidebar({ const { setFilters } = useFeedFiltersActions(); // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. 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 ?? []; // 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. const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetsQuery = useQuery({ - queryKey: ["facets", facetFilters], + queryKey: qk.facets(facetFilters), queryFn: () => api.facets(facetFilters), staleTime: 30_000, placeholderData: keepPreviousData, diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 7ef0543..6a704c0 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { History, Pause, Play, RefreshCw } from "lucide-react"; import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; @@ -55,14 +56,14 @@ export default function Stats() { function Overview({ me }: { me: Me }) { const { t } = useTranslation(); 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 s = status.data; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { - qc.invalidateQueries({ queryKey: ["my-status"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + qc.invalidateQueries({ queryKey: qk.channels() }); notify({ level: "success", message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }), @@ -73,8 +74,8 @@ function Overview({ me }: { me: Me }) { const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { - qc.invalidateQueries({ queryKey: ["my-status"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + qc.invalidateQueries({ queryKey: qk.channels() }); notify({ level: "success", message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }), @@ -200,10 +201,10 @@ function SystemStats() { const qc = useQueryClient(); const [days, setDays] = useState(30); 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({ 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 maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total)); diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index fc95e54..19c0d28 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; 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 }); useDismiss(open, () => setOpen(false), [chipRef, popRef]); const { data } = useQuery({ - queryKey: ["my-status"], + queryKey: qk.myStatus(), queryFn: api.myStatus, // 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 @@ -47,7 +48,7 @@ export default function SyncStatus({ const toggle = useMutation({ mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()), - onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }), }); if (!data) return null; diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index 7f4e86b..0fe3dc0 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { Plus, Trash2 } from "lucide-react"; import { api, type Tag } from "../lib/api"; import { notify } from "../lib/notifications"; @@ -116,8 +117,8 @@ export default function TagManager({ const confirm = useConfirm(); const listFade = useScrollFade(); const [newName, setNewName] = useState(""); - const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); - const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); + const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); + const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const channelsForTag = (tagId: number) => (channelsQuery.data ?? []) @@ -125,9 +126,9 @@ export default function TagManager({ .map((c) => ({ id: c.id, title: c.title ?? c.id })); const invalidate = () => { - qc.invalidateQueries({ queryKey: ["tags"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); - qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: qk.tags() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.feed() }); }; const create = useMutation({ mutationFn: (name: string) => api.createTag({ name, category: "other" }), diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts new file mode 100644 index 0000000..47dc048 --- /dev/null +++ b/frontend/src/lib/queryKeys.ts @@ -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, +}; diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts index 54ae93e..9690a9c 100644 --- a/frontend/src/lib/useActiveFilters.ts +++ b/frontend/src/lib/useActiveFilters.ts @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; +import { qk } from "./queryKeys"; import { api, type FeedFilters } from "./api"; import { parseSort } from "./feedSort"; @@ -45,9 +46,9 @@ export function useActiveFilters( setFilters: (f: FeedFilters) => void, ): ActiveFilter[] { const { t } = useTranslation(); - const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); + const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); const channelsQuery = useQuery({ - queryKey: ["channels"], + queryKey: qk.channels(), queryFn: api.channels, enabled: filters.channelIds.length > 0, staleTime: 5 * 60_000,