fix: refresh favorites after unfavorite

This commit is contained in:
TerryM
2026-06-05 18:16:33 +08:00
parent 486c09dd39
commit a9ec46e008
5 changed files with 88 additions and 21 deletions

View File

@@ -83,14 +83,18 @@ function RankBadge({ index }: { index: number }) {
);
}
function PopularRankRow({
export function PopularRankRow({
post,
index,
categories,
browseSort = "popular",
onFavoriteChange,
}: {
post: Post;
index: number;
categories: Category[];
browseSort?: string;
onFavoriteChange?: (postId: string, favorited: boolean) => void;
}) {
const { t, lang } = useI18n();
const navigate = useNavigate();
@@ -126,11 +130,12 @@ function PopularRankRow({
<article className="relative flex items-center gap-3 overflow-hidden rounded-2xl bg-[#272632] p-3 transition hover:ring-1 hover:ring-ark-gold/55 md:h-[90px] md:gap-0 md:p-0">
<button
type="button"
onClick={() =>
navigate(
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`),
)
}
onClick={() => {
const params = new URLSearchParams();
if (browseSort) params.set("sort", browseSort);
params.set("post", post.id);
navigate(lp(`/browse?${params.toString()}`));
}}
aria-label={r.title}
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
/>
@@ -175,7 +180,12 @@ function PopularRankRow({
</div>
<div className="relative z-10 flex shrink-0 items-center gap-2 pr-4 md:pr-6">
<FavoriteButton resourceId={r.id} />
<FavoriteButton
resourceId={r.id}
onFavoriteChange={(favorited) =>
onFavoriteChange?.(post.id, favorited)
}
/>
{r.isDownloadable ? (
<button
type="button"

View File

@@ -34,6 +34,7 @@ export function MessageBubble({
post,
fluid = false,
variant = "default",
onFavoriteChange,
}: {
post: Post;
/** When true, fill the parent container instead of applying the standalone
@@ -41,6 +42,7 @@ export function MessageBubble({
fluid?: boolean;
/** Desktop latest-updates cards follow the dedicated Figma masonry design. */
variant?: MessageBubbleVariant;
onFavoriteChange?: (postId: string, favorited: boolean) => void;
}) {
const Bubble = pickBubble(post);
const isVisual =
@@ -71,6 +73,9 @@ export function MessageBubble({
resourceId={post.id}
size="sm"
className="absolute z-20 bottom-4 right-4 shadow-lg shadow-black/30"
onFavoriteChange={(favorited) =>
onFavoriteChange?.(post.id, favorited)
}
/>
) : null}
@@ -95,7 +100,13 @@ export function MessageBubble({
{formatDateTime(post.publishedAt)}
</time>
<div className="flex shrink-0 items-center gap-2">
<FavoriteButton resourceId={post.id} size="sm" />
<FavoriteButton
resourceId={post.id}
size="sm"
onFavoriteChange={(favorited) =>
onFavoriteChange?.(post.id, favorited)
}
/>
{isFileBubble && post.attachments[0] ? (
<BubbleAttachmentDownloadButton
postId={post.id}

View File

@@ -7,6 +7,7 @@ type FavoriteButtonProps = {
resourceId: string;
className?: string;
size?: "sm" | "md";
onFavoriteChange?: (favorited: boolean) => void;
};
function FigmaBookmarkIcon() {
@@ -31,6 +32,7 @@ export function FavoriteButton({
resourceId,
className = "",
size = "md",
onFavoriteChange,
}: FavoriteButtonProps) {
const { t } = useI18n();
const favorites = useFavorites();
@@ -50,7 +52,12 @@ export function FavoriteButton({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void favorites.toggleFavorite(resourceId).catch(() => undefined);
void favorites
.toggleFavorite(resourceId)
.then((favorited) => {
if (favorited !== null) onFavoriteChange?.(favorited);
})
.catch(() => undefined);
}}
disabled={pending}
aria-pressed={isFavorite}

View File

@@ -25,7 +25,7 @@ type FavoritesContextValue = {
pendingIds: Set<string>;
statusFor: (resourceId: string) => FavoriteStatus;
ensureFavoriteIds: (resourceIds: string[]) => Promise<void>;
toggleFavorite: (resourceId: string) => Promise<void>;
toggleFavorite: (resourceId: string) => Promise<boolean | null>;
markFavorite: (resourceId: string, favorited: boolean) => void;
};
@@ -158,17 +158,19 @@ export function FavoritesProvider({ children }: { children: ReactNode }) {
);
const runFavoriteMutation = useCallback(
async (resourceId: string) => {
if (!token) return;
async (resourceId: string): Promise<boolean | null> => {
if (!token) return null;
const currentlyFavorite = favoriteIds.has(resourceId);
const nextFavorited = !currentlyFavorite;
setPendingIds((prev) => new Set(prev).add(resourceId));
markFavorite(resourceId, !currentlyFavorite);
markFavorite(resourceId, nextFavorited);
try {
if (currentlyFavorite) await removeFavorite(token, resourceId);
else await addFavorite(token, resourceId);
showToast(
currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"),
);
return nextFavorited;
} catch (error) {
markFavorite(resourceId, currentlyFavorite);
if (isFavoritesAuthError(error)) handleAuthError();
@@ -191,9 +193,9 @@ export function FavoritesProvider({ children }: { children: ReactNode }) {
pendingAfterLoginRef.current = resourceId;
openLoginModal();
showToast(t("favoriteLoginRequired"));
return;
return null;
}
await runFavoriteMutation(resourceId);
return runFavoriteMutation(resourceId);
},
[openLoginModal, runFavoriteMutation, showToast, status, t, token],
);

View File

@@ -1,21 +1,50 @@
import { Heart, RotateCcw } from "lucide-react";
import { useEffect, useState } from "react";
import { itemsOrEmpty } from "../../api";
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { isFavoritesAuthError, listFavorites } from "../../favorites/api";
import { useFavorites } from "../../favorites/FavoritesProvider";
import { useI18n } from "../../i18n";
import { langQuery, useI18n, type Lang } from "../../i18n";
import { Reveal } from "../../motion";
import { MessageBubble } from "../../components/messageStream/MessageBubble";
import { PopularRankRow } from "../../components/PopularRankList";
import { useSetPageTitle } from "../../components/PageTitleContext";
import { Skeleton } from "../../components/Skeleton";
import { useWallet } from "../../wallet/WalletProvider";
const pageSize = 50;
function useCategories(lang: Lang): Category[] {
const [categories, setCategories] = useState<Category[]>(() => {
const cached = readJSONCache<Category[]>(
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
);
return cached ? itemsOrEmpty(cached) : [];
});
useEffect(() => {
const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`;
const cached = readJSONCache<Category[]>(url);
if (cached) setCategories(itemsOrEmpty(cached));
let cancelled = false;
getJSON<Category[]>(url)
.then((items) => {
if (!cancelled) setCategories(itemsOrEmpty(items));
})
.catch(() => {
if (!cancelled && !cached) setCategories([]);
});
return () => {
cancelled = true;
};
}, [lang]);
return categories;
}
export default function Favorites() {
const { t } = useI18n();
const { lang, t } = useI18n();
const wallet = useWallet();
const { markFavorite } = useFavorites();
const categories = useCategories(lang);
const [posts, setPosts] = useState<
Awaited<ReturnType<typeof listFavorites>>["items"]
>([]);
@@ -104,7 +133,7 @@ export default function Favorites() {
}
return (
<div className="mx-auto flex w-full max-w-full flex-col gap-3 overflow-x-clip py-2 md:max-w-[820px] md:py-4 lg:max-w-[1080px] xl:max-w-[1180px]">
<div className="mx-auto flex w-full max-w-full flex-col gap-2.5 overflow-x-clip py-2 md:max-w-[680px] md:gap-3 md:py-4 lg:max-w-[900px] xl:max-w-[1120px]">
{loading || !loaded ? (
Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-[132px] rounded-2xl" />
@@ -134,7 +163,15 @@ export default function Favorites() {
) : (
posts.map((post, index) => (
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
<MessageBubble post={post} />
<PopularRankRow
post={post}
index={index}
categories={categories}
browseSort=""
onFavoriteChange={(_, favorited) => {
if (!favorited) setReloadKey((value) => value + 1);
}}
/>
</Reveal>
))
)}