fix: align favorites page with post adapter
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 28s

This commit is contained in:
TerryM
2026-06-04 17:46:09 +08:00
parent 4f6cbbc314
commit ec98ff5a03
4 changed files with 86 additions and 130 deletions

View File

@@ -187,7 +187,13 @@ If backend decides to keep the simplified `{ address }` login, please explicitly
## Priority 2 — Normalize favorites response contract
Frontend currently supports the staging response shape, but the response should be made explicit.
Frontend currently supports the staging response shape, but the response must be made explicit and self-sufficient. The frontend renders favorites as plain strings and does not perform per-resource translation, slug-to-name lookup, category fetching, or localization fallback.
### `lang` semantics
`?lang=<ui-lang>` on `GET /api/favorites` is a **display resolution hint**, not a filter. It must NOT filter favorites by post language. A user who favorited Chinese and English posts must see both regardless of `lang`. `lang` only tells the backend which language to resolve display strings into.
**Current staging behavior is wrong**: sending `?lang=en` on staging returns zero items for users whose favorites are Chinese posts, and vice versa. Because of this, the frontend currently does NOT send `lang` on `GET /api/favorites`. Once the backend treats `lang` as a resolve hint instead of a filter, the frontend will send `lang` again so resolved strings come back in the user's UI language.
### Favorites list
@@ -196,36 +202,50 @@ GET /api/favorites?lang=&limit=&page=&sort=&category=&q=
Authorization: Bearer <token>
```
Current staging response observed:
Required production response:
```json
{
"items": [
{
"id": "...",
"postType": "image",
"title": "...",
"description": "...",
"type": "...",
"categoryId": 11,
"categorySlug": "official-assets",
"language": "zh",
"title": "..."
"categoryName": "...",
"language": "...",
"sourceLanguage": "...",
"coverImage": "...",
"updatedAt": "...",
"publishedAt": "...",
"favoriteCount": 0,
"availability": "available"
}
]
}
```
Recommended production response:
```json
{
"items": [],
],
"page": 1,
"limit": 24,
"total": 0
}
```
Fields that must be present and pre-resolved by the backend when `lang` is supplied:
- `title` — already in `lang`. If a translation does not exist, fall back to the post's source language.
- `description` — same rule as `title`.
- `categoryName` — localized category name for `lang`. Frontend must not look up categories by slug.
- `type` — a string the frontend can display directly. If you need both a raw type code and a label, add `typeLabel` and use that for display.
- `language` — a human-readable label for the post's source language, in `lang`. e.g. for `lang=zh-CN` a Chinese post returns `language: "中文"`. If you prefer to keep `language` as a code, add `languageLabel` and use it for display.
- `coverImage` — a usable image URL. The frontend will not fall back to attachment arrays.
- `updatedAt`, `publishedAt` — ISO timestamps.
- `favoriteCount` — optional but recommended.
- `availability``"available" | "unavailable"`.
`page`, `limit`, and `total` are needed for correct pagination.
The frontend must never need to: load `/api/categories`, parse `localizations` maps, walk `attachments`, or translate `type` / `language` codes for this page.
### Favorite status by ids
```http

View File

@@ -1,9 +1,10 @@
import { apiBase, itemsOrEmpty, type Resource } from "../api";
import { apiBase, itemsOrEmpty } from "../api";
import type { Post } from "../types/post";
export type FavoriteSort = "favorited_at" | "published_at" | "hot";
export type FavoriteListResponse = {
items: Resource[];
items: Post[];
page?: number;
limit?: number;
total?: number;

View File

@@ -18,20 +18,21 @@ import { useSetPageTitle } from "../../components/PageTitleContext";
import { Skeleton } from "../../components/Skeleton";
import { useWallet } from "../../wallet/WalletProvider";
import { useLocalizedPath } from "../../useLocalizedPath";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
import { postToResource } from "../../utils/postResourceAdapter";
import { formatDateYmd } from "../../utils/format";
import { resourceTypeLabel } from "../../resourceTypeLabels";
import {
resourceLanguageLabel,
resourceTypeLabel,
} from "../../resourceTypeLabels";
const pageSize = 50;
function useCategoryNameBySlug(lang: Lang): Map<string, string> {
function useCategories(lang: Lang): Category[] {
const [categories, setCategories] = useState<Category[]>([]);
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) => {
@@ -40,99 +41,18 @@ function useCategoryNameBySlug(lang: Lang): Map<string, string> {
.catch(() => {
if (!cancelled && !cached) setCategories([]);
});
return () => {
cancelled = true;
};
}, [lang]);
return useMemo(() => {
const map = new Map<string, string>();
categories.forEach((category) => map.set(category.slug, category.name));
return map;
}, [categories]);
return categories;
}
type FavoriteAttachment = {
thumbnailUrl?: string;
thumbUrl?: string;
posterUrl?: string;
url?: string;
};
type FavoriteLocalization = {
title?: string;
text?: string;
description?: string;
};
type FavoriteResource = Resource & {
postType?: string;
sourceLanguage?: string;
createdAt?: string;
attachments?: FavoriteAttachment[];
localizations?: Record<string, FavoriteLocalization>;
};
function localizationKeys(lang: Lang): string[] {
if (lang === "zh-CN") return ["zh", "zh-CN", "zh-Hans"];
return [lang];
}
function localizedResourceText(
resource: FavoriteResource,
lang: Lang,
field: "title" | "description",
): string {
for (const key of localizationKeys(lang)) {
const localized = resource.localizations?.[key];
if (!localized) continue;
if (field === "title" && localized.title?.trim()) return localized.title;
if (field === "description") {
const text = localized.description || localized.text;
if (text?.trim()) return text;
}
}
if (field === "title") return resource.title;
return resource.description || resource.bodyText || "";
}
function firstAttachmentUrl(resource: FavoriteResource): string {
const attachment = resource.attachments?.[0];
return (
attachment?.thumbnailUrl ||
attachment?.thumbUrl ||
attachment?.posterUrl ||
attachment?.url ||
""
);
}
function FavoriteResourceCard({
categoryNameBySlug,
resource,
}: {
categoryNameBySlug: Map<string, string>;
resource: FavoriteResource;
}) {
const { lang, t } = useI18n();
function FavoriteResourceCard({ resource }: { resource: Resource }) {
const { t } = useI18n();
const lp = useLocalizedPath();
const unavailable = resource.availability === "unavailable";
const cover =
resource.coverImage || resource.previewUrl || firstAttachmentUrl(resource);
const categoryLabel =
(resource.categorySlug && categoryNameBySlug.get(resource.categorySlug)) ||
resource.categoryName ||
resource.categorySlug ||
"ARK";
const typeLabel = resourceTypeLabel(
t,
resource.type || resource.postType || "resource",
);
const date =
resource.updatedAt || resource.publishedAt || resource.createdAt || "";
const title = localizedResourceText(resource, lang, "title");
const description = localizedResourceText(resource, lang, "description");
const cover = resource.coverImage || resource.previewUrl;
return (
<article
@@ -145,7 +65,7 @@ function FavoriteResourceCard({
{!unavailable ? (
<Link
to={lp(`/resource/${resource.id}`)}
aria-label={title}
aria-label={resource.title}
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
/>
) : null}
@@ -172,22 +92,34 @@ function FavoriteResourceCard({
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-col gap-2 pr-11">
<h2 className="line-clamp-2 text-base font-bold leading-snug text-white md:text-lg">
{title}
{resource.title}
</h2>
{description ? (
{resource.description ? (
<p className="line-clamp-2 text-sm leading-6 text-neutral-400">
{description}
{resource.description}
</p>
) : null}
<div className="mt-auto flex flex-wrap items-center gap-2 text-xs text-neutral-400">
{resource.categoryName ? (
<span className="rounded-full bg-[#1f2028] px-2.5 py-1 text-neutral-200">
{cleanCategoryDisplayName(categoryLabel)}
{resource.categoryName}
</span>
<span>{typeLabel}</span>
{date ? (
) : null}
{resource.type ? (
<span>{resourceTypeLabel(t, resource.type)}</span>
) : null}
{resource.language ? (
<>
<span>·</span>
<time dateTime={date}>{formatDateYmd(date)}</time>
<span>{resourceLanguageLabel(t, resource.language)}</span>
</>
) : null}
{resource.updatedAt ? (
<>
<span>·</span>
<time dateTime={resource.updatedAt}>
{formatDateYmd(resource.updatedAt)}
</time>
</>
) : null}
{typeof resource.favoriteCount === "number" ? (
@@ -208,8 +140,10 @@ export default function Favorites() {
const { lang, t } = useI18n();
const wallet = useWallet();
const { markFavorite } = useFavorites();
const categoryNameBySlug = useCategoryNameBySlug(lang);
const [items, setItems] = useState<FavoriteResource[]>([]);
const categories = useCategories(lang);
const [posts, setPosts] = useState<
Awaited<ReturnType<typeof listFavorites>>["items"]
>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
@@ -219,7 +153,7 @@ export default function Favorites() {
useEffect(() => {
if (!wallet.token || wallet.status !== "loggedIn") {
setItems([]);
setPosts([]);
setLoading(false);
setLoaded(false);
setError("");
@@ -237,9 +171,9 @@ export default function Favorites() {
})
.then((data) => {
if (cancelled) return;
const resources = itemsOrEmpty(data.items) as FavoriteResource[];
setItems(resources);
resources.forEach((resource) => markFavorite(resource.id, true));
const items = itemsOrEmpty(data.items);
setPosts(items);
items.forEach((post) => markFavorite(post.id, true));
setLoaded(true);
})
.catch((err) => {
@@ -261,6 +195,11 @@ export default function Favorites() {
};
}, [markFavorite, reloadKey, t, wallet]);
const resources = useMemo(
() => posts.map((post) => postToResource(post, lang, categories)),
[posts, lang, categories],
);
if (wallet.status === "loading") {
return (
<Reveal className="mx-auto grid max-w-[980px] gap-3 px-0 py-2 md:py-4">
@@ -312,7 +251,7 @@ export default function Favorites() {
{t("walletRetry")}
</button>
</div>
) : items.length === 0 ? (
) : resources.length === 0 ? (
<div className="flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-3xl border border-white/10 bg-[#17171d] p-8 text-center">
<Heart className="h-10 w-10 text-ark-gold/60" strokeWidth={1.8} />
<h2 className="text-xl font-semibold text-white">
@@ -323,12 +262,8 @@ export default function Favorites() {
</p>
</div>
) : (
items.map((resource) => (
<FavoriteResourceCard
key={resource.id}
categoryNameBySlug={categoryNameBySlug}
resource={resource}
/>
resources.map((resource) => (
<FavoriteResourceCard key={resource.id} resource={resource} />
))
)}
</div>

View File

@@ -46,7 +46,7 @@ export function postToResource(
title,
description: postDisplayText(post, lang),
type: inferType(post, first),
language: post.language,
language: post.sourceLanguage || post.language,
categoryId: post.categoryId,
categorySlug: post.categorySlug,
categoryName: category?.name || post.categorySlug,