refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)
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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
|
import { qk } from "./lib/queryKeys";
|
||||||
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 { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
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
|
// 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).
|
// instance is set up (otherwise it would 503 against the setup-mode lock).
|
||||||
const setupQuery = useQuery({
|
const setupQuery = useQuery({
|
||||||
queryKey: ["setup-status"],
|
queryKey: qk.setupStatus(),
|
||||||
queryFn: api.setupStatus,
|
queryFn: api.setupStatus,
|
||||||
staleTime: Infinity,
|
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
|
// Only poll while signed in (data present) — polling on the public login page is pointless and
|
||||||
// its periodic refetch caused a visible flicker there.
|
// its periodic refetch caused a visible flicker there.
|
||||||
const meQuery = useQuery({
|
const meQuery = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: qk.me(),
|
||||||
queryFn: api.me,
|
queryFn: api.me,
|
||||||
enabled: setupQuery.isSuccess && !needsSetup,
|
enabled: setupQuery.isSuccess && !needsSetup,
|
||||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
||||||
@@ -173,7 +174,7 @@ export default function App() {
|
|||||||
// can't loop the public landing.
|
// can't loop the public landing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
if (queryClient.getQueryData(["me"])) {
|
if (queryClient.getQueryData(qk.me())) {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -223,7 +224,7 @@ export default function App() {
|
|||||||
|
|
||||||
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
|
// 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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const prefs = meQuery.data?.preferences;
|
const prefs = meQuery.data?.preferences;
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Info, Sparkles } from "lucide-react";
|
import { Info, Sparkles } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
@@ -21,7 +22,11 @@ export default function About({
|
|||||||
onOpenReleaseNotes: () => void;
|
onOpenReleaseNotes: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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][] = [
|
const rows: [string, string][] = [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||||
@@ -70,7 +71,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
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
|
// 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.
|
// with per-row disabling the admin can now start actions on several rows concurrently.
|
||||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
||||||
@@ -86,7 +87,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
api.setUserRole(id, role),
|
api.setUserRole(id, role),
|
||||||
onMutate: (vars) => startPending(vars.id),
|
onMutate: (vars) => startPending(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||||
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
||||||
},
|
},
|
||||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||||
@@ -98,7 +99,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
api.setUserSuspended(id, suspended),
|
api.setUserSuspended(id, suspended),
|
||||||
onMutate: (vars) => startPending(vars.id),
|
onMutate: (vars) => startPending(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
|
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),
|
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
|
||||||
onMutate: (vars) => startPending(vars.id),
|
onMutate: (vars) => startPending(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||||
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
||||||
},
|
},
|
||||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||||
@@ -267,11 +268,11 @@ function AdminInvites() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
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 [newEmail, setNewEmail] = useState("");
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-invites"] });
|
qc.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||||
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
|
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
|
||||||
};
|
};
|
||||||
const approve = useMutation({
|
const approve = useMutation({
|
||||||
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
|
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
|
||||||
@@ -383,14 +384,14 @@ function AdminDemo() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
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 [newEmail, setNewEmail] = useState("");
|
||||||
|
|
||||||
const add = useMutation({
|
const add = useMutation({
|
||||||
mutationFn: (email: string) => api.addDemoWhitelist(email),
|
mutationFn: (email: string) => api.addDemoWhitelist(email),
|
||||||
onSuccess: (_d, email) => {
|
onSuccess: (_d, email) => {
|
||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
|
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
||||||
notify({ level: "success", message: t("settings.demo.added", { email }) });
|
notify({ level: "success", message: t("settings.demo.added", { email }) });
|
||||||
},
|
},
|
||||||
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
||||||
@@ -398,7 +399,7 @@ function AdminDemo() {
|
|||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
|
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
||||||
notify({ level: "success", message: t("settings.demo.removed") });
|
notify({ level: "success", message: t("settings.demo.removed") });
|
||||||
},
|
},
|
||||||
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
@@ -18,7 +19,7 @@ export default function AuditLog() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
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 data = q.data;
|
||||||
const rows = useMemo(() => data?.items ?? [], [data]);
|
const rows = useMemo(() => data?.items ?? [], [data]);
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export default function AuditLog() {
|
|||||||
const clear = useMutation({
|
const clear = useMutation({
|
||||||
mutationFn: () => api.clearAudit(),
|
mutationFn: () => api.clearAudit(),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-audit"] });
|
qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
||||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||||
},
|
},
|
||||||
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
|
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||||
@@ -34,10 +35,10 @@ export default function ChatDock({ meId }: { meId: number }) {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
onMessage(({ message: m, from }) => {
|
onMessage(({ message: m, from }) => {
|
||||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||||
qc.invalidateQueries({ queryKey: ["message-unread"] });
|
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
||||||
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id;
|
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
|
// Auto-open/flash only for messages from someone else (not my own echo) and only for
|
||||||
// real user conversations.
|
// real user conversations.
|
||||||
if (m.kind === "user" && m.sender_id !== meId && from) {
|
if (m.kind === "user" && m.sender_id !== meId && from) {
|
||||||
@@ -51,8 +52,8 @@ export default function ChatDock({ meId }: { meId: number }) {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
onUnread(() => {
|
onUnread(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["message-unread"] });
|
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
||||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||||
}),
|
}),
|
||||||
[qc],
|
[qc],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
@@ -35,7 +36,7 @@ export default function ChatThread({
|
|||||||
const needsKey = !isSystem && !ready;
|
const needsKey = !isSystem && !ready;
|
||||||
|
|
||||||
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
|
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
|
||||||
["thread", partnerId],
|
qk.thread(partnerId),
|
||||||
() => api.thread(partnerId),
|
() => api.thread(partnerId),
|
||||||
{ intervalMs: POLL_MS, enabled: !needsKey },
|
{ 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;
|
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
||||||
if (incoming !== lastIncoming.current) {
|
if (incoming !== lastIncoming.current) {
|
||||||
lastIncoming.current = incoming;
|
lastIncoming.current = incoming;
|
||||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||||
qc.invalidateQueries({ queryKey: ["message-unread"] });
|
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [q.dataUpdatedAt, partnerId, meId, qc]);
|
}, [q.dataUpdatedAt, partnerId, meId, qc]);
|
||||||
@@ -123,8 +124,8 @@ export default function ChatThread({
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setDraft("");
|
setDraft("");
|
||||||
qc.invalidateQueries({ queryKey: ["thread", partnerId] });
|
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
|
||||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
|
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Plug, RotateCcw, Send } from "lucide-react";
|
import { Plug, RotateCcw, Send } from "lucide-react";
|
||||||
@@ -34,7 +35,7 @@ const GROUP_ORDER = [
|
|||||||
export default function ConfigPanel() {
|
export default function ConfigPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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 data = q.data;
|
||||||
|
|
||||||
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [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 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
|
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
||||||
setSaveState("saved");
|
setSaveState("saved");
|
||||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
@@ -22,11 +23,11 @@ export default function DownloadButton({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const me = qc.getQueryData<Me>(["me"]);
|
const me = qc.getQueryData<Me>(qk.me());
|
||||||
const isDemo = !!me?.is_demo;
|
const isDemo = !!me?.is_demo;
|
||||||
|
|
||||||
const indexQ = useQuery({
|
const indexQ = useQuery({
|
||||||
queryKey: ["download-index"],
|
queryKey: qk.downloadIndex(),
|
||||||
queryFn: api.downloadIndex,
|
queryFn: api.downloadIndex,
|
||||||
enabled: !isDemo,
|
enabled: !isDemo,
|
||||||
staleTime: 10000,
|
staleTime: 10000,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 {
|
import {
|
||||||
@@ -281,7 +282,7 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
|
|||||||
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -365,7 +366,7 @@ function UsageBar() {
|
|||||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
// 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
|
// 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.
|
// 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;
|
const u = usage.data;
|
||||||
if (!u) return null;
|
if (!u) return null;
|
||||||
const pct =
|
const pct =
|
||||||
@@ -408,7 +409,7 @@ function QuotaModal({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["dl-quota", userId],
|
queryKey: qk.dlQuota(userId),
|
||||||
queryFn: () => api.adminGetDownloadQuota(userId),
|
queryFn: () => api.adminGetDownloadQuota(userId),
|
||||||
});
|
});
|
||||||
const [form, setForm] = useState<{
|
const [form, setForm] = useState<{
|
||||||
@@ -429,8 +430,8 @@ function QuotaModal({
|
|||||||
}
|
}
|
||||||
: null);
|
: null);
|
||||||
const done = () => {
|
const done = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["dl-quota", userId] });
|
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||||
qc.invalidateQueries({ queryKey: ["admin-storage"] });
|
qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
@@ -516,7 +517,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
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 list = (users.data ?? []).filter((u) => !u.is_demo);
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const s = q.trim().toLowerCase();
|
const s = q.trim().toLowerCase();
|
||||||
@@ -565,8 +566,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
|
|||||||
|
|
||||||
function AdminSystem() {
|
function AdminSystem() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 });
|
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||||
const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 });
|
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||||
const s = storage.data;
|
const s = storage.data;
|
||||||
return (
|
return (
|
||||||
@@ -662,9 +663,9 @@ export default function DownloadCenter() {
|
|||||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||||
|
|
||||||
const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
|
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 });
|
||||||
const sharedQ = useQuery({
|
const sharedQ = useQuery({
|
||||||
queryKey: ["downloads-shared"],
|
queryKey: qk.downloadsShared(),
|
||||||
queryFn: api.downloadsShared,
|
queryFn: api.downloadsShared,
|
||||||
enabled: tab === "shared",
|
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).
|
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
||||||
const removeShared = useMutation({
|
const removeShared = useMutation({
|
||||||
mutationFn: (id: number) => api.removeSharedDownload(id),
|
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) => {
|
const confirmThen = async (title: string, body: string, fn: () => void) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
@@ -24,7 +25,7 @@ export default function DownloadDialog({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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<number | null>(null);
|
const [profileId, setProfileId] = useState<number | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
||||||
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
|
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||||
@@ -95,10 +96,10 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
|||||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
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
|
// 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.
|
// 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) => {
|
const setFilters = useCallback((next: FeedFilters) => {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { parseFeedView, type FeedView } from "../lib/feedView";
|
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)
|
// 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
|
// 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
|
// 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 {
|
interface FeedViewActions {
|
||||||
setView: (v: FeedView) => void;
|
setView: (v: FeedView) => void;
|
||||||
@@ -40,7 +41,7 @@ export function FeedViewProvider({ children }: { children: ReactNode }) {
|
|||||||
parseFeedView(readAccount(LS.feedView, null)),
|
parseFeedView(readAccount(LS.feedView, null)),
|
||||||
);
|
);
|
||||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
// 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.
|
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
|
||||||
const setView = useCallback((next: FeedView) => {
|
const setView = useCallback((next: FeedView) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const keyQ = useQuery({
|
const keyQ = useQuery({
|
||||||
queryKey: ["message-key"],
|
queryKey: qk.messageKey(),
|
||||||
queryFn: api.messageMyKey,
|
queryFn: api.messageMyKey,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
const configured = keyQ.data?.configured ?? false;
|
const configured = keyQ.data?.configured ?? false;
|
||||||
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
|
// 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.
|
// 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 hasPassword = meQ.data?.has_password ?? false;
|
||||||
|
|
||||||
const [view, setView] = useState<"gate" | "reset">("gate");
|
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);
|
const payload = await e2ee.setup(meId, pass);
|
||||||
await api.setupMessageKey(payload);
|
await api.setupMessageKey(payload);
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ["message-key"] });
|
qc.invalidateQueries({ queryKey: qk.messageKey() });
|
||||||
} catch {
|
} catch {
|
||||||
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
|
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,7 +78,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
await e2ee.resetLocal(meId);
|
await e2ee.resetLocal(meId);
|
||||||
setCurPass("");
|
setCurPass("");
|
||||||
setView("gate");
|
setView("gate");
|
||||||
qc.invalidateQueries({ queryKey: ["message-key"] });
|
qc.invalidateQueries({ queryKey: qk.messageKey() });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
|
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||||
@@ -74,7 +75,7 @@ function ConversationList({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const keyState = useKeyState();
|
const keyState = useKeyState();
|
||||||
const ready = keyState === "ready";
|
const ready = keyState === "ready";
|
||||||
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, {
|
const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, {
|
||||||
intervalMs: POLL_MS,
|
intervalMs: POLL_MS,
|
||||||
});
|
});
|
||||||
const items = q.data?.items ?? [];
|
const items = q.data?.items ?? [];
|
||||||
@@ -204,7 +205,7 @@ function ConversationList({
|
|||||||
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
|
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [filter, setFilter] = useState("");
|
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) =>
|
const users = (q.data ?? []).filter((u) =>
|
||||||
u.name.toLowerCase().includes(filter.trim().toLowerCase()),
|
u.name.toLowerCase().includes(filter.trim().toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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).
|
// Accounts that have signed in on this browser (loaded only while the popover is open).
|
||||||
const accountsQuery = useQuery({
|
const accountsQuery = useQuery({
|
||||||
queryKey: ["accounts"],
|
queryKey: qk.accounts(),
|
||||||
queryFn: api.accounts,
|
queryFn: api.accounts,
|
||||||
enabled: acctOpen,
|
enabled: acctOpen,
|
||||||
});
|
});
|
||||||
@@ -138,7 +139,7 @@ export default function NavSidebar({
|
|||||||
|
|
||||||
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
// 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.
|
// 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,
|
intervalMs: 30000,
|
||||||
});
|
});
|
||||||
// One indicator for both layers: durable server notifications + the client-side transient
|
// 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 clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
||||||
// Direct messages are their own module with their own unread badge (demo can't message).
|
// 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,
|
intervalMs: 30000,
|
||||||
enabled: !me.is_demo,
|
enabled: !me.is_demo,
|
||||||
});
|
});
|
||||||
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
||||||
// Download center badge = items still working (queued/running/paused). Reuses the same
|
// 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.
|
// 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,
|
intervalMs: 30000,
|
||||||
enabled: !me.is_demo,
|
enabled: !me.is_demo,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function NotificationsPanel() {
|
|||||||
const { focusChannel: onFocusChannel } = useChannelsActions();
|
const { focusChannel: onFocusChannel } = useChannelsActions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
||||||
["notifications"],
|
qk.notifications(),
|
||||||
() => api.notifications(),
|
() => api.notifications(),
|
||||||
{ intervalMs: POLL_MS },
|
{ intervalMs: POLL_MS },
|
||||||
);
|
);
|
||||||
@@ -72,8 +72,8 @@ export default function NotificationsPanel() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["notifications"] });
|
qc.invalidateQueries({ queryKey: qk.notifications() });
|
||||||
qc.invalidateQueries({ queryKey: ["notif-unread"] });
|
qc.invalidateQueries({ queryKey: qk.notifUnread() });
|
||||||
};
|
};
|
||||||
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
|
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
|
||||||
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
|
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
|
||||||
|
|||||||
@@ -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.
|
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
|
||||||
const savedPrefs = useMemo(
|
const savedPrefs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
|
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ?? {}) as {
|
||||||
playerAutoAdvance?: AutoMode;
|
playerAutoAdvance?: AutoMode;
|
||||||
playerLoop?: LoopMode;
|
playerLoop?: LoopMode;
|
||||||
},
|
},
|
||||||
@@ -393,7 +393,7 @@ export default function PlayerModal({
|
|||||||
modeRef.current = { autoMode, loopMode };
|
modeRef.current = { autoMode, loopMode };
|
||||||
const persistPref = (patch: Record<string, string>) => {
|
const persistPref = (patch: Record<string, string>) => {
|
||||||
api.savePrefs(patch).catch(() => {});
|
api.savePrefs(patch).catch(() => {});
|
||||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
||||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -138,7 +139,7 @@ export default function PlexBrowse() {
|
|||||||
}, [sub]);
|
}, [sub]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
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",
|
enabled: !!scope && sub.view.kind === "grid",
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
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
|
// 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.
|
// grid so you SEE who you're exploring, not just chips in the rail.
|
||||||
const peopleCardsQ = useQuery({
|
const peopleCardsQ = useQuery({
|
||||||
queryKey: ["plex-people-cards", scope, filters.actors],
|
queryKey: qk.plexPeopleCards(scope, filters.actors),
|
||||||
queryFn: () => api.plexPeopleCards(filters.actors, scope),
|
queryFn: () => api.plexPeopleCards(filters.actors, scope),
|
||||||
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
|
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
|
||||||
staleTime: 60_000,
|
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
|
// 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.
|
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
|
||||||
function optimisticStatus(id: string, next: string) {
|
function optimisticStatus(id: string, next: string) {
|
||||||
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: ["plex-library"] }, (old) =>
|
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: qk.plexLibrary() }, (old) =>
|
||||||
old
|
old
|
||||||
? {
|
? {
|
||||||
...old,
|
...old,
|
||||||
@@ -223,7 +224,7 @@ export default function PlexBrowse() {
|
|||||||
const next = card.status === "watched" ? "new" : "watched";
|
const next = card.status === "watched" ? "new" : "watched";
|
||||||
optimisticStatus(card.id, next);
|
optimisticStatus(card.id, next);
|
||||||
await api.plexSetState(card.id, next).catch(() => {});
|
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.
|
// 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
|
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
|
||||||
@@ -587,7 +588,7 @@ function PlexInfoView({
|
|||||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
@@ -614,7 +615,7 @@ function PlexInfoView({
|
|||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onPlayItem={onPlayItem}
|
onPlayItem={onPlayItem}
|
||||||
onFilter={onFilter}
|
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.
|
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -624,7 +625,7 @@ function PlexInfoView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Series drill-down: show detail page → season subpage → player -------------------------------
|
// --- 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
|
// 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),
|
// 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 —
|
// 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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin";
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
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 d = q.data;
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
const [collOpen, setCollOpen] = useState(false);
|
const [collOpen, setCollOpen] = useState(false);
|
||||||
@@ -1009,13 +1010,13 @@ function PlexShowView({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
}
|
}
|
||||||
async function markSeason(seasonRk: string, w: boolean) {
|
async function markSeason(seasonRk: string, w: boolean) {
|
||||||
await api.plexSeasonState(seasonRk, w).catch(() => {});
|
await api.plexSeasonState(seasonRk, w).catch(() => {});
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1208,7 +1209,7 @@ function PlexShowView({
|
|||||||
library={showLib}
|
library={showLib}
|
||||||
memberOf={show.collection_keys}
|
memberOf={show.collection_keys}
|
||||||
onClose={() => setCollOpen(false)}
|
onClose={() => setCollOpen(false)}
|
||||||
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
|
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1228,7 +1229,7 @@ function PlexSeasonView({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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 d = q.data;
|
||||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
@@ -1245,13 +1246,13 @@ function PlexSeasonView({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
}
|
}
|
||||||
async function markEpisode(ep: PlexCard) {
|
async function markEpisode(ep: PlexCard) {
|
||||||
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {});
|
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {});
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Check, Plus } from "lucide-react";
|
import { Check, Plus } from "lucide-react";
|
||||||
@@ -35,7 +36,7 @@ export default function PlexCollectionEditor({
|
|||||||
const [busy, setBusy] = useState<string | null>(null); // collection id currently mutating
|
const [busy, setBusy] = useState<string | null>(null); // collection id currently mutating
|
||||||
|
|
||||||
const listQ = useQuery({
|
const listQ = useQuery({
|
||||||
queryKey: ["plex-collections", library],
|
queryKey: qk.plexCollections(library),
|
||||||
queryFn: () => api.plexCollections(library),
|
queryFn: () => api.plexCollections(library),
|
||||||
});
|
});
|
||||||
const editable = useMemo(
|
const editable = useMemo(
|
||||||
@@ -54,9 +55,9 @@ export default function PlexCollectionEditor({
|
|||||||
const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable;
|
const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable;
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["plex-collections"] });
|
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-item", item.id] });
|
qc.invalidateQueries({ queryKey: qk.plexItem(item.id) });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
onChanged();
|
onChanged();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
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
|
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
|
||||||
// is PlexInfo-specific state, persisted through the same savePref.
|
// is PlexInfo-specific state, persisted through the same savePref.
|
||||||
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
|
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
|
||||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||||
{}) as { plexHiddenStripSources?: string[] };
|
{}) as { plexHiddenStripSources?: string[] };
|
||||||
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
// 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.
|
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
|
||||||
@@ -66,7 +67,7 @@ export default function PlexInfo({
|
|||||||
return next;
|
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 [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [playlistOpen, setPlaylistOpen] = 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 inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
||||||
const setState = async (status: "new" | "watched") => {
|
const setState = async (status: "new" | "watched") => {
|
||||||
await api.plexSetState(detail.id, status).catch(() => {});
|
await api.plexSetState(detail.id, status).catch(() => {});
|
||||||
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
qc.invalidateQueries({ queryKey: qk.plexShow() });
|
||||||
onStateChange?.();
|
onStateChange?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type WheelEvent as ReactWheelEvent,
|
type WheelEvent as ReactWheelEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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";
|
||||||
@@ -196,7 +197,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [id, setId] = useState(itemId);
|
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 detail = detailQ.data;
|
||||||
const duration = detail?.duration_seconds ?? 0;
|
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.
|
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
|
||||||
if (absRef.current > 0 && durationRef.current > 0) {
|
if (absRef.current > 0 && durationRef.current > 0) {
|
||||||
const pos = Math.floor(absRef.current);
|
const pos = Math.floor(absRef.current);
|
||||||
qc.setQueryData(["plex-item", id], (old) =>
|
qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
|
||||||
old ? { ...old, position_seconds: pos } : old,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
qc.invalidateQueries({ queryKey: qk.plexShow() });
|
||||||
};
|
};
|
||||||
}, [id, qc]);
|
}, [id, qc]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Check, Minus, Plus } from "lucide-react";
|
import { Check, Minus, Plus } from "lucide-react";
|
||||||
@@ -35,15 +36,15 @@ export default function PlexPlaylistAdd({
|
|||||||
|
|
||||||
const listQ = useQuery({
|
const listQ = useQuery({
|
||||||
queryKey: isGroup
|
queryKey: isGroup
|
||||||
? ["plex-playlists", "group", target.ratingKeys]
|
? qk.plexPlaylists("group", target.ratingKeys)
|
||||||
: ["plex-playlists", "contains", target.ratingKey],
|
: qk.plexPlaylists("contains", target.ratingKey),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
isGroup
|
isGroup
|
||||||
? api.plexPlaylists(undefined, target.ratingKeys)
|
? api.plexPlaylists(undefined, target.ratingKeys)
|
||||||
: api.plexPlaylists(target.ratingKey),
|
: api.plexPlaylists(target.ratingKey),
|
||||||
});
|
});
|
||||||
const playlists = listQ.data?.playlists ?? [];
|
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).
|
// How many of the target a playlist currently holds (0..size).
|
||||||
function inCount(p: PlexPlaylist): number {
|
function inCount(p: PlexPlaylist): number {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 {
|
import {
|
||||||
@@ -375,7 +376,7 @@ export default function PlexPlaylistView({
|
|||||||
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
|
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["plex-playlist", playlistId],
|
queryKey: qk.plexPlaylist(playlistId),
|
||||||
queryFn: () => api.plexPlaylist(playlistId),
|
queryFn: () => api.plexPlaylist(playlistId),
|
||||||
});
|
});
|
||||||
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
|
// 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 order = useMemo(() => items.map((i) => i.id), [items]);
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] });
|
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||||
};
|
};
|
||||||
|
|
||||||
function commit(next: PlexCard[]) {
|
function commit(next: PlexCard[]) {
|
||||||
@@ -398,7 +399,7 @@ export default function PlexPlaylistView({
|
|||||||
playlistId,
|
playlistId,
|
||||||
next.map((i) => i.id),
|
next.map((i) => i.id),
|
||||||
)
|
)
|
||||||
.then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] }));
|
.then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRemove(keys: string[]) {
|
async function onRemove(keys: string[]) {
|
||||||
@@ -431,7 +432,7 @@ export default function PlexPlaylistView({
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
await api.plexDeletePlaylist(playlistId);
|
await api.plexDeletePlaylist(playlistId);
|
||||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||||
onBack();
|
onBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||||
@@ -84,7 +85,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
setPlaylistOpen: onOpenPlaylist,
|
setPlaylistOpen: onOpenPlaylist,
|
||||||
} = usePlexActions();
|
} = usePlexActions();
|
||||||
const collFade = useScrollFade();
|
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 [newPlaylist, setNewPlaylist] = useState("");
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -93,12 +94,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
if (!title) return;
|
if (!title) return;
|
||||||
const pl = await api.plexCreatePlaylist(title);
|
const pl = await api.plexCreatePlaylist(title);
|
||||||
setNewPlaylist("");
|
setNewPlaylist("");
|
||||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||||
onOpenPlaylist(pl.id);
|
onOpenPlaylist(pl.id);
|
||||||
}
|
}
|
||||||
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
|
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: ["plex-facets", scope, show, facetKey],
|
queryKey: qk.plexFacets(scope, show, facetKey),
|
||||||
queryFn: () => api.plexFacets(scope, filters, show),
|
queryFn: () => api.plexFacets(scope, filters, show),
|
||||||
enabled: !!scope,
|
enabled: !!scope,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
@@ -108,7 +109,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
const [collSearch, setCollSearch] = useState("");
|
const [collSearch, setCollSearch] = useState("");
|
||||||
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
||||||
const collectionsQ = useQuery({
|
const collectionsQ = useQuery({
|
||||||
queryKey: ["plex-collections", "union", collSearchDeb],
|
queryKey: qk.plexCollections("union", collSearchDeb),
|
||||||
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
|
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
|
||||||
enabled: !filters.collection,
|
enabled: !filters.collection,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -68,7 +69,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||||
const saveTimer = useRef<number | undefined>(undefined);
|
const saveTimer = useRef<number | undefined>(undefined);
|
||||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
// 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
|
// 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.
|
// stores too); persistence to the server is the auto-save effect below.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
@@ -42,7 +43,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
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 profiles = profilesQ.data ?? [];
|
||||||
const builtins = profiles.filter((p) => p.is_builtin);
|
const builtins = profiles.filter((p) => p.is_builtin);
|
||||||
const mine = 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<DownloadSpec>(BLANK);
|
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] });
|
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
|
||||||
const startNew = () => {
|
const startNew = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setName("");
|
setName("");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -340,7 +341,7 @@ export default function Scheduler() {
|
|||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
// Poll faster while any job is running so live progress is smooth, then ease back when
|
// 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).
|
// idle. Derived from the freshest data by react-query itself (see useLiveQuery).
|
||||||
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
|
const q = useLiveQuery<SchedulerStatus>(qk.scheduler(), api.schedulerStatus, {
|
||||||
intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
|
intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
|
||||||
});
|
});
|
||||||
const data = q.data;
|
const data = q.data;
|
||||||
@@ -376,14 +377,14 @@ export default function Scheduler() {
|
|||||||
|
|
||||||
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: ["scheduler"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||||
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
|
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const intervalMut = useMutation({
|
const intervalMut = useMutation({
|
||||||
mutationFn: (v: { jobId: string; minutes: number }) =>
|
mutationFn: (v: { jobId: string; minutes: number }) =>
|
||||||
api.updateSchedulerJob(v.jobId, v.minutes),
|
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") }),
|
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,7 +397,7 @@ export default function Scheduler() {
|
|||||||
level: "success",
|
level: "success",
|
||||||
message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }),
|
message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }),
|
||||||
});
|
});
|
||||||
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
qc.invalidateQueries({ queryKey: qk.scheduler() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const runAllMut = useMutation({
|
const runAllMut = useMutation({
|
||||||
@@ -406,12 +407,12 @@ export default function Scheduler() {
|
|||||||
level: "success",
|
level: "success",
|
||||||
message: t("scheduler.triggeredAll", { count: res.started.length }),
|
message: t("scheduler.triggeredAll", { count: res.started.length }),
|
||||||
});
|
});
|
||||||
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
qc.invalidateQueries({ queryKey: qk.scheduler() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const batchMut = useMutation({
|
const batchMut = useMutation({
|
||||||
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||||
});
|
});
|
||||||
const purgeMut = useMutation({
|
const purgeMut = useMutation({
|
||||||
mutationFn: () => api.purgeDiscovery(),
|
mutationFn: () => api.purgeDiscovery(),
|
||||||
@@ -419,7 +420,7 @@ export default function Scheduler() {
|
|||||||
const removed =
|
const removed =
|
||||||
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
||||||
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
|
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
|
||||||
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
qc.invalidateQueries({ queryKey: qk.scheduler() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
||||||
@@ -387,7 +388,7 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
setCurrent("");
|
setCurrent("");
|
||||||
setNext("");
|
setNext("");
|
||||||
// Refresh `me` so has_password flips and the section switches to "Change password".
|
// 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({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: t("settings.account.password.saved", { email: me.email }),
|
message: t("settings.account.password.saved", { email: me.email }),
|
||||||
@@ -516,7 +517,7 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
function PlexWatchSync() {
|
function PlexWatchSync() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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 enabled = link.data?.sync_enabled ?? false;
|
||||||
|
|
||||||
const announce = (imp: { watched: number; in_progress: number } | null | undefined) => {
|
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.
|
// 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({
|
const toggle = useMutation({
|
||||||
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
qc.setQueryData(["plex-watch-link"], res);
|
qc.setQueryData(qk.plexWatchLink(), res);
|
||||||
announce(res.import);
|
announce(res.import);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reimport = useMutation({
|
const reimport = useMutation({
|
||||||
mutationFn: () => api.plexWatchImport(),
|
mutationFn: () => api.plexWatchImport(),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
qc.setQueryData(["plex-watch-link"], res);
|
qc.setQueryData(qk.plexWatchLink(), res);
|
||||||
announce(res.import);
|
announce(res.import);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||||
@@ -17,7 +18,7 @@ function UserShare({ job }: { job: DownloadJob }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
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 list = recipients.data ?? [];
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const s = q.trim().toLowerCase();
|
const s = q.trim().toLowerCase();
|
||||||
@@ -245,14 +246,14 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const links = useQuery({
|
const links = useQuery({
|
||||||
queryKey: ["download-links", job.id],
|
queryKey: qk.downloadLinks(job.id),
|
||||||
queryFn: () => api.downloadLinks(job.id),
|
queryFn: () => api.downloadLinks(job.id),
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [allowDownload, setAllowDownload] = useState(false);
|
const [allowDownload, setAllowDownload] = useState(false);
|
||||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
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({
|
const create = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function Overview({ me }: { me: Me }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const status = useQuery({ queryKey: qk.myStatus(), 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: qk.myUsage(), queryFn: api.myUsage });
|
||||||
const s = status.data;
|
const s = status.data;
|
||||||
const syncSubs = useMutation({
|
const syncSubs = useMutation({
|
||||||
mutationFn: () => api.syncSubscriptions(),
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
@@ -200,7 +200,7 @@ function SystemStats() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
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: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
|
||||||
const status = useQuery({ queryKey: qk.myStatus(), 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()),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
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 {
|
import {
|
||||||
@@ -73,7 +74,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const aspect =
|
const aspect =
|
||||||
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
||||||
const sb = useQuery({
|
const sb = useQuery({
|
||||||
queryKey: ["storyboard", job.id],
|
queryKey: qk.storyboard(job.id),
|
||||||
queryFn: () => api.downloadStoryboard(job.id),
|
queryFn: () => api.downloadStoryboard(job.id),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -307,8 +308,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
return { files: N };
|
return { files: N };
|
||||||
},
|
},
|
||||||
onSuccess: ({ files }) => {
|
onSuccess: ({ files }) => {
|
||||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||||
notify({ level: "success", message: t("editor.created", { count: files }) });
|
notify({ level: "success", message: t("editor.created", { count: files }) });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { shouldAutoOpenOnboarding } from "../lib/onboarding";
|
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
|
// 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
|
// 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.
|
// fetching query), so it re-runs the auto-open check when the account resolves or its grants change.
|
||||||
|
|
||||||
interface WizardActions {
|
interface WizardActions {
|
||||||
@@ -43,7 +44,7 @@ export function useWizard(): WizardActions {
|
|||||||
export function WizardProvider({ children }: { children: ReactNode }) {
|
export function WizardProvider({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
// 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 openWizard = useCallback(() => setOpen(true), []);
|
||||||
const closeWizard = useCallback(() => setOpen(false), []);
|
const closeWizard = useCallback(() => setOpen(false), []);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
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
|
// 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.
|
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
||||||
export function invalidateDownloads(qc: QueryClient) {
|
export function invalidateDownloads(qc: QueryClient) {
|
||||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||||
qc.invalidateQueries({ queryKey: ["download-index"] });
|
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
||||||
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Shared messaging helpers used by both the Messages page and the floating chat dock.
|
// Shared messaging helpers used by both the Messages page and the floating chat dock.
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { qk } from "./queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
|
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
|
||||||
@@ -57,7 +58,7 @@ function useUnlocked(): boolean {
|
|||||||
export type KeyState = "loading" | "setup" | "unlock" | "ready";
|
export type KeyState = "loading" | "setup" | "unlock" | "ready";
|
||||||
export function useKeyState(): KeyState {
|
export function useKeyState(): KeyState {
|
||||||
const unlocked = useUnlocked();
|
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.isLoading && !q.data) return "loading";
|
||||||
if (!(q.data?.configured ?? false)) return "setup";
|
if (!(q.data?.configured ?? false)) return "setup";
|
||||||
if (!unlocked) return "unlock";
|
if (!unlocked) return "unlock";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { qk } from "./queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { SlidersHorizontal } from "lucide-react";
|
import { SlidersHorizontal } from "lucide-react";
|
||||||
@@ -12,7 +13,7 @@ import { api } from "./api";
|
|||||||
|
|
||||||
export function useDetailPrefs() {
|
export function useDetailPrefs() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||||
{}) as {
|
{}) as {
|
||||||
plexInfoArtBg?: boolean;
|
plexInfoArtBg?: boolean;
|
||||||
plexInfoCast?: boolean;
|
plexInfoCast?: boolean;
|
||||||
@@ -23,7 +24,7 @@ export function useDetailPrefs() {
|
|||||||
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
||||||
const save = (patch: Record<string, unknown>) => {
|
const save = (patch: Record<string, unknown>) => {
|
||||||
api.savePrefs(patch).catch(() => {});
|
api.savePrefs(patch).catch(() => {});
|
||||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
||||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,4 +41,62 @@ export const qk = {
|
|||||||
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
|
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
|
||||||
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
|
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
|
||||||
savedViews: () => ["saved-views"] 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { qk } from "./queryKeys";
|
||||||
import { api, type Me } from "./api";
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// instead of having it prop-drilled through App.
|
||||||
export function useMe(): Me {
|
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;
|
return data as Me;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user