kink-discovery / frontend /mutations.js
Perplexed7675's picture
Sync from kink_cli (Docker Space)
a814fd9 verified
Raw
History Blame Contribute Delete
14.6 kB
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, authHeaders } from "./api.js";
import { optimisticBoard, removeKinkFromItemsPayload } from "./board-utils.js";
const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
export function useLoginMutation({ setAuth, setCreateResult, setAuthOpen, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ user_id, private_token }) =>
api("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id, private_token }),
}),
onMutate: () => setStatus("Opening profile…"),
onSuccess: (payload, vars) => {
setAuth({ userId: vars.user_id, token: vars.private_token });
setCreateResult(null);
setAuthOpen(false);
qc.setQueryData(["user", vars.user_id], payload.user);
setStatus("Profile open. Loading your saved plays…");
},
onError: (error) => setStatus(error.message),
});
}
export function useCreateMutation({ setAuth, setCreateResult, setLoginForm, setStatus }) {
return useMutation({
mutationFn: () =>
api("/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
}),
onMutate: () => {
setStatus(
"Creating profile… If this sits here a while, the Space is still loading the catalog (first boot can take several minutes).",
);
},
onSuccess: (payload) => {
setAuth({ userId: payload.id, token: payload.private_token });
setCreateResult(payload);
setLoginForm({ user_id: payload.id, private_token: payload.private_token });
setStatus("Profile created. Choose roles, then open your Play List.");
},
onError: (error) => setStatus(error.message),
});
}
export function useSavePlayMutation({
auth,
activeGroupId,
selectedKinkId,
setSelectedKinkId,
setStatus,
pendingPlayCommitRef,
playCommitKey,
syncPostSaveCaches,
onSaveStart = () => {},
onSaveSettled = () => {},
}) {
const qc = useQueryClient();
return useMutation({
mutationKey: ["savePlay"],
mutationFn: ({ kinkId, interestState, directions }) =>
api(`/users/${auth.userId}/plays`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ kink_id: kinkId, interest_state: interestState, directions }),
}),
onMutate: async (vars) => {
onSaveStart();
const directions = vars.directions || [];
/** Do not cancel/refetch the user query here: refetchInterval can still deliver a stale GET that
* replaces the cache after this onMutate runs but before onSuccess, wiping the optimistic play
* and leaving the same top card so the next tap dedupes to “no new save” while the counter stays flat. */
const recsKey = ["recs", auth.userId, activeGroupId];
const previousUser = qc.getQueryData(["user", auth.userId]);
const previousBoard = qc.getQueryData(["board", auth.userId]);
const previousRecs = qc.getQueryData(recsKey);
const playKey = String(vars.kinkId);
qc.setQueryData(["user", auth.userId], (old) => {
const base = old && typeof old === "object" ? old : { id: auth.userId, plays: {} };
return {
...base,
plays: {
...(base.plays || {}),
[playKey]: { interest_state: vars.interestState, directions },
},
};
});
if (previousRecs?.items) {
qc.setQueryData(recsKey, (old) => removeKinkFromItemsPayload(old, playKey));
}
qc.setQueryData(["board", auth.userId], (old) =>
optimisticBoard(old || previousBoard || EMPTY_BOARD, playKey, vars.interestState, directions, qc, auth),
);
if (!selectedKinkId) setSelectedKinkId(playKey);
return {
previousUser,
previousBoard,
previousRecs,
recsKey,
commitKey: playCommitKey(playKey, vars.interestState, directions),
};
},
onSuccess: (payload, vars, context) => {
syncPostSaveCaches(payload, String(vars.kinkId), vars.interestState, vars.directions || []);
/** Refetch play-board from server so My Plays never stays on a partial optimistic board (e.g. board cache was empty when save ran). */
qc.invalidateQueries({ queryKey: ["board", auth.userId] });
/** Avoid immediate recs/prompts refetch: it re-randomized the deck and made the next card “flash” away. Server still updates user/board via payload + optimistic cache. */
// No "Saved." status update — the deck advancing IS the feedback; the persistent text was friction.
},
onError: (error, _vars, context) => {
if (context?.previousUser) qc.setQueryData(["user", auth.userId], context.previousUser);
if (context?.previousBoard) qc.setQueryData(["board", auth.userId], context.previousBoard);
if (context?.recsKey) qc.setQueryData(context.recsKey, context.previousRecs);
setStatus(error.message);
},
onSettled: (_payload, _error, _vars, context) => {
onSaveSettled();
if (context?.commitKey && pendingPlayCommitRef.current === context.commitKey) {
pendingPlayCommitRef.current = "";
}
},
});
}
export function useDeletePlayMutation({ auth, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: (kinkId) =>
api(`/users/${auth.userId}/plays/${encodeURIComponent(kinkId)}`, {
method: "DELETE",
headers: authHeaders(auth.token),
}),
onMutate: async (kinkId) => {
const playKey = String(kinkId);
const previousUser = qc.getQueryData(["user", auth.userId]);
const previousBoard = qc.getQueryData(["board", auth.userId]);
qc.setQueryData(["user", auth.userId], (old) => {
if (!old?.plays) return old;
const { [playKey]: _drop, ...rest } = old.plays;
return { ...old, plays: rest };
});
qc.setQueryData(["board", auth.userId], (old) => {
if (!old) return old;
const next = { ...old };
for (const col of ["to_me", "by_me", "together", "hidden", "no_go"]) {
if (Array.isArray(next[col])) next[col] = next[col].filter((item) => String(item?.id) !== playKey);
}
return next;
});
return { previousUser, previousBoard };
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["board", auth.userId] });
qc.invalidateQueries({ queryKey: ["user", auth.userId] });
setStatus?.("Removed.");
},
onError: (error, _kinkId, context) => {
if (context?.previousUser) qc.setQueryData(["user", auth.userId], context.previousUser);
if (context?.previousBoard) qc.setQueryData(["board", auth.userId], context.previousBoard);
setStatus?.(error.message);
},
});
}
export function useSaveRoleMutation({ auth, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ kinkId, selected }) =>
api(`/users/${auth.userId}/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ kink_id: kinkId, selected }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["user", auth.userId] });
qc.invalidateQueries({ queryKey: ["roles", auth.userId] });
setStatus("Role saved.");
},
onError: (error) => setStatus(error.message),
});
}
export function usePrefMutation({ auth, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: (show_images) =>
api(`/users/${auth.userId}/preferences`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ show_images }),
}),
onSuccess: (payload) => qc.setQueryData(["user", auth.userId], payload),
onError: (error) => setStatus(error.message),
});
}
export function useScenarioPreferenceMutation({ auth, activeGroupId, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ parentKinkId, scenarioKinkId, interestState, directions = [], remove = false }) => {
if (remove) {
return api(`/users/${auth.userId}/scenario-preferences/${encodeURIComponent(scenarioKinkId)}`, {
method: "DELETE",
headers: authHeaders(auth.token),
});
}
return api(`/users/${auth.userId}/scenario-preferences`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({
parent_kink_id: parentKinkId,
scenario_kink_id: scenarioKinkId,
interest_state: interestState,
directions,
}),
});
},
onSuccess: (payload) => {
qc.setQueryData(["user", auth.userId], payload);
qc.invalidateQueries({ queryKey: ["scenario-parents", auth.userId] });
if (activeGroupId) qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, activeGroupId] });
setStatus("Scenario saved.");
},
onError: (error) => setStatus(error.message),
});
}
function syncPartnerGroupsFromUserPayload(qc, userId, payload) {
if (payload?.partner_groups != null) {
qc.setQueryData(["partner-groups", userId], { items: payload.partner_groups });
}
}
export function useLinkMutation({ auth, setGroupDraftMembers, setGroupNameInput, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: (partner_id) =>
api(`/users/${auth.userId}/partners`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ partner_id }),
}),
onSuccess: (payload, partnerId) => {
qc.setQueryData(["user", auth.userId], payload);
syncPartnerGroupsFromUserPayload(qc, auth.userId, payload);
qc.invalidateQueries({ queryKey: ["partner-groups", auth.userId] });
if (payload.partners?.includes(partnerId)) {
setGroupDraftMembers([partnerId]);
setGroupNameInput(`Shared with ${partnerId}`);
setStatus("You're linked. A shared list \"Together\" was created for both of you.");
} else {
setStatus("Link request sent. When they accept, you will both get a shared \"Together\" list.");
}
},
onError: (error) => setStatus(error.message),
});
}
export function usePartnerRequestMutations({ auth, setStatus }) {
const qc = useQueryClient();
const patchUser = (payload) => {
qc.setQueryData(["user", auth.userId], payload);
syncPartnerGroupsFromUserPayload(qc, auth.userId, payload);
qc.invalidateQueries({ queryKey: ["partner-groups", auth.userId] });
};
const accept = useMutation({
mutationFn: (from_user_id) =>
api(`/users/${auth.userId}/partner-requests/accept`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ from_user_id }),
}),
onSuccess: (payload) => {
patchUser(payload);
setStatus("Linked. A shared \"Together\" list is ready — you can add more lists anytime.");
},
onError: (error) => setStatus(error.message),
});
const decline = useMutation({
mutationFn: (from_user_id) =>
api(`/users/${auth.userId}/partner-requests/decline`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ from_user_id }),
}),
onSuccess: (payload) => {
patchUser(payload);
setStatus("Request declined.");
},
onError: (error) => setStatus(error.message),
});
const cancel = useMutation({
mutationFn: (to_user_id) =>
api(`/users/${auth.userId}/partner-requests/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ to_user_id }),
}),
onSuccess: (payload) => {
patchUser(payload);
setStatus("Request withdrawn.");
},
onError: (error) => setStatus(error.message),
});
return { accept, decline, cancel };
}
export function useCreateGroupMutation({ auth, setActiveGroupId, setGroupNameInput, setGroupDraftMembers, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, memberIds }) =>
api(`/users/${auth.userId}/partner-groups`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ name, member_ids: memberIds }),
}),
onSuccess: (payload) => {
qc.invalidateQueries({ queryKey: ["user", auth.userId] });
qc.invalidateQueries({ queryKey: ["partner-groups", auth.userId] });
setActiveGroupId(payload.id);
setGroupNameInput("");
setGroupDraftMembers([]);
setStatus("Group created.");
},
onError: (error) => setStatus(error.message),
});
}
export function useRemoveGroupMemberMutation({ auth, removeMemberFromGroupCache, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, memberId }) =>
api(`/users/${auth.userId}/partner-groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(memberId)}`, {
method: "DELETE",
headers: authHeaders(auth.token),
}),
onSuccess: (_payload, vars) => {
removeMemberFromGroupCache(vars.groupId, vars.memberId);
qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, vars.groupId] });
qc.invalidateQueries({ queryKey: ["recs", auth.userId, vars.groupId] });
setStatus("Group updated.");
},
onError: (error) => setStatus(error.message),
});
}
export function useShareToggleMutation({ auth, replacePartnerGroup, setStatus }) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, share }) =>
api(`/users/${auth.userId}/partner-groups/${encodeURIComponent(groupId)}/share-toggle`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(auth.token) },
body: JSON.stringify({ share }),
}),
onSuccess: (_payload, vars) => {
replacePartnerGroup({ id: vars.groupId, my_share_full_list: vars.share });
setStatus("Sharing preference updated.");
qc.invalidateQueries({ queryKey: ["group-overlap", auth.userId, vars.groupId] });
},
onError: (error) => setStatus(error.message),
});
}