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, post,
index, index,
categories, categories,
browseSort = "popular",
onFavoriteChange,
}: { }: {
post: Post; post: Post;
index: number; index: number;
categories: Category[]; categories: Category[];
browseSort?: string;
onFavoriteChange?: (postId: string, favorited: boolean) => void;
}) { }) {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
const navigate = useNavigate(); 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"> <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 <button
type="button" type="button"
onClick={() => onClick={() => {
navigate( const params = new URLSearchParams();
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`), if (browseSort) params.set("sort", browseSort);
) params.set("post", post.id);
} navigate(lp(`/browse?${params.toString()}`));
}}
aria-label={r.title} aria-label={r.title}
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70" 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>
<div className="relative z-10 flex shrink-0 items-center gap-2 pr-4 md:pr-6"> <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 ? ( {r.isDownloadable ? (
<button <button
type="button" type="button"

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,50 @@
import { Heart, RotateCcw } from "lucide-react"; import { Heart, RotateCcw } from "lucide-react";
import { useEffect, useState } from "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 { isFavoritesAuthError, listFavorites } from "../../favorites/api";
import { useFavorites } from "../../favorites/FavoritesProvider"; import { useFavorites } from "../../favorites/FavoritesProvider";
import { useI18n } from "../../i18n"; import { langQuery, useI18n, type Lang } from "../../i18n";
import { Reveal } from "../../motion"; import { Reveal } from "../../motion";
import { MessageBubble } from "../../components/messageStream/MessageBubble"; import { PopularRankRow } from "../../components/PopularRankList";
import { useSetPageTitle } from "../../components/PageTitleContext"; import { useSetPageTitle } from "../../components/PageTitleContext";
import { Skeleton } from "../../components/Skeleton"; import { Skeleton } from "../../components/Skeleton";
import { useWallet } from "../../wallet/WalletProvider"; import { useWallet } from "../../wallet/WalletProvider";
const pageSize = 50; 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() { export default function Favorites() {
const { t } = useI18n(); const { lang, t } = useI18n();
const wallet = useWallet(); const wallet = useWallet();
const { markFavorite } = useFavorites(); const { markFavorite } = useFavorites();
const categories = useCategories(lang);
const [posts, setPosts] = useState< const [posts, setPosts] = useState<
Awaited<ReturnType<typeof listFavorites>>["items"] Awaited<ReturnType<typeof listFavorites>>["items"]
>([]); >([]);
@@ -104,7 +133,7 @@ export default function Favorites() {
} }
return ( 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 ? ( {loading || !loaded ? (
Array.from({ length: 4 }).map((_, index) => ( Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-[132px] rounded-2xl" /> <Skeleton key={index} className="h-[132px] rounded-2xl" />
@@ -134,7 +163,15 @@ export default function Favorites() {
) : ( ) : (
posts.map((post, index) => ( posts.map((post, index) => (
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}> <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> </Reveal>
)) ))
)} )}