From 337e8f7e67c9076c05d631fcfff6d6f6a0387c4f Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:36:11 +0800 Subject: [PATCH] feat: add favorites state and buttons --- src/App.tsx | 194 +++++++++--------- src/api.ts | 2 + src/components/PopularRankList.tsx | 2 + src/components/RecommendedCard.tsx | 6 + .../messageStream/MessageBubble.tsx | 6 + .../messageStream/MessageStream.tsx | 6 + src/favorites/FavoriteButton.tsx | 61 ++++++ src/favorites/FavoritesProvider.tsx | 167 +++++++++++++++ src/favorites/api.ts | 98 +++++++++ src/locales/en.ts | 6 + src/locales/id.ts | 6 + src/locales/ja.ts | 6 + src/locales/ko.ts | 6 + src/locales/ms.ts | 6 + src/locales/vi.ts | 6 + src/locales/zh-CN.ts | 6 + 16 files changed, 491 insertions(+), 93 deletions(-) create mode 100644 src/favorites/FavoriteButton.tsx create mode 100644 src/favorites/FavoritesProvider.tsx create mode 100644 src/favorites/api.ts diff --git a/src/App.tsx b/src/App.tsx index 8f32be0..f851274 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; +import { FavoritesProvider } from "./favorites/FavoritesProvider"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; import { WalletLoginModal } from "./wallet/WalletLoginModal"; import { WalletProvider } from "./wallet/WalletProvider"; @@ -33,104 +34,111 @@ export default function App() { - - - - - - - - - - }> - {/* English (root, no prefix) */} - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - } - /> + + + + + + + + + + + }> + {/* English (root, no prefix) */} + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> - {/* Each non-English language gets its own nested tree. */} - {localizedHomeRoutes.map((route) => ( - - - } - /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - ))} - + {/* Each non-English language gets its own nested tree. */} + {localizedHomeRoutes.map((route) => ( + + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + ))} + + + {adminEnabled ? ( + AdminRouteTree() + ) : ( + } + /> + )} - {adminEnabled ? ( - AdminRouteTree() - ) : ( } /> - )} - - } - /> - - - - - - - + + + + + + + + diff --git a/src/api.ts b/src/api.ts index 50a900c..f643958 100644 --- a/src/api.ts +++ b/src/api.ts @@ -261,6 +261,8 @@ export type Resource = { isRecommended: boolean; publishedAt?: string; updatedAt: string; + favoriteCount?: number; + availability?: "available" | "unavailable"; tags?: string[]; }; diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 2c3b7a1..b71ce1a 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -24,6 +24,7 @@ import type { Post } from "../types/post"; import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { useToast } from "./Toast"; +import { FavoriteButton } from "../favorites/FavoriteButton"; const MEDALS = ["🥇", "🥈", "🥉"]; const MAX_ITEMS = 5; @@ -174,6 +175,7 @@ function PopularRankRow({
+ {r.isDownloadable ? (
; @@ -53,6 +54,11 @@ export function MessageBubble({ isVisual ? "p-0" : "px-4 py-3" }`} > + {post.linkPreview ? (
diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index c81bb6b..6ef9fe9 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -9,6 +9,7 @@ import { FilterChips } from "./FilterChips"; import { MessageBubble } from "./MessageBubble"; import { useGroupedByDay } from "./hooks/useGroupedByDay"; import { usePostStream } from "./hooks/usePostStream"; +import { useFavorites } from "../../favorites/FavoritesProvider"; export type MessageStreamProps = { scope: PostScope; @@ -30,9 +31,14 @@ export function MessageStream({ scope }: MessageStreamProps) { const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); + const { ensureFavoriteIds } = useFavorites(); const groups = useGroupedByDay(items, lang); const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; + useEffect(() => { + void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined); + }, [ensureFavoriteIds, items]); + const sentinelRef = useRef(null); const filterBarRef = useRef(null); const hasMoreRef = useRef(hasMore); diff --git a/src/favorites/FavoriteButton.tsx b/src/favorites/FavoriteButton.tsx new file mode 100644 index 0000000..260cd9c --- /dev/null +++ b/src/favorites/FavoriteButton.tsx @@ -0,0 +1,61 @@ +import { Heart, LoaderCircle } from "lucide-react"; +import { useEffect } from "react"; +import { useI18n } from "../i18n"; +import { useFavorites } from "./FavoritesProvider"; + +type FavoriteButtonProps = { + resourceId: string; + className?: string; + size?: "sm" | "md"; +}; + +export function FavoriteButton({ + resourceId, + className = "", + size = "md", +}: FavoriteButtonProps) { + const { t } = useI18n(); + const favorites = useFavorites(); + const status = favorites.statusFor(resourceId); + const pending = favorites.pendingIds.has(resourceId); + const isFavorite = status === "favorited"; + + useEffect(() => { + void favorites.ensureFavoriteIds([resourceId]).catch(() => undefined); + }, [favorites, resourceId]); + + const dimension = size === "sm" ? "h-9 w-9" : "h-10 w-10"; + + return ( + + ); +} diff --git a/src/favorites/FavoritesProvider.tsx b/src/favorites/FavoritesProvider.tsx new file mode 100644 index 0000000..b62982b --- /dev/null +++ b/src/favorites/FavoritesProvider.tsx @@ -0,0 +1,167 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useToast } from "../components/Toast"; +import { useI18n } from "../i18n"; +import { useWallet } from "../wallet/WalletProvider"; +import { addFavorite, getFavoriteIds, removeFavorite } from "./api"; + +type FavoriteStatus = "unknown" | "favorited" | "notFavorited"; + +type FavoritesContextValue = { + favoriteIds: Set; + pendingIds: Set; + statusFor: (resourceId: string) => FavoriteStatus; + ensureFavoriteIds: (resourceIds: string[]) => Promise; + toggleFavorite: (resourceId: string) => Promise; + markFavorite: (resourceId: string, favorited: boolean) => void; +}; + +const FavoritesContext = createContext(null); + +export function FavoritesProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + const { showToast } = useToast(); + const wallet = useWallet(); + const [favoriteIds, setFavoriteIds] = useState>(() => new Set()); + const [knownIds, setKnownIds] = useState>(() => new Set()); + const [pendingIds, setPendingIds] = useState>(() => new Set()); + const pendingAfterLoginRef = useRef(null); + + useEffect(() => { + if (wallet.status === "loggedOut") { + setFavoriteIds(new Set()); + setKnownIds(new Set()); + setPendingIds(new Set()); + pendingAfterLoginRef.current = null; + } + }, [wallet.status]); + + const markFavorite = useCallback((resourceId: string, favorited: boolean) => { + setKnownIds((prev) => new Set(prev).add(resourceId)); + setFavoriteIds((prev) => { + const next = new Set(prev); + if (favorited) next.add(resourceId); + else next.delete(resourceId); + return next; + }); + }, []); + + const ensureFavoriteIds = useCallback( + async (resourceIds: string[]) => { + if (!wallet.token || wallet.status !== "loggedIn") return; + const missing = [...new Set(resourceIds)].filter( + (id) => !knownIds.has(id), + ); + if (missing.length === 0) return; + const ids = await getFavoriteIds(wallet.token, missing); + setKnownIds((prev) => { + const next = new Set(prev); + missing.forEach((id) => next.add(id)); + return next; + }); + setFavoriteIds((prev) => { + const next = new Set(prev); + ids.forEach((id) => next.add(id)); + return next; + }); + }, + [knownIds, wallet.status, wallet.token], + ); + + const runFavoriteMutation = useCallback( + async (resourceId: string) => { + if (!wallet.token) return; + const currentlyFavorite = favoriteIds.has(resourceId); + setPendingIds((prev) => new Set(prev).add(resourceId)); + markFavorite(resourceId, !currentlyFavorite); + try { + if (currentlyFavorite) await removeFavorite(wallet.token, resourceId); + else await addFavorite(wallet.token, resourceId); + showToast( + currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"), + ); + } catch (error) { + markFavorite(resourceId, currentlyFavorite); + showToast(t("favoriteFailed"), "error"); + throw error; + } finally { + setPendingIds((prev) => { + const next = new Set(prev); + next.delete(resourceId); + return next; + }); + } + }, + [favoriteIds, markFavorite, showToast, t, wallet.token], + ); + + const toggleFavorite = useCallback( + async (resourceId: string) => { + if (!wallet.token || wallet.status !== "loggedIn") { + pendingAfterLoginRef.current = resourceId; + wallet.openLoginModal(); + showToast(t("favoriteLoginRequired")); + return; + } + await runFavoriteMutation(resourceId); + }, + [runFavoriteMutation, showToast, t, wallet], + ); + + useEffect(() => { + if (wallet.status !== "loggedIn" || !wallet.token) return; + const pending = pendingAfterLoginRef.current; + if (!pending) return; + pendingAfterLoginRef.current = null; + void runFavoriteMutation(pending).catch(() => undefined); + }, [runFavoriteMutation, wallet.status, wallet.token]); + + const statusFor = useCallback( + (resourceId: string): FavoriteStatus => { + if (favoriteIds.has(resourceId)) return "favorited"; + if (knownIds.has(resourceId)) return "notFavorited"; + return "unknown"; + }, + [favoriteIds, knownIds], + ); + + const value = useMemo( + () => ({ + favoriteIds, + pendingIds, + statusFor, + ensureFavoriteIds, + toggleFavorite, + markFavorite, + }), + [ + ensureFavoriteIds, + favoriteIds, + markFavorite, + pendingIds, + statusFor, + toggleFavorite, + ], + ); + + return ( + + {children} + + ); +} + +export function useFavorites() { + const ctx = useContext(FavoritesContext); + if (!ctx) + throw new Error("useFavorites must be used within FavoritesProvider"); + return ctx; +} diff --git a/src/favorites/api.ts b/src/favorites/api.ts new file mode 100644 index 0000000..0d5dd37 --- /dev/null +++ b/src/favorites/api.ts @@ -0,0 +1,98 @@ +import { apiBase, type Resource } from "../api"; + +export type FavoriteSort = "favorited_at" | "published_at" | "hot"; + +export type FavoriteItem = { + favoritedAt: string; + resource: Resource; +}; + +export type FavoriteListResponse = { + items: FavoriteItem[]; + page: number; + limit: number; + total: number; +}; + +export type FavoriteIdsResponse = { + ids: string[]; +}; + +export type FavoriteMutationResponse = { + ok: boolean; + resourceId: string; + favorited: boolean; + favoritedAt?: string; + favoriteCount: number; +}; + +function authHeaders(token: string): HeadersInit { + return { Authorization: `Bearer ${token}` }; +} + +async function parseJSON(res: Response): Promise { + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +export async function listFavorites( + token: string, + params: { + sort?: FavoriteSort; + page?: number; + limit?: number; + category?: string; + q?: string; + includeUnavailable?: boolean; + lang?: string; + } = {}, +): Promise { + const sp = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === "") return; + sp.set(key, String(value)); + }); + const suffix = sp.toString() ? `?${sp}` : ""; + const res = await fetch(`${apiBase}/api/me/favorites${suffix}`, { + headers: authHeaders(token), + }); + return parseJSON(res); +} + +export async function getFavoriteIds( + token: string, + resourceIds: string[], +): Promise { + if (resourceIds.length === 0) return []; + const uniqueIds = [...new Set(resourceIds)].slice(0, 100); + const res = await fetch( + `${apiBase}/api/me/favorites/ids?resourceIds=${encodeURIComponent( + uniqueIds.join(","), + )}`, + { headers: authHeaders(token) }, + ); + const data = await parseJSON(res); + return data.ids; +} + +export async function addFavorite( + token: string, + resourceId: string, +): Promise { + const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { + method: "POST", + headers: authHeaders(token), + }); + return parseJSON(res); +} + +export async function removeFavorite( + token: string, + resourceId: string, +): Promise { + const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { + method: "DELETE", + headers: authHeaders(token), + }); + return parseJSON(res); +} diff --git a/src/locales/en.ts b/src/locales/en.ts index f23fb32..2f432ce 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -139,6 +139,12 @@ export const enDict: Dict = { adminSearchQuery: "Query", adminSearchTime: "Time", adminSearchId: "ID", + favoriteAdd: "Add to favorites", + favoriteRemove: "Remove from favorites", + favoriteAdded: "Added to favorites", + favoriteRemoved: "Removed from favorites", + favoriteFailed: "Could not update favorites", + favoriteLoginRequired: "Connect your wallet to save favorites", favorites: "My Favorites", favoritesComingSoon: "Coming Soon", favoritesComingSoonDesc: diff --git a/src/locales/id.ts b/src/locales/id.ts index ad902e4..49070be 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -139,6 +139,12 @@ export const idDict: Dict = { adminSearchQuery: "Kueri", adminSearchTime: "Waktu", adminSearchId: "ID", + favoriteAdd: "Tambah ke favorit", + favoriteRemove: "Hapus dari favorit", + favoriteAdded: "Ditambahkan ke favorit", + favoriteRemoved: "Dihapus dari favorit", + favoriteFailed: "Tidak dapat memperbarui favorit", + favoriteLoginRequired: "Hubungkan dompet untuk menyimpan favorit", favorites: "Favorit Saya", favoritesComingSoon: "Segera Hadir", favoritesComingSoonDesc: diff --git a/src/locales/ja.ts b/src/locales/ja.ts index d2bd593..0ff78ba 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -140,6 +140,12 @@ export const jaDict: Dict = { adminSearchQuery: "検索キーワード", adminSearchTime: "時刻", adminSearchId: "ID", + favoriteAdd: "お気に入りに追加", + favoriteRemove: "お気に入りから削除", + favoriteAdded: "お気に入りに追加しました", + favoriteRemoved: "お気に入りから削除しました", + favoriteFailed: "お気に入りを更新できませんでした", + favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です", favorites: "お気に入り", favoritesComingSoon: "近日公開", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 513735c..56e948e 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -139,6 +139,12 @@ export const koDict: Dict = { adminSearchQuery: "검색어", adminSearchTime: "시간", adminSearchId: "ID", + favoriteAdd: "즐겨찾기에 추가", + favoriteRemove: "즐겨찾기에서 제거", + favoriteAdded: "즐겨찾기에 추가되었습니다", + favoriteRemoved: "즐겨찾기에서 제거되었습니다", + favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다", + favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요", favorites: "내 즐겨찾기", favoritesComingSoon: "출시 예정", favoritesComingSoonDesc: diff --git a/src/locales/ms.ts b/src/locales/ms.ts index b2c7cd7..10a93b4 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -139,6 +139,12 @@ export const msDict: Dict = { adminSearchQuery: "Kata kunci", adminSearchTime: "Masa", adminSearchId: "ID", + favoriteAdd: "Tambah ke kegemaran", + favoriteRemove: "Buang daripada kegemaran", + favoriteAdded: "Ditambah ke kegemaran", + favoriteRemoved: "Dibuang daripada kegemaran", + favoriteFailed: "Tidak dapat mengemas kini kegemaran", + favoriteLoginRequired: "Sambung dompet untuk menyimpan kegemaran", favorites: "Kegemaran Saya", favoritesComingSoon: "Akan Hadir", favoritesComingSoonDesc: diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 2ac19ff..800b656 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -139,6 +139,12 @@ export const viDict: Dict = { adminSearchQuery: "Từ khóa", adminSearchTime: "Thời gian", adminSearchId: "ID", + favoriteAdd: "Thêm vào yêu thích", + favoriteRemove: "Xóa khỏi yêu thích", + favoriteAdded: "Đã thêm vào yêu thích", + 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", 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 5697609..cee4257 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -137,6 +137,12 @@ export const zhDict: Dict = { adminSearchQuery: "查询词", adminSearchTime: "时间", adminSearchId: "编号", + favoriteAdd: "加入收藏", + favoriteRemove: "取消收藏", + favoriteAdded: "已加入收藏", + favoriteRemoved: "已取消收藏", + favoriteFailed: "无法更新收藏", + favoriteLoginRequired: "请先连接钱包再收藏", favorites: "我的收藏", favoritesComingSoon: "功能即将推出", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",