import { Heart, RotateCcw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { assetUrl, getJSON, itemsOrEmpty, readJSONCache, type Category, type Resource, } from "../../api"; import { FavoriteButton } from "../../favorites/FavoriteButton"; import { isFavoritesAuthError, listFavorites } from "../../favorites/api"; import { useFavorites } from "../../favorites/FavoritesProvider"; import { langQuery, useI18n, type Lang } from "../../i18n"; import { Reveal } from "../../motion"; import { useSetPageTitle } from "../../components/PageTitleContext"; import { Skeleton } from "../../components/Skeleton"; import { useWallet } from "../../wallet/WalletProvider"; import { useLocalizedPath } from "../../useLocalizedPath"; import { postToResource } from "../../utils/postResourceAdapter"; import { formatDateYmd } from "../../utils/format"; import { resourceLanguageLabel, resourceTypeLabel, } from "../../resourceTypeLabels"; const pageSize = 50; function useCategories(lang: Lang): Category[] { const [categories, setCategories] = useState([]); useEffect(() => { const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`; const cached = readJSONCache(url); if (cached) setCategories(itemsOrEmpty(cached)); let cancelled = false; getJSON(url) .then((items) => { if (!cancelled) setCategories(itemsOrEmpty(items)); }) .catch(() => { if (!cancelled && !cached) setCategories([]); }); return () => { cancelled = true; }; }, [lang]); return categories; } function FavoriteResourceCard({ resource }: { resource: Resource }) { const { t } = useI18n(); const lp = useLocalizedPath(); const unavailable = resource.availability === "unavailable"; const cover = resource.coverImage || resource.previewUrl; return (
{!unavailable ? ( ) : null}
{cover && !unavailable ? ( ) : (
)} {unavailable ? ( {t("favoritesUnavailable")} ) : null}

{resource.title}

{resource.description ? (

{resource.description}

) : null}
{resource.categoryName ? ( {resource.categoryName} ) : null} {resource.type ? ( {resourceTypeLabel(t, resource.type)} ) : null} {resource.language ? ( <> · {resourceLanguageLabel(t, resource.language)} ) : null} {resource.updatedAt ? ( <> · ) : null} {typeof resource.favoriteCount === "number" ? ( · ♥ {resource.favoriteCount} ) : null}
); } export default function Favorites() { const { lang, t } = useI18n(); const wallet = useWallet(); const { markFavorite } = useFavorites(); const categories = useCategories(lang); const [posts, setPosts] = useState< Awaited>["items"] >([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); const [reloadKey, setReloadKey] = useState(0); useSetPageTitle(t("favorites")); useEffect(() => { if (!wallet.token || wallet.status !== "loggedIn") { setPosts([]); setLoading(false); setLoaded(false); setError(""); return; } let cancelled = false; setLoading(true); setLoaded(false); setError(""); listFavorites(wallet.token, { limit: pageSize, includeUnavailable: true, }) .then((data) => { if (cancelled) return; const items = itemsOrEmpty(data.items); setPosts(items); items.forEach((post) => markFavorite(post.id, true)); setLoaded(true); }) .catch((err) => { if (cancelled) return; if (isFavoritesAuthError(err)) { wallet.logout(); wallet.openLoginModal(); return; } setError(err instanceof Error ? err.message : t("loadFailed")); setLoaded(true); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [markFavorite, reloadKey, t, wallet]); const resources = useMemo( () => posts.map((post) => postToResource(post, lang, categories)), [posts, lang, categories], ); if (wallet.status === "loading") { return ( {Array.from({ length: 4 }).map((_, index) => ( ))} ); } if (wallet.status !== "loggedIn") { return (

{t("favorites")}

{t("favoritesLoginDesc")}

); } return (
{loading || !loaded ? ( Array.from({ length: 4 }).map((_, index) => ( )) ) : error ? (

{error}

) : resources.length === 0 ? (

{t("favoritesEmptyTitle")}

{t("favoritesEmptyDesc")}

) : ( resources.map((resource) => ( )) )}
); }