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 { 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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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() });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user