diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 68088ea..80f40af 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -1,4 +1,4 @@ -import { Heart, RotateCcw, Search, SlidersHorizontal, X } from "lucide-react"; +import { Heart, RotateCcw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { @@ -10,14 +10,9 @@ import { type Resource, } from "../../api"; import { FavoriteButton } from "../../favorites/FavoriteButton"; -import { - isFavoritesAuthError, - listFavorites, - type FavoriteSort, -} from "../../favorites/api"; +import { isFavoritesAuthError, listFavorites } 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"; @@ -25,16 +20,18 @@ 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 = 24; +const pageSize = 50; -function useCategories(lang: Lang) { +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) => { @@ -43,19 +40,100 @@ function useCategories(lang: Lang) { .catch(() => { if (!cancelled && !cached) setCategories([]); }); + return () => { cancelled = true; }; }, [lang]); - return categories; + return useMemo(() => { + const map = new Map(); + categories.forEach((category) => map.set(category.slug, category.name)); + return map; + }, [categories]); } -function FavoriteResourceCard({ resource }: { resource: Resource }) { - const { t } = useI18n(); +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; + 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 (
) : null} @@ -94,22 +172,24 @@ function FavoriteResourceCard({ resource }: { resource: Resource }) {

- {resource.title} + {title}

- {resource.description ? ( + {description ? (

- {resource.description} + {description}

) : null}
- {cleanCategoryDisplayName(resource.categoryName)} + {cleanCategoryDisplayName(categoryLabel)} - {resource.type} - · - + {typeLabel} + {date ? ( + <> + · + + + ) : null} {typeof resource.favoriteCount === "number" ? ( · ♥ {resource.favoriteCount} ) : null} @@ -128,51 +208,37 @@ 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 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); - const [showFilters, setShowFilters] = useState(false); useSetPageTitle(t("favorites")); - useEffect(() => { - setPage(1); - }, [sort, category, query]); - useEffect(() => { if (!wallet.token || wallet.status !== "loggedIn") { setItems([]); - setTotal(0); setLoading(false); setLoaded(false); + setError(""); return; } + let cancelled = false; setLoading(true); setLoaded(false); setError(""); + listFavorites(wallet.token, { - sort, - category, - q: query, - page, limit: pageSize, includeUnavailable: true, }) .then((data) => { if (cancelled) return; - const resources = itemsOrEmpty(data.items); + const resources = itemsOrEmpty(data.items) as FavoriteResource[]; setItems(resources); - setTotal(data.total ?? resources.length); resources.forEach((resource) => markFavorite(resource.id, true)); setLoaded(true); }) @@ -189,22 +255,11 @@ export default function Favorites() { .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], - ); + }, [markFavorite, reloadKey, t, wallet]); if (wallet.status === "loading") { return ( @@ -240,118 +295,11 @@ export default function Favorites() { } 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 || !loaded ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
+ Array.from({ length: 4 }).map((_, index) => ( + + )) ) : error ? (

{error}

@@ -368,49 +316,21 @@ export default function Favorites() {

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

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

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