terry-wallet-login #15
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user