feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { Children, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function CategoryCardRail({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const items = Children.toArray(children);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [rowHeight, setRowHeight] = useState<number>();
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
const [atTop, setAtTop] = useState(true);
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
|
||||
const updateState = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const max = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
||||
setOverflowing(max > 2);
|
||||
setAtTop(viewport.scrollTop <= 2);
|
||||
setAtBottom(viewport.scrollTop >= max - 2);
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const first = trackRef.current?.querySelector<HTMLElement>("[data-rail-card]");
|
||||
if (!first) return;
|
||||
setRowHeight(Math.ceil(first.getBoundingClientRect().height + 2));
|
||||
requestAnimationFrame(updateState);
|
||||
}, [updateState]);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (trackRef.current) observer.observe(trackRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [items.length, measure]);
|
||||
|
||||
const scrollRow = (direction: -1 | 1) => {
|
||||
const viewport = viewportRef.current;
|
||||
const cards = trackRef.current?.querySelectorAll<HTMLElement>("[data-rail-card]");
|
||||
if (!viewport || !cards?.length) return;
|
||||
const absoluteRows = [...new Set([...cards].map((card) => Math.round(card.offsetTop)))].sort((a, b) => a - b);
|
||||
const origin = absoluteRows[0] ?? 0;
|
||||
const rows = absoluteRows.map((top) => top - origin);
|
||||
const current = viewport.scrollTop;
|
||||
const target = direction > 0
|
||||
? rows.find((top) => top > current + 3) ?? rows.at(-1) ?? 0
|
||||
: [...rows].reverse().find((top) => top < current - 3) ?? rows[0] ?? 0;
|
||||
viewport.scrollTo({ top: target, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)} data-slot="category-card-rail">
|
||||
<div
|
||||
className="scrollbar-thin overflow-x-hidden overflow-y-auto overscroll-contain pr-2 scroll-smooth"
|
||||
ref={viewportRef}
|
||||
style={rowHeight ? { height: rowHeight } : undefined}
|
||||
onScroll={updateState}
|
||||
>
|
||||
<div className="grid auto-rows-fr grid-cols-2 gap-4 xl:grid-cols-4" ref={trackRef}>
|
||||
{items.map((item, index) => (
|
||||
<div className="min-w-0 [&>*]:h-full" data-rail-card key={index}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{overflowing && (
|
||||
<div className="mt-2 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="mr-1">在卡片区域上下滚动查看更多模型大类</span>
|
||||
<Button aria-label="上一行模型大类" disabled={atTop} size="icon-xs" variant="outline" onClick={() => scrollRow(-1)}>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button aria-label="下一行模型大类" disabled={atBottom} size="icon-xs" variant="outline" onClick={() => scrollRow(1)}>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user