Files
Arkie-Library-Frontend/src/favorites/FavoritesProvider.tsx

171 lines
5.1 KiB
TypeScript
Raw Normal View History

2026-06-02 00:36:11 +08:00
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useToast } from "../components/Toast";
import { useI18n } from "../i18n";
import { useWallet } from "../wallet/WalletProvider";
import { addFavorite, getFavoriteIds, removeFavorite } from "./api";
type FavoriteStatus = "unknown" | "favorited" | "notFavorited";
type FavoritesContextValue = {
favoriteIds: Set<string>;
pendingIds: Set<string>;
statusFor: (resourceId: string) => FavoriteStatus;
ensureFavoriteIds: (resourceIds: string[]) => Promise<void>;
toggleFavorite: (resourceId: string) => Promise<void>;
markFavorite: (resourceId: string, favorited: boolean) => void;
};
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
export function FavoritesProvider({ children }: { children: ReactNode }) {
const { t } = useI18n();
const { showToast } = useToast();
const wallet = useWallet();
2026-06-02 00:57:37 +08:00
const { address, openLoginModal, status, token } = wallet;
2026-06-02 00:36:11 +08:00
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(() => new Set());
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
const pendingAfterLoginRef = useRef<string | null>(null);
2026-06-02 00:57:37 +08:00
const lastAddressRef = useRef<string | null>(null);
2026-06-02 00:36:11 +08:00
useEffect(() => {
2026-06-02 00:57:37 +08:00
const nextAddress = status === "loggedIn" ? address : null;
if (lastAddressRef.current === nextAddress) return;
lastAddressRef.current = nextAddress;
setFavoriteIds(new Set());
setKnownIds(new Set());
setPendingIds(new Set());
if (!nextAddress) pendingAfterLoginRef.current = null;
}, [address, status]);
2026-06-02 00:36:11 +08:00
const markFavorite = useCallback((resourceId: string, favorited: boolean) => {
setKnownIds((prev) => new Set(prev).add(resourceId));
setFavoriteIds((prev) => {
const next = new Set(prev);
if (favorited) next.add(resourceId);
else next.delete(resourceId);
return next;
});
}, []);
const ensureFavoriteIds = useCallback(
async (resourceIds: string[]) => {
2026-06-02 00:57:37 +08:00
if (!token || status !== "loggedIn") return;
2026-06-02 00:36:11 +08:00
const missing = [...new Set(resourceIds)].filter(
(id) => !knownIds.has(id),
);
if (missing.length === 0) return;
2026-06-02 00:57:37 +08:00
const ids = await getFavoriteIds(token, missing);
2026-06-02 00:36:11 +08:00
setKnownIds((prev) => {
const next = new Set(prev);
missing.forEach((id) => next.add(id));
return next;
});
setFavoriteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.add(id));
return next;
});
},
2026-06-02 00:57:37 +08:00
[knownIds, status, token],
2026-06-02 00:36:11 +08:00
);
const runFavoriteMutation = useCallback(
async (resourceId: string) => {
2026-06-02 00:57:37 +08:00
if (!token) return;
2026-06-02 00:36:11 +08:00
const currentlyFavorite = favoriteIds.has(resourceId);
setPendingIds((prev) => new Set(prev).add(resourceId));
markFavorite(resourceId, !currentlyFavorite);
try {
2026-06-02 00:57:37 +08:00
if (currentlyFavorite) await removeFavorite(token, resourceId);
else await addFavorite(token, resourceId);
2026-06-02 00:36:11 +08:00
showToast(
currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"),
);
} catch (error) {
markFavorite(resourceId, currentlyFavorite);
showToast(t("favoriteFailed"), "error");
throw error;
} finally {
setPendingIds((prev) => {
const next = new Set(prev);
next.delete(resourceId);
return next;
});
}
},
2026-06-02 00:57:37 +08:00
[favoriteIds, markFavorite, showToast, t, token],
2026-06-02 00:36:11 +08:00
);
const toggleFavorite = useCallback(
async (resourceId: string) => {
2026-06-02 00:57:37 +08:00
if (!token || status !== "loggedIn") {
2026-06-02 00:36:11 +08:00
pendingAfterLoginRef.current = resourceId;
2026-06-02 00:57:37 +08:00
openLoginModal();
2026-06-02 00:36:11 +08:00
showToast(t("favoriteLoginRequired"));
return;
}
await runFavoriteMutation(resourceId);
},
2026-06-02 00:57:37 +08:00
[openLoginModal, runFavoriteMutation, showToast, status, t, token],
2026-06-02 00:36:11 +08:00
);
useEffect(() => {
2026-06-02 00:57:37 +08:00
if (status !== "loggedIn" || !token) return;
2026-06-02 00:36:11 +08:00
const pending = pendingAfterLoginRef.current;
if (!pending) return;
pendingAfterLoginRef.current = null;
void runFavoriteMutation(pending).catch(() => undefined);
2026-06-02 00:57:37 +08:00
}, [runFavoriteMutation, status, token]);
2026-06-02 00:36:11 +08:00
const statusFor = useCallback(
(resourceId: string): FavoriteStatus => {
if (favoriteIds.has(resourceId)) return "favorited";
if (knownIds.has(resourceId)) return "notFavorited";
return "unknown";
},
[favoriteIds, knownIds],
);
const value = useMemo<FavoritesContextValue>(
() => ({
favoriteIds,
pendingIds,
statusFor,
ensureFavoriteIds,
toggleFavorite,
markFavorite,
}),
[
ensureFavoriteIds,
favoriteIds,
markFavorite,
pendingIds,
statusFor,
toggleFavorite,
],
);
return (
<FavoritesContext.Provider value={value}>
{children}
</FavoritesContext.Provider>
);
}
export function useFavorites() {
const ctx = useContext(FavoritesContext);
if (!ctx)
throw new Error("useFavorites must be used within FavoritesProvider");
return ctx;
}