From de93e883c9605c9e2f6b5d72179380233f0db53a Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:39:36 +0800 Subject: [PATCH] feat: build favorites page --- src/locales/en.ts | 16 ++ src/locales/id.ts | 15 ++ src/locales/ja.ts | 14 ++ src/locales/ko.ts | 14 ++ src/locales/ms.ts | 15 ++ src/locales/vi.ts | 14 ++ src/locales/zh-CN.ts | 13 ++ src/pages/Favorites/index.tsx | 386 +++++++++++++++++++++++++++++++--- 8 files changed, 460 insertions(+), 27 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 2f432ce..31662f6 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -145,6 +145,22 @@ export const enDict: Dict = { favoriteRemoved: "Removed from favorites", favoriteFailed: "Could not update favorites", favoriteLoginRequired: "Connect your wallet to save favorites", + favoritesLoginDesc: + "Connect your wallet to view and manage your saved ARK resources.", + favoritesLibraryTitle: "Saved resources", + favoritesEmptyTitle: "No favorites yet", + favoritesEmptyDesc: + "Browse resources and tap the heart icon to save them here.", + favoritesNoFilteredTitle: "No matching favorites", + favoritesNoFilteredDesc: + "Try changing your search, category, or sort filters.", + favoritesFilterAllCategories: "All categories", + favoritesSortFavoritedAt: "Recently saved", + favoritesSortPublishedAt: "Newest published", + favoritesSortHot: "Hot resources", + favoritesSearchPlaceholder: "Search your favorites", + favoritesUnavailable: "Unavailable", + favoritesClearFilters: "Clear filters", favorites: "My Favorites", favoritesComingSoon: "Coming Soon", favoritesComingSoonDesc: diff --git a/src/locales/id.ts b/src/locales/id.ts index 49070be..87c975e 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -145,6 +145,21 @@ export const idDict: Dict = { favoriteRemoved: "Dihapus dari favorit", favoriteFailed: "Tidak dapat memperbarui favorit", favoriteLoginRequired: "Hubungkan dompet untuk menyimpan favorit", + favoritesLoginDesc: + "Hubungkan dompet untuk melihat dan mengelola sumber ARK yang disimpan.", + favoritesLibraryTitle: "Sumber tersimpan", + favoritesEmptyTitle: "Belum ada favorit", + favoritesEmptyDesc: + "Jelajahi sumber dan ketuk ikon hati untuk menyimpannya di sini.", + favoritesNoFilteredTitle: "Tidak ada favorit yang cocok", + favoritesNoFilteredDesc: "Coba ubah pencarian, kategori, atau urutan.", + favoritesFilterAllCategories: "Semua kategori", + favoritesSortFavoritedAt: "Baru disimpan", + favoritesSortPublishedAt: "Terbaru diterbitkan", + favoritesSortHot: "Sumber populer", + favoritesSearchPlaceholder: "Cari favorit Anda", + favoritesUnavailable: "Tidak tersedia", + favoritesClearFilters: "Hapus filter", favorites: "Favorit Saya", favoritesComingSoon: "Segera Hadir", favoritesComingSoonDesc: diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 0ff78ba..ea55119 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -146,6 +146,20 @@ export const jaDict: Dict = { favoriteRemoved: "お気に入りから削除しました", favoriteFailed: "お気に入りを更新できませんでした", favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です", + favoritesLoginDesc: + "ウォレットを接続すると、保存した ARK 資料を表示・管理できます。", + favoritesLibraryTitle: "保存した資料", + favoritesEmptyTitle: "お気に入りはまだありません", + favoritesEmptyDesc: "資料を閲覧してハートを押すと、ここに保存されます。", + favoritesNoFilteredTitle: "一致するお気に入りがありません", + favoritesNoFilteredDesc: "検索、カテゴリ、並び順を変更してみてください。", + favoritesFilterAllCategories: "すべてのカテゴリ", + favoritesSortFavoritedAt: "最近保存", + favoritesSortPublishedAt: "新しい公開順", + favoritesSortHot: "人気資料", + favoritesSearchPlaceholder: "お気に入りを検索", + favoritesUnavailable: "利用不可", + favoritesClearFilters: "フィルターをクリア", favorites: "お気に入り", favoritesComingSoon: "近日公開", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 56e948e..202c443 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -145,6 +145,20 @@ export const koDict: Dict = { favoriteRemoved: "즐겨찾기에서 제거되었습니다", favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다", favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요", + favoritesLoginDesc: + "지갑을 연결하면 저장한 ARK 자료를 보고 관리할 수 있습니다.", + favoritesLibraryTitle: "저장한 자료", + favoritesEmptyTitle: "아직 즐겨찾기가 없습니다", + favoritesEmptyDesc: "자료를 둘러보다가 하트 아이콘을 눌러 여기에 저장하세요.", + favoritesNoFilteredTitle: "일치하는 즐겨찾기가 없습니다", + favoritesNoFilteredDesc: "검색어, 카테고리 또는 정렬을 변경해 보세요.", + favoritesFilterAllCategories: "모든 카테고리", + favoritesSortFavoritedAt: "최근 저장", + favoritesSortPublishedAt: "최신 게시", + favoritesSortHot: "인기 자료", + favoritesSearchPlaceholder: "내 즐겨찾기 검색", + favoritesUnavailable: "사용 불가", + favoritesClearFilters: "필터 지우기", favorites: "내 즐겨찾기", favoritesComingSoon: "출시 예정", favoritesComingSoonDesc: diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 10a93b4..61e2d3a 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -145,6 +145,21 @@ export const msDict: Dict = { favoriteRemoved: "Dibuang daripada kegemaran", favoriteFailed: "Tidak dapat mengemas kini kegemaran", favoriteLoginRequired: "Sambung dompet untuk menyimpan kegemaran", + favoritesLoginDesc: + "Sambung dompet untuk melihat dan mengurus sumber ARK yang disimpan.", + favoritesLibraryTitle: "Sumber disimpan", + favoritesEmptyTitle: "Belum ada kegemaran", + favoritesEmptyDesc: + "Lihat sumber dan tekan ikon hati untuk menyimpannya di sini.", + favoritesNoFilteredTitle: "Tiada kegemaran sepadan", + favoritesNoFilteredDesc: "Cuba ubah carian, kategori atau susunan.", + favoritesFilterAllCategories: "Semua kategori", + favoritesSortFavoritedAt: "Baru disimpan", + favoritesSortPublishedAt: "Terbaru diterbitkan", + favoritesSortHot: "Sumber popular", + favoritesSearchPlaceholder: "Cari kegemaran anda", + favoritesUnavailable: "Tidak tersedia", + favoritesClearFilters: "Kosongkan penapis", favorites: "Kegemaran Saya", favoritesComingSoon: "Akan Hadir", favoritesComingSoonDesc: diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 800b656..800d778 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -145,6 +145,20 @@ export const viDict: Dict = { favoriteRemoved: "Đã xóa khỏi yêu thích", favoriteFailed: "Không thể cập nhật yêu thích", favoriteLoginRequired: "Kết nối ví để lưu yêu thích", + favoritesLoginDesc: "Kết nối ví để xem và quản lý tài nguyên ARK đã lưu.", + favoritesLibraryTitle: "Tài nguyên đã lưu", + favoritesEmptyTitle: "Chưa có mục yêu thích", + favoritesEmptyDesc: + "Duyệt tài nguyên và bấm biểu tượng trái tim để lưu tại đây.", + favoritesNoFilteredTitle: "Không có mục phù hợp", + favoritesNoFilteredDesc: "Hãy đổi từ khóa, danh mục hoặc cách sắp xếp.", + favoritesFilterAllCategories: "Tất cả danh mục", + favoritesSortFavoritedAt: "Lưu gần đây", + favoritesSortPublishedAt: "Mới xuất bản", + favoritesSortHot: "Tài nguyên hot", + favoritesSearchPlaceholder: "Tìm trong yêu thích", + favoritesUnavailable: "Không khả dụng", + favoritesClearFilters: "Xóa bộ lọc", favorites: "Yêu thích của tôi", favoritesComingSoon: "Sắp ra mắt", favoritesComingSoonDesc: diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index cee4257..abe2d98 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -143,6 +143,19 @@ export const zhDict: Dict = { favoriteRemoved: "已取消收藏", favoriteFailed: "无法更新收藏", favoriteLoginRequired: "请先连接钱包再收藏", + favoritesLoginDesc: "连接钱包后即可查看和管理你收藏的 ARK 资料。", + favoritesLibraryTitle: "已收藏资料", + favoritesEmptyTitle: "还没有收藏", + favoritesEmptyDesc: "浏览资料时点击爱心,就可以把常用内容保存到这里。", + favoritesNoFilteredTitle: "没有符合条件的收藏", + favoritesNoFilteredDesc: "试着调整搜索、分类或排序条件。", + favoritesFilterAllCategories: "全部分类", + favoritesSortFavoritedAt: "最近收藏", + favoritesSortPublishedAt: "最新发布", + favoritesSortHot: "热门资料", + favoritesSearchPlaceholder: "搜索我的收藏", + favoritesUnavailable: "已下架", + favoritesClearFilters: "清除筛选", favorites: "我的收藏", favoritesComingSoon: "功能即将推出", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 46dadc5..6be9937 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -1,43 +1,375 @@ -import { Heart } from "lucide-react"; +import { Heart, Search, SlidersHorizontal, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { useI18n } from "../../i18n"; +import { + assetUrl, + getJSON, + itemsOrEmpty, + readJSONCache, + type Category, + type Resource, +} from "../../api"; +import { FavoriteButton } from "../../favorites/FavoriteButton"; +import { 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; + const content = ( +
+
+ {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} +
+
+ + +
+ ); + + if (unavailable) return content; + return ( + + {content} + + ); +} export default function Favorites() { const { lang, t } = useI18n(); - // Show "我的收藏" in the global header, consistent with the other pages. + 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(""); + 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, + lang: langQuery(lang), + }) + .then((data) => { + if (cancelled) return; + const resources = itemsOrEmpty(data.items).map((item) => item.resource); + setItems(resources); + setTotal(data.total); + resources.forEach((resource) => markFavorite(resource.id, true)); + }) + .catch((err) => { + if (!cancelled) + setError(err instanceof Error ? err.message : t("loadFailed")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [ + category, + lang, + markFavorite, + page, + query, + sort, + t, + wallet.status, + wallet.token, + ]); + + 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()); + }} + > + + + + + + + +
+ + {hasFilters ? ( + + ) : null}
-

- {t("favorites")} -

+ {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) => ( + + ))} +
+ )} -

- {t("favoritesComingSoon")} -

- -

- {t("favoritesComingSoonDesc")} -

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