Spaces:
Sleeping
Sleeping
| 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), | |
| }); | |
| } | |