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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<ManagedChannel[]>(["channels"], (old) =>
|
||||
qc.setQueryData<ManagedChannel[]>(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<ManagedChannel[]>(["channels"], (old) =>
|
||||
qc.setQueryData<ManagedChannel[]>(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.
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string | null>(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() });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<SavedView[]>(
|
||||
["saved-views"],
|
||||
qk.savedViews(),
|
||||
next.map((id) => views.find((v) => v.id === id)!),
|
||||
);
|
||||
await api.reorderViews(next);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number>(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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user