Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
106 lines
3.9 KiB
TypeScript
106 lines
3.9 KiB
TypeScript
import { ChevronRight } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { useDiscoverSection } from "~/api/discover.ts";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { ChipRow } from "./chips.tsx";
|
|
import { DiscoverCard, DiscoverCardSkeleton } from "./discover-card.tsx";
|
|
import {
|
|
apiQuery,
|
|
CATEGORIES,
|
|
normalize,
|
|
type RecommendSection,
|
|
sectionsOf,
|
|
toSearchParams,
|
|
} from "./discover-state.ts";
|
|
import { useDiscoverActions } from "./use-discover-actions.tsx";
|
|
|
|
// 推荐(对照 MP recommend.vue):按分区横排,分区进入视口附近才加载(各分区各自请求、并发),
|
|
// 每区「查看全部」带同样的参数切到探索。顶部按 全部 / 电影 / 剧集 / 动画 / 榜单 筛分区。
|
|
|
|
const CARD_W = "w-32 shrink-0 sm:w-36";
|
|
|
|
/** 进入视口(提前 600px)后为 true;没有 IntersectionObserver 时直接 true */
|
|
function useNearViewport<T extends Element>() {
|
|
const ref = useRef<T>(null);
|
|
const [near, setNear] = useState(() => typeof IntersectionObserver === "undefined");
|
|
useEffect(() => {
|
|
if (near || !ref.current) return;
|
|
const io = new IntersectionObserver((es) => es.some((e) => e.isIntersecting) && setNear(true), {
|
|
rootMargin: "600px 0px",
|
|
});
|
|
io.observe(ref.current);
|
|
return () => io.disconnect();
|
|
}, [near]);
|
|
return [ref, near] as const;
|
|
}
|
|
|
|
export function RecommendView({
|
|
category,
|
|
onCategory,
|
|
}: {
|
|
category: string;
|
|
onCategory: (c: string | undefined) => void;
|
|
}) {
|
|
const actions = useDiscoverActions();
|
|
return (
|
|
<div className="space-y-7">
|
|
<ChipRow label="分类" options={CATEGORIES.slice(1)} allLabel="全部" value={category} onChange={onCategory} />
|
|
{sectionsOf(category).map((s) => (
|
|
<Section key={s.key} section={s} actions={actions} />
|
|
))}
|
|
{actions.dialogs}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ section, actions }: { section: RecommendSection; actions: ReturnType<typeof useDiscoverActions> }) {
|
|
const [ref, near] = useNearViewport<HTMLElement>();
|
|
const state = normalize(section.explore);
|
|
const q = useDiscoverSection(apiQuery(state), near);
|
|
const items = q.data?.items ?? [];
|
|
const id = `discover-${section.key}`;
|
|
return (
|
|
<section ref={ref} aria-labelledby={id} className="min-w-0" data-section={section.key}>
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<h2 id={id} className="text-body font-semibold text-foreground">
|
|
{section.title}
|
|
</h2>
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link to={`?${toSearchParams(state)}`}>
|
|
查看全部
|
|
<ChevronRight />
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
{q.isError ? (
|
|
<div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-caption text-muted-foreground">
|
|
<span className="min-w-0 truncate">{errorMessage(q.error, "加载失败")}</span>
|
|
<Button variant="outline" size="sm" onClick={() => void q.refetch()}>
|
|
重试
|
|
</Button>
|
|
</div>
|
|
) : q.isSuccess && items.length === 0 ? (
|
|
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-caption text-muted-foreground">
|
|
这里暂时没有条目。
|
|
</p>
|
|
) : (
|
|
<ul className="scroll-bleed flex gap-3 overflow-x-auto">
|
|
{q.isSuccess
|
|
? items.slice(0, 20).map((it) => (
|
|
<li key={`${it.source}:${it.id}`} className={CARD_W}>
|
|
<DiscoverCard item={it} busy={actions.busyOf(it)} onAction={(a) => void actions.act(it, a)} />
|
|
</li>
|
|
))
|
|
: Array.from({ length: 8 }, (_, i) => (
|
|
<li key={i} className={CARD_W}>
|
|
<DiscoverCardSkeleton />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|