feat: add favorites state and buttons
This commit is contained in:
167
src/favorites/FavoritesProvider.tsx
Normal file
167
src/favorites/FavoritesProvider.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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();
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (wallet.status === "loggedOut") {
|
||||
setFavoriteIds(new Set());
|
||||
setKnownIds(new Set());
|
||||
setPendingIds(new Set());
|
||||
pendingAfterLoginRef.current = null;
|
||||
}
|
||||
}, [wallet.status]);
|
||||
|
||||
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[]) => {
|
||||
if (!wallet.token || wallet.status !== "loggedIn") return;
|
||||
const missing = [...new Set(resourceIds)].filter(
|
||||
(id) => !knownIds.has(id),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
const ids = await getFavoriteIds(wallet.token, missing);
|
||||
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;
|
||||
});
|
||||
},
|
||||
[knownIds, wallet.status, wallet.token],
|
||||
);
|
||||
|
||||
const runFavoriteMutation = useCallback(
|
||||
async (resourceId: string) => {
|
||||
if (!wallet.token) return;
|
||||
const currentlyFavorite = favoriteIds.has(resourceId);
|
||||
setPendingIds((prev) => new Set(prev).add(resourceId));
|
||||
markFavorite(resourceId, !currentlyFavorite);
|
||||
try {
|
||||
if (currentlyFavorite) await removeFavorite(wallet.token, resourceId);
|
||||
else await addFavorite(wallet.token, resourceId);
|
||||
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;
|
||||
});
|
||||
}
|
||||
},
|
||||
[favoriteIds, markFavorite, showToast, t, wallet.token],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (resourceId: string) => {
|
||||
if (!wallet.token || wallet.status !== "loggedIn") {
|
||||
pendingAfterLoginRef.current = resourceId;
|
||||
wallet.openLoginModal();
|
||||
showToast(t("favoriteLoginRequired"));
|
||||
return;
|
||||
}
|
||||
await runFavoriteMutation(resourceId);
|
||||
},
|
||||
[runFavoriteMutation, showToast, t, wallet],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (wallet.status !== "loggedIn" || !wallet.token) return;
|
||||
const pending = pendingAfterLoginRef.current;
|
||||
if (!pending) return;
|
||||
pendingAfterLoginRef.current = null;
|
||||
void runFavoriteMutation(pending).catch(() => undefined);
|
||||
}, [runFavoriteMutation, wallet.status, wallet.token]);
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user