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({
;
@@ -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: "登入与收藏功能开发中,敬请期待。",