feat(web): 发现页改为「推荐」分区 +「探索」筛选,卡片带本地状态与订阅 / 搜索资源
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
9a154bc249
commit
740e2fe67d
9 files changed
+1422
-211
No files matched your search
@@ -0,0 +1,105 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user