feat: add favorites state and buttons

This commit is contained in:
TerryM
2026-06-02 00:36:11 +08:00
parent 43700d9fdc
commit 337e8f7e67
16 changed files with 491 additions and 93 deletions

View File

@@ -3,6 +3,7 @@ import { I18nProvider } from "./i18n";
import { MotionProvider } from "./motion"; import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast"; import { ToastProvider } from "./components/Toast";
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
import { FavoritesProvider } from "./favorites/FavoritesProvider";
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
import { WalletLoginModal } from "./wallet/WalletLoginModal"; import { WalletLoginModal } from "./wallet/WalletLoginModal";
import { WalletProvider } from "./wallet/WalletProvider"; import { WalletProvider } from "./wallet/WalletProvider";
@@ -33,6 +34,7 @@ export default function App() {
<ToastProvider> <ToastProvider>
<RainbowWalletProvider> <RainbowWalletProvider>
<WalletProvider> <WalletProvider>
<FavoritesProvider>
<SaveToAlbumGuideProvider> <SaveToAlbumGuideProvider>
<AdminRouterModeProvider value="absolute"> <AdminRouterModeProvider value="absolute">
<ImageLightboxProvider> <ImageLightboxProvider>
@@ -46,7 +48,9 @@ export default function App() {
{/* English (root, no prefix) */} {/* English (root, no prefix) */}
<Route <Route
path="/" path="/"
element={<LocalizedHomePage targetLang="en" />} element={
<LocalizedHomePage targetLang="en" />
}
/> />
<Route path="/browse" element={<Browse />} /> <Route path="/browse" element={<Browse />} />
<Route <Route
@@ -61,7 +65,10 @@ export default function App() {
path="/category/:slug" path="/category/:slug"
element={<CategoryPage />} element={<CategoryPage />}
/> />
<Route path="/search" element={<SearchPage />} /> <Route
path="/search"
element={<SearchPage />}
/>
<Route <Route
path="/resource/:id" path="/resource/:id"
element={<PostRedirect />} element={<PostRedirect />}
@@ -131,6 +138,7 @@ export default function App() {
</ImageLightboxProvider> </ImageLightboxProvider>
</AdminRouterModeProvider> </AdminRouterModeProvider>
</SaveToAlbumGuideProvider> </SaveToAlbumGuideProvider>
</FavoritesProvider>
</WalletProvider> </WalletProvider>
</RainbowWalletProvider> </RainbowWalletProvider>
</ToastProvider> </ToastProvider>

View File

@@ -261,6 +261,8 @@ export type Resource = {
isRecommended: boolean; isRecommended: boolean;
publishedAt?: string; publishedAt?: string;
updatedAt: string; updatedAt: string;
favoriteCount?: number;
availability?: "available" | "unavailable";
tags?: string[]; tags?: string[];
}; };

View File

@@ -24,6 +24,7 @@ import type { Post } from "../types/post";
import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { downloadAttachment } from "./messageStream/utils/downloadFile";
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { FavoriteButton } from "../favorites/FavoriteButton";
const MEDALS = ["🥇", "🥈", "🥉"]; const MEDALS = ["🥇", "🥈", "🥉"];
const MAX_ITEMS = 5; const MAX_ITEMS = 5;
@@ -174,6 +175,7 @@ function PopularRankRow({
</div> </div>
<div className="relative z-10 flex shrink-0 items-center gap-1"> <div className="relative z-10 flex shrink-0 items-center gap-1">
<FavoriteButton resourceId={r.id} />
{r.isDownloadable ? ( {r.isDownloadable ? (
<button <button
type="button" type="button"

View File

@@ -15,6 +15,7 @@ import {
} from "./messageStream/utils/downloadFile"; } from "./messageStream/utils/downloadFile";
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { FavoriteButton } from "../favorites/FavoriteButton";
function isPlaceholderAsset(path: string | undefined | null) { function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover"); return !path || path.includes("placeholder-cover");
@@ -133,6 +134,11 @@ export function RecommendedCard({
{r.badgeLabel} {r.badgeLabel}
</span> </span>
) : null} ) : null}
<FavoriteButton
resourceId={r.id}
size="sm"
className="absolute right-3 top-3 z-20 shadow-lg shadow-black/30"
/>
</div> </div>
<div <div
className={ className={

View File

@@ -8,6 +8,7 @@ import { AlbumBubble } from "./bubbles/AlbumBubble";
import { VideoBubble } from "./bubbles/VideoBubble"; import { VideoBubble } from "./bubbles/VideoBubble";
import { LinkPreviewCard } from "./LinkPreviewCard"; import { LinkPreviewCard } from "./LinkPreviewCard";
import { formatDateTime } from "./utils/formatTime"; import { formatDateTime } from "./utils/formatTime";
import { FavoriteButton } from "../../favorites/FavoriteButton";
type BubbleComponent = ComponentType<{ post: Post }>; type BubbleComponent = ComponentType<{ post: Post }>;
@@ -53,6 +54,11 @@ export function MessageBubble({
isVisual ? "p-0" : "px-4 py-3" isVisual ? "p-0" : "px-4 py-3"
}`} }`}
> >
<FavoriteButton
resourceId={post.id}
size="sm"
className="absolute right-3 top-3 z-20 shadow-lg shadow-black/30"
/>
<Bubble post={post} /> <Bubble post={post} />
{post.linkPreview ? ( {post.linkPreview ? (
<div className={isVisual ? "px-4 pt-3" : "mt-3"}> <div className={isVisual ? "px-4 pt-3" : "mt-3"}>

View File

@@ -9,6 +9,7 @@ import { FilterChips } from "./FilterChips";
import { MessageBubble } from "./MessageBubble"; import { MessageBubble } from "./MessageBubble";
import { useGroupedByDay } from "./hooks/useGroupedByDay"; import { useGroupedByDay } from "./hooks/useGroupedByDay";
import { usePostStream } from "./hooks/usePostStream"; import { usePostStream } from "./hooks/usePostStream";
import { useFavorites } from "../../favorites/FavoritesProvider";
export type MessageStreamProps = { export type MessageStreamProps = {
scope: PostScope; scope: PostScope;
@@ -30,9 +31,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
const { items, isLoading, error, hasMore, loadMore, reset } = const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params); usePostStream(params);
const { ensureFavoriteIds } = useFavorites();
const groups = useGroupedByDay(items, lang); const groups = useGroupedByDay(items, lang);
const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
useEffect(() => {
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
}, [ensureFavoriteIds, items]);
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const filterBarRef = useRef<HTMLDivElement>(null); const filterBarRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(hasMore); const hasMoreRef = useRef(hasMore);

View File

@@ -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 (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void favorites.toggleFavorite(resourceId).catch(() => undefined);
}}
disabled={pending}
aria-pressed={isFavorite}
aria-label={isFavorite ? t("favoriteRemove") : t("favoriteAdd")}
title={isFavorite ? t("favoriteRemove") : t("favoriteAdd")}
className={[
"inline-flex shrink-0 items-center justify-center rounded-full border outline-none transition active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait disabled:opacity-70",
dimension,
isFavorite
? "border-ark-gold/60 bg-ark-gold text-black hover:bg-ark-gold2"
: "border-white/10 bg-[#191921]/90 text-white hover:border-ark-gold/50 hover:bg-ark-gold/10 hover:text-ark-gold",
className,
].join(" ")}
>
{pending ? (
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.2} />
) : (
<Heart
className="h-5 w-5"
strokeWidth={2.2}
fill={isFavorite ? "currentColor" : "none"}
/>
)}
</button>
);
}

View File

@@ -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<string>;
pendingIds: Set<string>;
statusFor: (resourceId: string) => FavoriteStatus;
ensureFavoriteIds: (resourceIds: string[]) => Promise<void>;
toggleFavorite: (resourceId: string) => Promise<void>;
markFavorite: (resourceId: string, favorited: boolean) => void;
};
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
export function FavoritesProvider({ children }: { children: ReactNode }) {
const { t } = useI18n();
const { showToast } = useToast();
const wallet = useWallet();
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(() => new Set());
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
const pendingAfterLoginRef = useRef<string | null>(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<FavoritesContextValue>(
() => ({
favoriteIds,
pendingIds,
statusFor,
ensureFavoriteIds,
toggleFavorite,
markFavorite,
}),
[
ensureFavoriteIds,
favoriteIds,
markFavorite,
pendingIds,
statusFor,
toggleFavorite,
],
);
return (
<FavoritesContext.Provider value={value}>
{children}
</FavoritesContext.Provider>
);
}
export function useFavorites() {
const ctx = useContext(FavoritesContext);
if (!ctx)
throw new Error("useFavorites must be used within FavoritesProvider");
return ctx;
}

98
src/favorites/api.ts Normal file
View File

@@ -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<T>(res: Response): Promise<T> {
if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<T>;
}
export async function listFavorites(
token: string,
params: {
sort?: FavoriteSort;
page?: number;
limit?: number;
category?: string;
q?: string;
includeUnavailable?: boolean;
lang?: string;
} = {},
): Promise<FavoriteListResponse> {
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<FavoriteListResponse>(res);
}
export async function getFavoriteIds(
token: string,
resourceIds: string[],
): Promise<string[]> {
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<FavoriteIdsResponse>(res);
return data.ids;
}
export async function addFavorite(
token: string,
resourceId: string,
): Promise<FavoriteMutationResponse> {
const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, {
method: "POST",
headers: authHeaders(token),
});
return parseJSON<FavoriteMutationResponse>(res);
}
export async function removeFavorite(
token: string,
resourceId: string,
): Promise<FavoriteMutationResponse> {
const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, {
method: "DELETE",
headers: authHeaders(token),
});
return parseJSON<FavoriteMutationResponse>(res);
}

View File

@@ -139,6 +139,12 @@ export const enDict: Dict = {
adminSearchQuery: "Query", adminSearchQuery: "Query",
adminSearchTime: "Time", adminSearchTime: "Time",
adminSearchId: "ID", 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", favorites: "My Favorites",
favoritesComingSoon: "Coming Soon", favoritesComingSoon: "Coming Soon",
favoritesComingSoonDesc: favoritesComingSoonDesc:

View File

@@ -139,6 +139,12 @@ export const idDict: Dict = {
adminSearchQuery: "Kueri", adminSearchQuery: "Kueri",
adminSearchTime: "Waktu", adminSearchTime: "Waktu",
adminSearchId: "ID", 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", favorites: "Favorit Saya",
favoritesComingSoon: "Segera Hadir", favoritesComingSoon: "Segera Hadir",
favoritesComingSoonDesc: favoritesComingSoonDesc:

View File

@@ -140,6 +140,12 @@ export const jaDict: Dict = {
adminSearchQuery: "検索キーワード", adminSearchQuery: "検索キーワード",
adminSearchTime: "時刻", adminSearchTime: "時刻",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "お気に入りに追加",
favoriteRemove: "お気に入りから削除",
favoriteAdded: "お気に入りに追加しました",
favoriteRemoved: "お気に入りから削除しました",
favoriteFailed: "お気に入りを更新できませんでした",
favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です",
favorites: "お気に入り", favorites: "お気に入り",
favoritesComingSoon: "近日公開", favoritesComingSoon: "近日公開",
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",

View File

@@ -139,6 +139,12 @@ export const koDict: Dict = {
adminSearchQuery: "검색어", adminSearchQuery: "검색어",
adminSearchTime: "시간", adminSearchTime: "시간",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "즐겨찾기에 추가",
favoriteRemove: "즐겨찾기에서 제거",
favoriteAdded: "즐겨찾기에 추가되었습니다",
favoriteRemoved: "즐겨찾기에서 제거되었습니다",
favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다",
favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요",
favorites: "내 즐겨찾기", favorites: "내 즐겨찾기",
favoritesComingSoon: "출시 예정", favoritesComingSoon: "출시 예정",
favoritesComingSoonDesc: favoritesComingSoonDesc:

View File

@@ -139,6 +139,12 @@ export const msDict: Dict = {
adminSearchQuery: "Kata kunci", adminSearchQuery: "Kata kunci",
adminSearchTime: "Masa", adminSearchTime: "Masa",
adminSearchId: "ID", 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", favorites: "Kegemaran Saya",
favoritesComingSoon: "Akan Hadir", favoritesComingSoon: "Akan Hadir",
favoritesComingSoonDesc: favoritesComingSoonDesc:

View File

@@ -139,6 +139,12 @@ export const viDict: Dict = {
adminSearchQuery: "Từ khóa", adminSearchQuery: "Từ khóa",
adminSearchTime: "Thời gian", adminSearchTime: "Thời gian",
adminSearchId: "ID", 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", favorites: "Yêu thích của tôi",
favoritesComingSoon: "Sắp ra mắt", favoritesComingSoon: "Sắp ra mắt",
favoritesComingSoonDesc: favoritesComingSoonDesc:

View File

@@ -137,6 +137,12 @@ export const zhDict: Dict = {
adminSearchQuery: "查询词", adminSearchQuery: "查询词",
adminSearchTime: "时间", adminSearchTime: "时间",
adminSearchId: "编号", adminSearchId: "编号",
favoriteAdd: "加入收藏",
favoriteRemove: "取消收藏",
favoriteAdded: "已加入收藏",
favoriteRemoved: "已取消收藏",
favoriteFailed: "无法更新收藏",
favoriteLoginRequired: "请先连接钱包再收藏",
favorites: "我的收藏", favorites: "我的收藏",
favoritesComingSoon: "功能即将推出", favoritesComingSoon: "功能即将推出",
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",