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 ## 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 ### Favorites list
@@ -196,36 +202,50 @@ GET /api/favorites?lang=&limit=&page=&sort=&category=&q=
Authorization: Bearer <token> Authorization: Bearer <token>
``` ```
Current staging response observed: Required production response:
```json ```json
{ {
"items": [ "items": [
{ {
"id": "...", "id": "...",
"postType": "image", "title": "...",
"description": "...",
"type": "...",
"categoryId": 11, "categoryId": 11,
"categorySlug": "official-assets", "categorySlug": "official-assets",
"language": "zh", "categoryName": "...",
"title": "..." "language": "...",
"sourceLanguage": "...",
"coverImage": "...",
"updatedAt": "...",
"publishedAt": "...",
"favoriteCount": 0,
"availability": "available"
} }
] ],
}
```
Recommended production response:
```json
{
"items": [],
"page": 1, "page": 1,
"limit": 24, "limit": 24,
"total": 0 "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. `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 ### Favorite status by ids
```http ```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 FavoriteSort = "favorited_at" | "published_at" | "hot";
export type FavoriteListResponse = { export type FavoriteListResponse = {
items: Resource[]; items: Post[];
page?: number; page?: number;
limit?: number; limit?: number;
total?: number; total?: number;

View File

@@ -18,20 +18,21 @@ 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";
import { useLocalizedPath } from "../../useLocalizedPath"; import { useLocalizedPath } from "../../useLocalizedPath";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; import { postToResource } from "../../utils/postResourceAdapter";
import { formatDateYmd } from "../../utils/format"; import { formatDateYmd } from "../../utils/format";
import { resourceTypeLabel } from "../../resourceTypeLabels"; import {
resourceLanguageLabel,
resourceTypeLabel,
} from "../../resourceTypeLabels";
const pageSize = 50; const pageSize = 50;
function useCategoryNameBySlug(lang: Lang): Map<string, string> { function useCategories(lang: Lang): Category[] {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => { useEffect(() => {
const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`; const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`;
const cached = readJSONCache<Category[]>(url); const cached = readJSONCache<Category[]>(url);
if (cached) setCategories(itemsOrEmpty(cached)); if (cached) setCategories(itemsOrEmpty(cached));
let cancelled = false; let cancelled = false;
getJSON<Category[]>(url) getJSON<Category[]>(url)
.then((items) => { .then((items) => {
@@ -40,99 +41,18 @@ function useCategoryNameBySlug(lang: Lang): Map<string, string> {
.catch(() => { .catch(() => {
if (!cancelled && !cached) setCategories([]); if (!cancelled && !cached) setCategories([]);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [lang]); }, [lang]);
return categories;
return useMemo(() => {
const map = new Map<string, string>();
categories.forEach((category) => map.set(category.slug, category.name));
return map;
}, [categories]);
} }
type FavoriteAttachment = { function FavoriteResourceCard({ resource }: { resource: Resource }) {
thumbnailUrl?: string; const { t } = useI18n();
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();
const lp = useLocalizedPath(); const lp = useLocalizedPath();
const unavailable = resource.availability === "unavailable"; const unavailable = resource.availability === "unavailable";
const cover = const cover = resource.coverImage || resource.previewUrl;
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");
return ( return (
<article <article
@@ -145,7 +65,7 @@ function FavoriteResourceCard({
{!unavailable ? ( {!unavailable ? (
<Link <Link
to={lp(`/resource/${resource.id}`)} 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" className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
/> />
) : null} ) : 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"> <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"> <h2 className="line-clamp-2 text-base font-bold leading-snug text-white md:text-lg">
{title} {resource.title}
</h2> </h2>
{description ? ( {resource.description ? (
<p className="line-clamp-2 text-sm leading-6 text-neutral-400"> <p className="line-clamp-2 text-sm leading-6 text-neutral-400">
{description} {resource.description}
</p> </p>
) : null} ) : null}
<div className="mt-auto flex flex-wrap items-center gap-2 text-xs text-neutral-400"> <div className="mt-auto flex flex-wrap items-center gap-2 text-xs text-neutral-400">
<span className="rounded-full bg-[#1f2028] px-2.5 py-1 text-neutral-200"> {resource.categoryName ? (
{cleanCategoryDisplayName(categoryLabel)} <span className="rounded-full bg-[#1f2028] px-2.5 py-1 text-neutral-200">
</span> {resource.categoryName}
<span>{typeLabel}</span> </span>
{date ? ( ) : null}
{resource.type ? (
<span>{resourceTypeLabel(t, resource.type)}</span>
) : null}
{resource.language ? (
<> <>
<span>·</span> <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} ) : null}
{typeof resource.favoriteCount === "number" ? ( {typeof resource.favoriteCount === "number" ? (
@@ -208,8 +140,10 @@ export default function Favorites() {
const { lang, t } = useI18n(); const { lang, t } = useI18n();
const wallet = useWallet(); const wallet = useWallet();
const { markFavorite } = useFavorites(); const { markFavorite } = useFavorites();
const categoryNameBySlug = useCategoryNameBySlug(lang); const categories = useCategories(lang);
const [items, setItems] = useState<FavoriteResource[]>([]); const [posts, setPosts] = useState<
Awaited<ReturnType<typeof listFavorites>>["items"]
>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -219,7 +153,7 @@ export default function Favorites() {
useEffect(() => { useEffect(() => {
if (!wallet.token || wallet.status !== "loggedIn") { if (!wallet.token || wallet.status !== "loggedIn") {
setItems([]); setPosts([]);
setLoading(false); setLoading(false);
setLoaded(false); setLoaded(false);
setError(""); setError("");
@@ -237,9 +171,9 @@ export default function Favorites() {
}) })
.then((data) => { .then((data) => {
if (cancelled) return; if (cancelled) return;
const resources = itemsOrEmpty(data.items) as FavoriteResource[]; const items = itemsOrEmpty(data.items);
setItems(resources); setPosts(items);
resources.forEach((resource) => markFavorite(resource.id, true)); items.forEach((post) => markFavorite(post.id, true));
setLoaded(true); setLoaded(true);
}) })
.catch((err) => { .catch((err) => {
@@ -261,6 +195,11 @@ export default function Favorites() {
}; };
}, [markFavorite, reloadKey, t, wallet]); }, [markFavorite, reloadKey, t, wallet]);
const resources = useMemo(
() => posts.map((post) => postToResource(post, lang, categories)),
[posts, lang, categories],
);
if (wallet.status === "loading") { if (wallet.status === "loading") {
return ( return (
<Reveal className="mx-auto grid max-w-[980px] gap-3 px-0 py-2 md:py-4"> <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")} {t("walletRetry")}
</button> </button>
</div> </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"> <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} /> <Heart className="h-10 w-10 text-ark-gold/60" strokeWidth={1.8} />
<h2 className="text-xl font-semibold text-white"> <h2 className="text-xl font-semibold text-white">
@@ -323,12 +262,8 @@ export default function Favorites() {
</p> </p>
</div> </div>
) : ( ) : (
items.map((resource) => ( resources.map((resource) => (
<FavoriteResourceCard <FavoriteResourceCard key={resource.id} resource={resource} />
key={resource.id}
categoryNameBySlug={categoryNameBySlug}
resource={resource}
/>
)) ))
)} )}
</div> </div>

View File

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