Files
UHD_Media_Tool/apps/web/app/components/discover/recommend-view.tsx
T

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>
);
}