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 { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; import { formatDateYmd } from "../../utils/format"; import { resourceTypeLabel } from "../../resourceTypeLabels"; const pageSize = 50; function useCategoryNameBySlug(lang: Lang): Map { 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 useMemo(() => { const map = new Map(); categories.forEach((category) => map.set(category.slug, category.name)); return map; }, [categories]); } type FavoriteAttachment = { thumbnailUrl?: string; thumbUrl?: string; posterUrl?: string; url?: string; }; type FavoriteLocalization = { title?: string; text?: string; description?: string; }; type FavoriteResource = Resource & { postType?: string; sourceLanguage?: string; createdAt?: string; attachments?: FavoriteAttachment[]; localizations?: Record; }; function localizationKeys(lang: Lang): string[] { if (lang === "zh-CN") return ["zh", "zh-CN", "zh-Hans"]; return [lang]; } function localizedResourceText( resource: FavoriteResource, lang: Lang, field: "title" | "description", ): string { for (const key of localizationKeys(lang)) { const localized = resource.localizations?.[key]; if (!localized) continue; if (field === "title" && localized.title?.trim()) return localized.title; if (field === "description") { const text = localized.description || localized.text; if (text?.trim()) return text; } } if (field === "title") return resource.title; return resource.description || resource.bodyText || ""; } function firstAttachmentUrl(resource: FavoriteResource): string { const attachment = resource.attachments?.[0]; return ( attachment?.thumbnailUrl || attachment?.thumbUrl || attachment?.posterUrl || attachment?.url || "" ); } function FavoriteResourceCard({ categoryNameBySlug, resource, }: { categoryNameBySlug: Map; resource: FavoriteResource; }) { const { lang, t } = useI18n(); const lp = useLocalizedPath(); const unavailable = resource.availability === "unavailable"; const cover = resource.coverImage || resource.previewUrl || firstAttachmentUrl(resource); const categoryLabel = (resource.categorySlug && categoryNameBySlug.get(resource.categorySlug)) || resource.categoryName || resource.categorySlug || "ARK"; const typeLabel = resourceTypeLabel( t, resource.type || resource.postType || "resource", ); const date = resource.updatedAt || resource.publishedAt || resource.createdAt || ""; const title = localizedResourceText(resource, lang, "title"); const description = localizedResourceText(resource, lang, "description"); return (
{!unavailable ? ( ) : null}
{cover && !unavailable ? ( ) : (
)} {unavailable ? ( {t("favoritesUnavailable")} ) : null}

{title}

{description ? (

{description}

) : null}
{cleanCategoryDisplayName(categoryLabel)} {typeLabel} {date ? ( <> · ) : null} {typeof resource.favoriteCount === "number" ? ( · ♥ {resource.favoriteCount} ) : null}
); } export default function Favorites() { const { lang, t } = useI18n(); const wallet = useWallet(); const { markFavorite } = useFavorites(); const categoryNameBySlug = useCategoryNameBySlug(lang); const [items, setItems] = useState([]); 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") { setItems([]); 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 resources = itemsOrEmpty(data.items) as FavoriteResource[]; setItems(resources); resources.forEach((resource) => markFavorite(resource.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]); 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}

) : items.length === 0 ? (

{t("favoritesEmptyTitle")}

{t("favoritesEmptyDesc")}

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