From 34297b696f11f68fd358c4727e5095033fe017c7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 22:10:03 +0200 Subject: [PATCH] refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in 36 files. All ~200 React Query keys across the app now route through the factory; zero raw key literals remain outside queryKeys.ts. The heterogeneous composite plex keys (plex-library/facets/collections/ playlists carry a filters object, a ratingKeys array, or a union/group discriminator) take a variadic pass-through — the factory centralizes the ROOT string; single-id keys stay precisely typed. Nullable keys (thread, playlist, ytSearch) accept null so a null segment is preserved. Pure substitution — byte-identical key arrays, so TanStack prefix matching is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err), prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex modules load, feed/facets/tags/my-status/plex-library/plex-facets/ plex-collections/plex-playlists all 200, full UI renders. --- frontend/src/App.tsx | 9 +-- frontend/src/components/About.tsx | 7 ++- frontend/src/components/AdminUsers.tsx | 21 +++---- frontend/src/components/AuditLog.tsx | 5 +- frontend/src/components/ChatDock.tsx | 11 ++-- frontend/src/components/ChatThread.tsx | 11 ++-- frontend/src/components/ConfigPanel.tsx | 5 +- frontend/src/components/DownloadButton.tsx | 5 +- frontend/src/components/DownloadCenter.tsx | 23 ++++---- frontend/src/components/DownloadDialog.tsx | 3 +- .../src/components/FeedFiltersProvider.tsx | 5 +- frontend/src/components/FeedViewProvider.tsx | 5 +- frontend/src/components/KeyGate.tsx | 11 ++-- frontend/src/components/Messages.tsx | 5 +- frontend/src/components/NavSidebar.tsx | 9 +-- .../src/components/NotificationsPanel.tsx | 6 +- frontend/src/components/PlayerModal.tsx | 4 +- frontend/src/components/PlexBrowse.tsx | 39 +++++++------ .../src/components/PlexCollectionEditor.tsx | 9 +-- frontend/src/components/PlexInfo.tsx | 11 ++-- frontend/src/components/PlexPlayer.tsx | 11 ++-- frontend/src/components/PlexPlaylistAdd.tsx | 7 ++- frontend/src/components/PlexPlaylistView.tsx | 11 ++-- frontend/src/components/PlexSidebar.tsx | 9 +-- frontend/src/components/PrefsProvider.tsx | 3 +- frontend/src/components/ProfileEditor.tsx | 5 +- frontend/src/components/Scheduler.tsx | 15 ++--- frontend/src/components/SettingsPanel.tsx | 11 ++-- frontend/src/components/ShareDialog.tsx | 7 ++- frontend/src/components/Stats.tsx | 4 +- frontend/src/components/VideoEditor.tsx | 7 ++- frontend/src/components/WizardProvider.tsx | 5 +- frontend/src/lib/downloads.ts | 7 ++- frontend/src/lib/messaging.ts | 3 +- frontend/src/lib/plexDetailUi.tsx | 5 +- frontend/src/lib/queryKeys.ts | 58 +++++++++++++++++++ frontend/src/lib/useMe.ts | 5 +- 37 files changed, 235 insertions(+), 142 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 934dac3..483cac7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useState } from "react"; +import { qk } from "./lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api"; @@ -135,7 +136,7 @@ export default function App() { // We check this before anything else and render the wizard; `me` is held back until we know the // instance is set up (otherwise it would 503 against the setup-mode lock). const setupQuery = useQuery({ - queryKey: ["setup-status"], + queryKey: qk.setupStatus(), queryFn: api.setupStatus, staleTime: Infinity, }); @@ -145,7 +146,7 @@ export default function App() { // Only poll while signed in (data present) — polling on the public login page is pointless and // its periodic refetch caused a visible flicker there. const meQuery = useQuery({ - queryKey: ["me"], + queryKey: qk.me(), queryFn: api.me, enabled: setupQuery.isSuccess && !needsSetup, refetchInterval: (query) => (query.state.data ? 60_000 : false), @@ -173,7 +174,7 @@ export default function App() { // can't loop the public landing. useEffect(() => { setUnauthorizedHandler(() => { - if (queryClient.getQueryData(["me"])) { + if (queryClient.getQueryData(qk.me())) { queryClient.clear(); window.location.reload(); } @@ -223,7 +224,7 @@ export default function App() { // On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags, // language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider - // and the feed view by FeedViewProvider — each via its own passive ["me"] observer. + // and the feed view by FeedViewProvider — each via its own passive qk.me() observer. useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 79e6eb4..25c1c3d 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { Info, Sparkles } from "lucide-react"; import { api } from "../lib/api"; @@ -21,7 +22,11 @@ export default function About({ onOpenReleaseNotes: () => void; }) { const { t } = useTranslation(); - const { data } = useQuery({ queryKey: ["version"], queryFn: api.version, staleTime: 5 * 60_000 }); + const { data } = useQuery({ + queryKey: qk.version(), + queryFn: api.version, + staleTime: 5 * 60_000, + }); const rows: [string, string][] = [ [ diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 09bca40..acb6992 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; @@ -70,7 +71,7 @@ function UsersRoles({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); + const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); // Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since // with per-row disabling the admin can now start actions on several rows concurrently. const [pendingIds, setPendingIds] = useState>(new Set()); @@ -86,7 +87,7 @@ function UsersRoles({ me }: { me: Me }) { api.setUserRole(id, role), onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { - qc.invalidateQueries({ queryKey: ["admin-users"] }); + qc.invalidateQueries({ queryKey: qk.adminUsers() }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); }, onSettled: (_d, _e, vars) => endPending(vars.id), @@ -98,7 +99,7 @@ function UsersRoles({ me }: { me: Me }) { api.setUserSuspended(id, suspended), onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { - qc.invalidateQueries({ queryKey: ["admin-users"] }); + qc.invalidateQueries({ queryKey: qk.adminUsers() }); notify({ level: "success", message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", { @@ -113,7 +114,7 @@ function UsersRoles({ me }: { me: Me }) { mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id), onMutate: (vars) => startPending(vars.id), onSuccess: (_d, vars) => { - qc.invalidateQueries({ queryKey: ["admin-users"] }); + qc.invalidateQueries({ queryKey: qk.adminUsers() }); notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); }, onSettled: (_d, _e, vars) => endPending(vars.id), @@ -267,11 +268,11 @@ function AdminInvites() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() }); + const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() }); const [newEmail, setNewEmail] = useState(""); const refresh = () => { - qc.invalidateQueries({ queryKey: ["admin-invites"] }); - qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge + qc.invalidateQueries({ queryKey: qk.adminInvites() }); + qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge }; const approve = useMutation({ mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id), @@ -383,14 +384,14 @@ function AdminDemo() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() }); + const list = useQuery({ queryKey: qk.demoWhitelist(), queryFn: () => api.adminDemoWhitelist() }); const [newEmail, setNewEmail] = useState(""); const add = useMutation({ mutationFn: (email: string) => api.addDemoWhitelist(email), onSuccess: (_d, email) => { setNewEmail(""); - qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); + qc.invalidateQueries({ queryKey: qk.demoWhitelist() }); notify({ level: "success", message: t("settings.demo.added", { email }) }); }, onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }), @@ -398,7 +399,7 @@ function AdminDemo() { const remove = useMutation({ mutationFn: (id: number) => api.removeDemoWhitelist(id), onSuccess: () => { - qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); + qc.invalidateQueries({ queryKey: qk.demoWhitelist() }); notify({ level: "success", message: t("settings.demo.removed") }); }, onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index d413e6f..241c4b3 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Trash2 } from "lucide-react"; @@ -18,7 +19,7 @@ export default function AuditLog() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.adminAudit() }); + const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() }); const data = q.data; const rows = useMemo(() => data?.items ?? [], [data]); @@ -31,7 +32,7 @@ export default function AuditLog() { const clear = useMutation({ mutationFn: () => api.clearAudit(), onSuccess: (r) => { - qc.invalidateQueries({ queryKey: ["admin-audit"] }); + qc.invalidateQueries({ queryKey: qk.adminAudit() }); notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); }, onError: () => notify({ level: "error", message: t("audit.clearFailed") }), diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index 9d7cba2..9340005 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, ChevronUp, X } from "lucide-react"; @@ -34,10 +35,10 @@ export default function ChatDock({ meId }: { meId: number }) { useEffect( () => onMessage(({ message: m, from }) => { - qc.invalidateQueries({ queryKey: ["conversations"] }); - qc.invalidateQueries({ queryKey: ["message-unread"] }); + qc.invalidateQueries({ queryKey: qk.conversations() }); + qc.invalidateQueries({ queryKey: qk.messageUnread() }); const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id; - qc.invalidateQueries({ queryKey: ["thread", partnerId] }); + qc.invalidateQueries({ queryKey: qk.thread(partnerId) }); // Auto-open/flash only for messages from someone else (not my own echo) and only for // real user conversations. if (m.kind === "user" && m.sender_id !== meId && from) { @@ -51,8 +52,8 @@ export default function ChatDock({ meId }: { meId: number }) { useEffect( () => onUnread(() => { - qc.invalidateQueries({ queryKey: ["message-unread"] }); - qc.invalidateQueries({ queryKey: ["conversations"] }); + qc.invalidateQueries({ queryKey: qk.messageUnread() }); + qc.invalidateQueries({ queryKey: qk.conversations() }); }), [qc], ); diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index 1eb1a43..fab9595 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Send } from "lucide-react"; @@ -35,7 +36,7 @@ export default function ChatThread({ const needsKey = !isSystem && !ready; const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>( - ["thread", partnerId], + qk.thread(partnerId), () => api.thread(partnerId), { intervalMs: POLL_MS, enabled: !needsKey }, ); @@ -102,8 +103,8 @@ export default function ChatThread({ const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length; if (incoming !== lastIncoming.current) { lastIncoming.current = incoming; - qc.invalidateQueries({ queryKey: ["conversations"] }); - qc.invalidateQueries({ queryKey: ["message-unread"] }); + qc.invalidateQueries({ queryKey: qk.conversations() }); + qc.invalidateQueries({ queryKey: qk.messageUnread() }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [q.dataUpdatedAt, partnerId, meId, qc]); @@ -123,8 +124,8 @@ export default function ChatThread({ }, onSuccess: () => { setDraft(""); - qc.invalidateQueries({ queryKey: ["thread", partnerId] }); - qc.invalidateQueries({ queryKey: ["conversations"] }); + qc.invalidateQueries({ queryKey: qk.thread(partnerId) }); + qc.invalidateQueries({ queryKey: qk.conversations() }); }, onError: (e) => { // The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index b3c3d36..7dae0a2 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plug, RotateCcw, Send } from "lucide-react"; @@ -34,7 +35,7 @@ const GROUP_ORDER = [ export default function ConfigPanel() { const { t } = useTranslation(); const qc = useQueryClient(); - const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig }); + const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig }); const data = q.data; const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]); @@ -98,7 +99,7 @@ export default function ConfigPanel() { await api.setConfig(key, it.type === "int" ? Number(raw) : raw); } } - await qc.invalidateQueries({ queryKey: ["admin-config"] }); + await qc.invalidateQueries({ queryKey: qk.adminConfig() }); setReseedToken((n) => n + 1); // re-seed the draft from the saved server state setSaveState("saved"); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index 90e36a9..c58386a 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Download } from "lucide-react"; @@ -22,11 +23,11 @@ export default function DownloadButton({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - const me = qc.getQueryData(["me"]); + const me = qc.getQueryData(qk.me()); const isDemo = !!me?.is_demo; const indexQ = useQuery({ - queryKey: ["download-index"], + queryKey: qk.downloadIndex(), queryFn: api.downloadIndex, enabled: !isDemo, staleTime: 10000, diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 92dd99f..84d68db 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -281,7 +282,7 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void extra_links: links.map((l) => l.trim()).filter(Boolean), }), onSuccess: () => { - qc.invalidateQueries({ queryKey: ["downloads"] }); + qc.invalidateQueries({ queryKey: qk.downloads() }); onClose(); }, }); @@ -365,7 +366,7 @@ function UsageBar() { // Poll live (like the library list) so the footprint updates the moment the worker finishes an // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. - const usage = useLiveQuery(["download-usage"], api.downloadUsage, { intervalMs: 2000 }); + const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 }); const u = usage.data; if (!u) return null; const pct = @@ -408,7 +409,7 @@ function QuotaModal({ const { t } = useTranslation(); const qc = useQueryClient(); const q = useQuery({ - queryKey: ["dl-quota", userId], + queryKey: qk.dlQuota(userId), queryFn: () => api.adminGetDownloadQuota(userId), }); const [form, setForm] = useState<{ @@ -429,8 +430,8 @@ function QuotaModal({ } : null); const done = () => { - qc.invalidateQueries({ queryKey: ["dl-quota", userId] }); - qc.invalidateQueries({ queryKey: ["admin-storage"] }); + qc.invalidateQueries({ queryKey: qk.dlQuota(userId) }); + qc.invalidateQueries({ queryKey: qk.adminStorage() }); onClose(); }; const save = useMutation({ @@ -516,7 +517,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } const { t } = useTranslation(); const [q, setQ] = useState(""); const [open, setOpen] = useState(false); - const users = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); + const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); const list = (users.data ?? []).filter((u) => !u.is_demo); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); @@ -565,8 +566,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } function AdminSystem() { const { t } = useTranslation(); - const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 }); - const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 }); + const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); + const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); const s = storage.data; return ( @@ -662,9 +663,9 @@ export default function DownloadCenter() { const [shareJob, setShareJob] = useState(null); const [editJob, setEditJob] = useState(null); - const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 }); + const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 }); const sharedQ = useQuery({ - queryKey: ["downloads-shared"], + queryKey: qk.downloadsShared(), queryFn: api.downloadsShared, enabled: tab === "shared", }); @@ -698,7 +699,7 @@ export default function DownloadCenter() { // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched). const removeShared = useMutation({ mutationFn: (id: number) => api.removeSharedDownload(id), - onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads-shared"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }), }); const confirmThen = async (title: string, body: string, fn: () => void) => { diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 8340ee6..36caadd 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Modal from "./Modal"; @@ -24,7 +25,7 @@ export default function DownloadDialog({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles }); + const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles }); const [profileId, setProfileId] = useState(null); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index a3a897f..6ce5921 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -7,6 +7,7 @@ import { useState, type ReactNode, } from "react"; +import { qk } from "../lib/queryKeys"; import { useQuery } from "@tanstack/react-query"; import { api, getActiveAccount, type FeedFilters } from "../lib/api"; import { hasFilterParams, paramsToFilters } from "../lib/urlState"; @@ -95,10 +96,10 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) { hasFilterParams(new URLSearchParams(window.location.search)), ); - // Passive observer: App owns the fetching `["me"]` query (gated on setup state); enabled:false + // Passive observer: App owns the fetching `qk.me()` query (gated on setup state); enabled:false // here reads its cache and re-renders when it lands, without triggering a second fetch (which // could 503 in setup mode). We only need id + is_demo, which don't change on background refetch. - const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); const setFilters = useCallback((next: FeedFilters) => { setFiltersState(next); diff --git a/frontend/src/components/FeedViewProvider.tsx b/frontend/src/components/FeedViewProvider.tsx index 90b55d7..ba51511 100644 --- a/frontend/src/components/FeedViewProvider.tsx +++ b/frontend/src/components/FeedViewProvider.tsx @@ -7,6 +7,7 @@ import { useState, type ReactNode, } from "react"; +import { qk } from "../lib/queryKeys"; import { useQuery } from "@tanstack/react-query"; import { api } from "../lib/api"; import { parseFeedView, type FeedView } from "../lib/feedView"; @@ -19,7 +20,7 @@ import { LS, readAccount, writeAccount } from "../lib/storage"; // The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs) // and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider // carries the same server-sync the App god component used to. A localStorage mirror seeds the initial -// render synchronously; the passive ["me"] observer adopts the server value once the account resolves. +// render synchronously; the passive qk.me() observer adopts the server value once the account resolves. interface FeedViewActions { setView: (v: FeedView) => void; @@ -40,7 +41,7 @@ export function FeedViewProvider({ children }: { children: ReactNode }) { parseFeedView(readAccount(LS.feedView, null)), ); // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). - const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); // A user pick persists both to the localStorage mirror (instant reload) and to the server. const setView = useCallback((next: FeedView) => { diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index caf6dcc..58c3629 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Lock, ShieldCheck } from "lucide-react"; @@ -16,15 +17,15 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa const { t } = useTranslation(); const qc = useQueryClient(); const keyQ = useQuery({ - queryKey: ["message-key"], + queryKey: qk.messageKey(), queryFn: api.messageMyKey, staleTime: 60_000, }); const configured = keyQ.data?.configured ?? false; // Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts - // have no password to check. Reactive passive observer of the App-owned ["me"] query (enabled: + // have no password to check. Reactive passive observer of the App-owned qk.me() query (enabled: // false — App does the fetching), so hasPassword stays correct even if me resolves after mount. - const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); const hasPassword = meQ.data?.has_password ?? false; const [view, setView] = useState<"gate" | "reset">("gate"); @@ -46,7 +47,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa const payload = await e2ee.setup(meId, pass); await api.setupMessageKey(payload); } - qc.invalidateQueries({ queryKey: ["message-key"] }); + qc.invalidateQueries({ queryKey: qk.messageKey() }); } catch { setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed")); } finally { @@ -77,7 +78,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa await e2ee.resetLocal(meId); setCurPass(""); setView("gate"); - qc.invalidateQueries({ queryKey: ["message-key"] }); + qc.invalidateQueries({ queryKey: qk.messageKey() }); } catch (e) { setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed")); } finally { diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 399f2d0..cfe52d6 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; @@ -74,7 +75,7 @@ function ConversationList({ const { t } = useTranslation(); const keyState = useKeyState(); const ready = keyState === "ready"; - const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { + const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, { intervalMs: POLL_MS, }); const items = q.data?.items ?? []; @@ -204,7 +205,7 @@ function ConversationList({ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) { const { t } = useTranslation(); const [filter, setFilter] = useState(""); - const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers }); + const q = useQuery({ queryKey: qk.messageUsers(), queryFn: api.messageUsers }); const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase()), ); diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index a8a1950..d0ce865 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { qk } from "../lib/queryKeys"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; @@ -114,7 +115,7 @@ export default function NavSidebar({ // Accounts that have signed in on this browser (loaded only while the popover is open). const accountsQuery = useQuery({ - queryKey: ["accounts"], + queryKey: qk.accounts(), queryFn: api.accounts, enabled: acctOpen, }); @@ -138,7 +139,7 @@ export default function NavSidebar({ // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // tab is hidden); coexists with the client-side transient bell at the bottom of the rail. - const unreadQuery = useLiveQuery(["notif-unread"], api.notificationUnreadCount, { + const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, { intervalMs: 30000, }); // One indicator for both layers: durable server notifications + the client-side transient @@ -146,14 +147,14 @@ export default function NavSidebar({ const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const unread = (unreadQuery.data?.count ?? 0) + clientUnread; // Direct messages are their own module with their own unread badge (demo can't message). - const msgUnreadQuery = useLiveQuery(["message-unread"], api.messageUnreadCount, { + const msgUnreadQuery = useLiveQuery(qk.messageUnread(), api.messageUnreadCount, { intervalMs: 30000, enabled: !me.is_demo, }); const msgUnread = msgUnreadQuery.data?.count ?? 0; // Download center badge = items still working (queued/running/paused). Reuses the same // lightweight per-user index the feed cards poll, so no extra request shape. - const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, { + const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, { intervalMs: 30000, enabled: !me.is_demo, }); diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 80623b5..6fee962 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -57,7 +57,7 @@ export default function NotificationsPanel() { const { focusChannel: onFocusChannel } = useChannelsActions(); const qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( - ["notifications"], + qk.notifications(), () => api.notifications(), { intervalMs: POLL_MS }, ); @@ -72,8 +72,8 @@ export default function NotificationsPanel() { }, []); const refresh = () => { - qc.invalidateQueries({ queryKey: ["notifications"] }); - qc.invalidateQueries({ queryKey: ["notif-unread"] }); + qc.invalidateQueries({ queryKey: qk.notifications() }); + qc.invalidateQueries({ queryKey: qk.notifUnread() }); }; const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh }); const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 55ce6ee..a9f023b 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -381,7 +381,7 @@ export default function PlayerModal({ // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler. const savedPrefs = useMemo( () => - (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? {}) as { + (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode; }, @@ -393,7 +393,7 @@ export default function PlayerModal({ modeRef.current = { autoMode, loopMode }; const persistPref = (patch: Record) => { api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, ); }; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 6678fd2..c1e7d32 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -7,6 +7,7 @@ import { useState, type CSSProperties, } from "react"; +import { qk } from "../lib/queryKeys"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { @@ -138,7 +139,7 @@ export default function PlexBrowse() { }, [sub]); const browseQ = useInfiniteQuery({ - queryKey: ["plex-library", scope, dq, sort, show, filters], + queryKey: qk.plexLibrary(scope, dq, sort, show, filters), enabled: !!scope && sub.view.kind === "grid", initialPageParam: 0, queryFn: ({ pageParam }) => @@ -162,7 +163,7 @@ export default function PlexBrowse() { // Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the // grid so you SEE who you're exploring, not just chips in the rail. const peopleCardsQ = useQuery({ - queryKey: ["plex-people-cards", scope, filters.actors], + queryKey: qk.plexPeopleCards(scope, filters.actors), queryFn: () => api.plexPeopleCards(filters.actors, scope), enabled: sub.view.kind === "grid" && filters.actors.length > 0, staleTime: 60_000, @@ -206,7 +207,7 @@ export default function PlexBrowse() { // and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably // BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe. function optimisticStatus(id: string, next: string) { - qc.setQueriesData>({ queryKey: ["plex-library"] }, (old) => + qc.setQueriesData>({ queryKey: qk.plexLibrary() }, (old) => old ? { ...old, @@ -223,7 +224,7 @@ export default function PlexBrowse() { const next = card.status === "watched" ? "new" : "watched"; optimisticStatus(card.id, next); await api.plexSetState(card.id, next).catch(() => {}); - qc.invalidateQueries({ queryKey: ["plex-library"] }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. // Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person @@ -587,7 +588,7 @@ function PlexInfoView({ onFilter: (patch: Partial) => void; }) { const { t } = useTranslation(); - const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) }); + const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) }); return (
@@ -614,7 +615,7 @@ function PlexInfoView({ onPlay={onPlay} onPlayItem={onPlayItem} onFilter={onFilter} - // No onStateChange: PlexInfo.setState already invalidates ["plex-item", id] (this very + // No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very // query), so q refetches automatically — a manual refetch here just double-fetched. /> @@ -624,7 +625,7 @@ function PlexInfoView({ } // --- Series drill-down: show detail page → season subpage → player ------------------------------- -// Both the show page and the season page read the SAME cached ["plex-show", showId] payload (the +// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the // season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's // whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start), // Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only — @@ -989,8 +990,8 @@ function PlexShowView({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin"; - const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); + const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; + const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) }); const d = q.data; const [addTarget, setAddTarget] = useState(null); const [collOpen, setCollOpen] = useState(false); @@ -1009,13 +1010,13 @@ function PlexShowView({ } finally { setBusy(false); } - qc.invalidateQueries({ queryKey: ["plex-show", showId] }); - qc.invalidateQueries({ queryKey: ["plex-library"] }); + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } async function markSeason(seasonRk: string, w: boolean) { await api.plexSeasonState(seasonRk, w).catch(() => {}); - qc.invalidateQueries({ queryKey: ["plex-show", showId] }); - qc.invalidateQueries({ queryKey: ["plex-library"] }); + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } return ( @@ -1208,7 +1209,7 @@ function PlexShowView({ library={showLib} memberOf={show.collection_keys} onClose={() => setCollOpen(false)} - onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })} + onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })} /> )}
@@ -1228,7 +1229,7 @@ function PlexSeasonView({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); + const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) }); const d = q.data; const se = d?.seasons.find((s) => s.id === seasonId); const [addTarget, setAddTarget] = useState(null); @@ -1245,13 +1246,13 @@ function PlexSeasonView({ } finally { setBusy(false); } - qc.invalidateQueries({ queryKey: ["plex-show", showId] }); - qc.invalidateQueries({ queryKey: ["plex-library"] }); + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } async function markEpisode(ep: PlexCard) { await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {}); - qc.invalidateQueries({ queryKey: ["plex-show", showId] }); - qc.invalidateQueries({ queryKey: ["plex-library"] }); + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } return ( diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index d9746a1..2067fb3 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Plus } from "lucide-react"; @@ -35,7 +36,7 @@ export default function PlexCollectionEditor({ const [busy, setBusy] = useState(null); // collection id currently mutating const listQ = useQuery({ - queryKey: ["plex-collections", library], + queryKey: qk.plexCollections(library), queryFn: () => api.plexCollections(library), }); const editable = useMemo( @@ -54,9 +55,9 @@ export default function PlexCollectionEditor({ const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable; const refresh = () => { - qc.invalidateQueries({ queryKey: ["plex-collections"] }); - qc.invalidateQueries({ queryKey: ["plex-item", item.id] }); - qc.invalidateQueries({ queryKey: ["plex-library"] }); + qc.invalidateQueries({ queryKey: qk.plexCollections() }); + qc.invalidateQueries({ queryKey: qk.plexItem(item.id) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); onChanged(); }; diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 49664e4..e1f547f 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; @@ -52,7 +53,7 @@ export default function PlexInfo({ // artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding // is PlexInfo-specific state, persisted through the same savePref. const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs(); - const prefs = (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? + const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { plexHiddenStripSources?: string[] }; // Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled // per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here. @@ -66,7 +67,7 @@ export default function PlexInfo({ return next; }); }; - const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin"; + const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const [editorOpen, setEditorOpen] = useState(false); const [playlistOpen, setPlaylistOpen] = useState(false); @@ -77,9 +78,9 @@ export default function PlexInfo({ const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; const setState = async (status: "new" | "watched") => { await api.plexSetState(detail.id, status).catch(() => {}); - qc.invalidateQueries({ queryKey: ["plex-item", detail.id] }); - qc.invalidateQueries({ queryKey: ["plex-library"] }); - qc.invalidateQueries({ queryKey: ["plex-show"] }); + qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + qc.invalidateQueries({ queryKey: qk.plexShow() }); onStateChange?.(); }; diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 8a373ee..110678f 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -9,6 +9,7 @@ import { type ReactNode, type WheelEvent as ReactWheelEvent, } from "react"; +import { qk } from "../lib/queryKeys"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -196,7 +197,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { tRef.current = t; const qc = useQueryClient(); const [id, setId] = useState(itemId); - const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) }); + const detailQ = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) }); const detail = detailQ.data; const duration = detail?.duration_seconds ?? 0; @@ -513,12 +514,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play. if (absRef.current > 0 && durationRef.current > 0) { const pos = Math.floor(absRef.current); - qc.setQueryData(["plex-item", id], (old) => - old ? { ...old, position_seconds: pos } : old, - ); + qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old)); } - qc.invalidateQueries({ queryKey: ["plex-library"] }); - qc.invalidateQueries({ queryKey: ["plex-show"] }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + qc.invalidateQueries({ queryKey: qk.plexShow() }); }; }, [id, qc]); diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index 7fabc9f..9e05bf3 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Minus, Plus } from "lucide-react"; @@ -35,15 +36,15 @@ export default function PlexPlaylistAdd({ const listQ = useQuery({ queryKey: isGroup - ? ["plex-playlists", "group", target.ratingKeys] - : ["plex-playlists", "contains", target.ratingKey], + ? qk.plexPlaylists("group", target.ratingKeys) + : qk.plexPlaylists("contains", target.ratingKey), queryFn: () => isGroup ? api.plexPlaylists(undefined, target.ratingKeys) : api.plexPlaylists(target.ratingKey), }); const playlists = listQ.data?.playlists ?? []; - const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + const refresh = () => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); // How many of the target a playlist currently holds (0..size). function inCount(p: PlexPlaylist): number { diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index d9d9448..3cec46a 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -375,7 +376,7 @@ export default function PlexPlaylistView({ const [collapsedSeasons, setCollapsedSeasons] = useState>(new Set()); const q = useQuery({ - queryKey: ["plex-playlist", playlistId], + queryKey: qk.plexPlaylist(playlistId), queryFn: () => api.plexPlaylist(playlistId), }); // Local optimistic copy so drag/remove are instant; re-synced whenever the query returns. @@ -387,8 +388,8 @@ export default function PlexPlaylistView({ const order = useMemo(() => items.map((i) => i.id), [items]); const invalidate = () => { - qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] }); - qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); + qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); }; function commit(next: PlexCard[]) { @@ -398,7 +399,7 @@ export default function PlexPlaylistView({ playlistId, next.map((i) => i.id), ) - .then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] })); + .then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() })); } async function onRemove(keys: string[]) { @@ -431,7 +432,7 @@ export default function PlexPlaylistView({ ) return; await api.plexDeletePlaylist(playlistId); - qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); onBack(); } diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 024c17a..9a3bff9 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,4 +1,5 @@ import { useState, type ReactNode } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; @@ -84,7 +85,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl setPlaylistOpen: onOpenPlaylist, } = usePlexActions(); const collFade = useScrollFade(); - const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); + const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); const [editing, setEditing] = useState(false); const qc = useQueryClient(); @@ -93,12 +94,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl if (!title) return; const pl = await api.plexCreatePlaylist(title); setNewPlaylist(""); - qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); onOpenPlaylist(pl.id); } const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ - queryKey: ["plex-facets", scope, show, facetKey], + queryKey: qk.plexFacets(scope, show, facetKey), queryFn: () => api.plexFacets(scope, filters, show), enabled: !!scope, placeholderData: (prev) => prev, @@ -108,7 +109,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const [collSearch, setCollSearch] = useState(""); const collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ - queryKey: ["plex-collections", "union", collSearchDeb], + queryKey: qk.plexCollections("union", collSearchDeb), queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), enabled: !filters.collection, }); diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx index 18f3c5a..87229d8 100644 --- a/frontend/src/components/PrefsProvider.tsx +++ b/frontend/src/components/PrefsProvider.tsx @@ -8,6 +8,7 @@ import { useState, type ReactNode, } from "react"; +import { qk } from "../lib/queryKeys"; import { useQuery } from "@tanstack/react-query"; import { api } from "../lib/api"; import { @@ -68,7 +69,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) { const saveMsgTimer = useRef(undefined); const saveTimer = useRef(undefined); // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). - const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); // Live-apply the prefs locally for instant preview (their localStorage mirrors update via the // stores too); persistence to the server is the auto-save effect below. diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index 9648294..3bc22a6 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Lock, Pencil, Plus, Trash2 } from "lucide-react"; @@ -42,7 +43,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles }); + const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles }); const profiles = profilesQ.data ?? []; const builtins = profiles.filter((p) => p.is_builtin); const mine = profiles.filter((p) => !p.is_builtin); @@ -52,7 +53,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { const [spec, setSpec] = useState(BLANK); const [formOpen, setFormOpen] = useState(false); - const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] }); + const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() }); const startNew = () => { setEditing(null); setName(""); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index ddd4839..1dedc82 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import i18n from "../i18n"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -340,7 +341,7 @@ export default function Scheduler() { const confirm = useConfirm(); // Poll faster while any job is running so live progress is smooth, then ease back when // idle. Derived from the freshest data by react-query itself (see useLiveQuery). - const q = useLiveQuery(["scheduler"], api.schedulerStatus, { + const q = useLiveQuery(qk.scheduler(), api.schedulerStatus, { intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS), }); const data = q.data; @@ -376,14 +377,14 @@ export default function Scheduler() { const pauseResume = useMutation({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), - onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }), onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }), }); const intervalMut = useMutation({ mutationFn: (v: { jobId: string; minutes: number }) => api.updateSchedulerJob(v.jobId, v.minutes), - onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }), onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }), }); @@ -396,7 +397,7 @@ export default function Scheduler() { level: "success", message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }), }); - qc.invalidateQueries({ queryKey: ["scheduler"] }); + qc.invalidateQueries({ queryKey: qk.scheduler() }); }, }); const runAllMut = useMutation({ @@ -406,12 +407,12 @@ export default function Scheduler() { level: "success", message: t("scheduler.triggeredAll", { count: res.started.length }), }); - qc.invalidateQueries({ queryKey: ["scheduler"] }); + qc.invalidateQueries({ queryKey: qk.scheduler() }); }, }); const batchMut = useMutation({ mutationFn: (v: number) => api.updateMaintenanceBatch(v), - onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }), }); const purgeMut = useMutation({ mutationFn: () => api.purgeDiscovery(), @@ -419,7 +420,7 @@ export default function Scheduler() { const removed = (res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0); notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) }); - qc.invalidateQueries({ queryKey: ["scheduler"] }); + qc.invalidateQueries({ queryKey: qk.scheduler() }); }, }); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 58604c9..a75a1f1 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; @@ -387,7 +388,7 @@ function SignInMethods({ me }: { me: Me }) { setCurrent(""); setNext(""); // Refresh `me` so has_password flips and the section switches to "Change password". - await qc.invalidateQueries({ queryKey: ["me"] }); + await qc.invalidateQueries({ queryKey: qk.me() }); notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }), @@ -516,7 +517,7 @@ function SignInMethods({ me }: { me: Me }) { function PlexWatchSync() { const { t, i18n } = useTranslation(); const qc = useQueryClient(); - const link = useQuery({ queryKey: ["plex-watch-link"], queryFn: () => api.plexWatchLink() }); + const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() }); const enabled = link.data?.sync_enabled ?? false; const announce = (imp: { watched: number; in_progress: number } | null | undefined) => { @@ -529,7 +530,7 @@ function PlexWatchSync() { }), }); // The browse grid's watch badges read plex_states — refresh them. - qc.invalidateQueries({ queryKey: ["plex"] }); + qc.invalidateQueries({ queryKey: qk.plex() }); } }; @@ -539,14 +540,14 @@ function PlexWatchSync() { const toggle = useMutation({ mutationFn: (next: boolean) => api.plexWatchSetLink(next), onSuccess: (res) => { - qc.setQueryData(["plex-watch-link"], res); + qc.setQueryData(qk.plexWatchLink(), res); announce(res.import); }, }); const reimport = useMutation({ mutationFn: () => api.plexWatchImport(), onSuccess: (res) => { - qc.setQueryData(["plex-watch-link"], res); + qc.setQueryData(qk.plexWatchLink(), res); announce(res.import); }, }); diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 508df8a..396cb32 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; @@ -17,7 +18,7 @@ function UserShare({ job }: { job: DownloadJob }) { const { t } = useTranslation(); const [q, setQ] = useState(""); const [open, setOpen] = useState(false); - const recipients = useQuery({ queryKey: ["share-recipients"], queryFn: api.shareRecipients }); + const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients }); const list = recipients.data ?? []; const filtered = useMemo(() => { const s = q.trim().toLowerCase(); @@ -245,14 +246,14 @@ function LinkShare({ job }: { job: DownloadJob }) { const { t } = useTranslation(); const qc = useQueryClient(); const links = useQuery({ - queryKey: ["download-links", job.id], + queryKey: qk.downloadLinks(job.id), queryFn: () => api.downloadLinks(job.id), }); const [creating, setCreating] = useState(false); const [allowDownload, setAllowDownload] = useState(false); const [expiryDays, setExpiryDays] = useState(null); const [password, setPassword] = useState(""); - const invalidate = () => qc.invalidateQueries({ queryKey: ["download-links", job.id] }); + const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) }); const create = useMutation({ mutationFn: () => diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 6a704c0..a7d4439 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -57,7 +57,7 @@ function Overview({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); - const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage }); + const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage }); const s = status.data; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), @@ -200,7 +200,7 @@ function SystemStats() { const { t } = useTranslation(); const qc = useQueryClient(); const [days, setDays] = useState(30); - const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) }); + const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) }); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const pauseResume = useMutation({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index 8f992fc..d3ca5d7 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -73,7 +74,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos const aspect = (job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9; const sb = useQuery({ - queryKey: ["storyboard", job.id], + queryKey: qk.storyboard(job.id), queryFn: () => api.downloadStoryboard(job.id), staleTime: Infinity, retry: false, @@ -307,8 +308,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos return { files: N }; }, onSuccess: ({ files }) => { - qc.invalidateQueries({ queryKey: ["downloads"] }); - qc.invalidateQueries({ queryKey: ["download-usage"] }); + qc.invalidateQueries({ queryKey: qk.downloads() }); + qc.invalidateQueries({ queryKey: qk.downloadUsage() }); notify({ level: "success", message: t("editor.created", { count: files }) }); onClose(); }, diff --git a/frontend/src/components/WizardProvider.tsx b/frontend/src/components/WizardProvider.tsx index 1c2b81f..c56e7a6 100644 --- a/frontend/src/components/WizardProvider.tsx +++ b/frontend/src/components/WizardProvider.tsx @@ -9,6 +9,7 @@ import { useState, type ReactNode, } from "react"; +import { qk } from "../lib/queryKeys"; import { useQuery } from "@tanstack/react-query"; import { api } from "../lib/api"; import { shouldAutoOpenOnboarding } from "../lib/onboarding"; @@ -21,7 +22,7 @@ const OnboardingWizard = lazy(() => import("./OnboardingWizard")); // // The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from // granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from -// Settings / the notify "reconnect" action. It reads `me` as a passive ["me"] observer (App owns the +// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (App owns the // fetching query), so it re-runs the auto-open check when the account resolves or its grants change. interface WizardActions { @@ -43,7 +44,7 @@ export function useWizard(): WizardActions { export function WizardProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). - const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); const openWizard = useCallback(() => setOpen(true), []); const closeWizard = useCallback(() => setOpen(false), []); diff --git a/frontend/src/lib/downloads.ts b/frontend/src/lib/downloads.ts index 6fd9351..36dc6cb 100644 --- a/frontend/src/lib/downloads.ts +++ b/frontend/src/lib/downloads.ts @@ -1,9 +1,10 @@ import type { QueryClient } from "@tanstack/react-query"; +import { qk } from "./queryKeys"; // The download queue, the per-card download index, and the storage-usage meter all move together // whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit. export function invalidateDownloads(qc: QueryClient) { - qc.invalidateQueries({ queryKey: ["downloads"] }); - qc.invalidateQueries({ queryKey: ["download-index"] }); - qc.invalidateQueries({ queryKey: ["download-usage"] }); + qc.invalidateQueries({ queryKey: qk.downloads() }); + qc.invalidateQueries({ queryKey: qk.downloadIndex() }); + qc.invalidateQueries({ queryKey: qk.downloadUsage() }); } diff --git a/frontend/src/lib/messaging.ts b/frontend/src/lib/messaging.ts index 8bdcb05..88b964d 100644 --- a/frontend/src/lib/messaging.ts +++ b/frontend/src/lib/messaging.ts @@ -1,5 +1,6 @@ // Shared messaging helpers used by both the Messages page and the floating chat dock. import { useSyncExternalStore } from "react"; +import { qk } from "./queryKeys"; import { useQuery } from "@tanstack/react-query"; import { api } from "./api"; import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee"; @@ -57,7 +58,7 @@ function useUnlocked(): boolean { export type KeyState = "loading" | "setup" | "unlock" | "ready"; export function useKeyState(): KeyState { const unlocked = useUnlocked(); - const q = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 }); + const q = useQuery({ queryKey: qk.messageKey(), queryFn: api.messageMyKey, staleTime: 60_000 }); if (q.isLoading && !q.data) return "loading"; if (!(q.data?.configured ?? false)) return "setup"; if (!unlocked) return "unlock"; diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 6631e79..08e7f4a 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { qk } from "./queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { SlidersHorizontal } from "lucide-react"; @@ -12,7 +13,7 @@ import { api } from "./api"; export function useDetailPrefs() { const qc = useQueryClient(); - const prefs = (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? + const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; @@ -23,7 +24,7 @@ export function useDetailPrefs() { const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false); const save = (patch: Record) => { api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, ); }; diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 47dc048..c7d3141 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -41,4 +41,62 @@ export const qk = { id !== undefined ? (["playlist", id] as const) : (["playlist"] as const), playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const, savedViews: () => ["saved-views"] as const, + + // --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above) + accounts: () => ["accounts"] as const, + version: () => ["version"] as const, + setupStatus: () => ["setup-status"] as const, + myUsage: () => ["my-usage"] as const, + scheduler: () => ["scheduler"] as const, + + // --- notifications --- + notifications: () => ["notifications"] as const, + notifUnread: () => ["notif-unread"] as const, + + // --- messaging --- + conversations: () => ["conversations"] as const, + thread: (partnerId: number | null) => ["thread", partnerId] as const, + messageKey: () => ["message-key"] as const, + messageUnread: () => ["message-unread"] as const, + messageUsers: () => ["message-users"] as const, + shareRecipients: () => ["share-recipients"] as const, + + // --- downloads --- + downloads: () => ["downloads"] as const, + downloadsShared: () => ["downloads-shared"] as const, + downloadIndex: () => ["download-index"] as const, + downloadUsage: () => ["download-usage"] as const, + downloadProfiles: () => ["download-profiles"] as const, + downloadLinks: (jobId: number) => ["download-links", jobId] as const, + dlQuota: (userId: number) => ["dl-quota", userId] as const, + storyboard: (jobId: number) => ["storyboard", jobId] as const, + + // --- admin --- + adminUsers: () => ["admin-users"] as const, + adminInvites: () => ["admin-invites"] as const, + adminConfig: () => ["admin-config"] as const, + adminAudit: () => ["admin-audit"] as const, + adminStorage: () => ["admin-storage"] as const, + adminDownloads: () => ["admin-downloads"] as const, + adminQuota: (days: number) => ["admin-quota", days] as const, + demoWhitelist: () => ["demo-whitelist"] as const, + + // --- plex --- + // The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys + // array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through: + // the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]` + // anyway. Single-id plex keys stay precisely typed. + plex: () => ["plex"] as const, + plexWatchLink: () => ["plex-watch-link"] as const, + plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], + plexShow: (showId?: string) => + showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const), + plexItem: (id: string) => ["plex-item", id] as const, + plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest], + plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const, + plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest], + // facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too. + plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest], + plexPeopleCards: (scope: string, actors: string[]) => + ["plex-people-cards", scope, actors] as const, }; diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts index dd5d780..d5f126b 100644 --- a/frontend/src/lib/useMe.ts +++ b/frontend/src/lib/useMe.ts @@ -1,13 +1,14 @@ import { useQuery } from "@tanstack/react-query"; +import { qk } from "./queryKeys"; import { api, type Me } from "./api"; -// The signed-in account, read from the ["me"] query cache. App owns the FETCHING query (gated on +// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on // setup state) and renders modules only once it has resolved to a real user — so anywhere inside the // authenticated module tree the cache is always populated. `enabled:false` makes this a passive // observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache // updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook // instead of having it prop-drilled through App. export function useMe(): Me { - const { data } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + const { data } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); return data as Me; }