import { Heart, RotateCcw, Search, SlidersHorizontal, X } 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, type FavoriteSort, } from "../../favorites/api"; import { useFavorites } from "../../favorites/FavoritesProvider"; import { langQuery, useI18n, type Lang } from "../../i18n"; import { homePathForLang } from "../../languageRoutes"; 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"; const pageSize = 24; function useCategories(lang: Lang) { 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}
{cleanCategoryDisplayName(resource.categoryName)} {resource.type} · {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 [sort, setSort] = useState("favorited_at"); const [category, setCategory] = useState(""); const [queryInput, setQueryInput] = useState(""); const [query, setQuery] = useState(""); const [page, setPage] = useState(1); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [reloadKey, setReloadKey] = useState(0); const [showFilters, setShowFilters] = useState(false); useSetPageTitle(t("favorites")); useEffect(() => { setPage(1); }, [sort, category, query]); useEffect(() => { if (!wallet.token || wallet.status !== "loggedIn") { setItems([]); setTotal(0); return; } let cancelled = false; setLoading(true); setError(""); listFavorites(wallet.token, { sort, category, q: query, page, limit: pageSize, includeUnavailable: true, }) .then((data) => { if (cancelled) return; const resources = itemsOrEmpty(data.items); setItems(resources); setTotal(data.total ?? resources.length); resources.forEach((resource) => markFavorite(resource.id, true)); }) .catch((err) => { if (cancelled) return; if (isFavoritesAuthError(err)) { wallet.logout(); wallet.openLoginModal(); return; } setError(err instanceof Error ? err.message : t("loadFailed")); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [category, markFavorite, page, query, reloadKey, sort, t, wallet]); const totalPages = Math.max(1, Math.ceil(total / pageSize)); const hasFilters = Boolean(category || query || sort !== "favorited_at"); const sortOptions = useMemo( () => [ { value: "favorited_at" as const, label: t("favoritesSortFavoritedAt") }, { value: "published_at" as const, label: t("favoritesSortPublishedAt") }, { value: "hot" as const, label: t("favoritesSortHot") }, ], [t], ); if (wallet.status !== "loggedIn") { return (

{t("favorites")}

{t("favoritesLoginDesc")}

); } return (

{t("favorites")}

{t("favoritesLibraryTitle")}

{t("backToHome")}
{ event.preventDefault(); setQuery(queryInput.trim()); }} > {/* Mobile-only toggle: collapse sort/category into a "Filters" drawer. */}
{hasFilters ? ( ) : null}
{loading ? (
{Array.from({ length: 4 }).map((_, index) => ( ))}
) : error ? (

{error}

) : items.length === 0 ? (

{hasFilters ? t("favoritesNoFilteredTitle") : t("favoritesEmptyTitle")}

{hasFilters ? t("favoritesNoFilteredDesc") : t("favoritesEmptyDesc")}

) : (
{items.map((resource) => ( ))}
)} {totalPages > 1 ? (
{t("pageIndicator") .replace("{{c}}", String(page)) .replace("{{p}}", String(totalPages))}
) : null}
); }