fix: refresh favorites after unfavorite
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user