feat: Carousel

main
mackt 3 weeks ago
parent 6864cf3395
commit aed3e4e49d

@ -16,7 +16,7 @@
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.1.5",
"@sentry/nextjs": "^8",
"@types/qrcode": "^1.5.5",
@ -24,6 +24,9 @@
"@types/uuid": "^9.0.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"embla-carousel-auto-scroll": "^8.3.1",
"embla-carousel-autoplay": "^8.3.1",
"embla-carousel-react": "^8.3.1",
"immer": "^10.0.4",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",

@ -1,8 +1,24 @@
/** 期刊列表 */
import { notFound } from 'next/navigation';
import { Category, JournalRecommendList, JournalItem, Pagination, RedirectCheck, ButtonFM } from '@/components';
import { apiSearchCategoryList, apiGetJournalRecommendWithCollect, apiJournalList, apiGetTags } from '@/services';
import { Carousel } from '@/components/ui/carousel';
import {
Category,
JournalRecommendList,
JournalItem,
Pagination,
RedirectCheck,
ButtonFM,
HomeCarousel,
} from '@/components';
import {
apiSearchCategoryList,
apiGetJournalRecommendWithCollect,
apiJournalList,
apiGetTags,
apiCarousel,
} from '@/services';
export async function generateMetadata({ params: { category } }: { params: { category?: string } }) {
/**
@ -14,7 +30,7 @@ export async function generateMetadata({ params: { category } }: { params: { cat
if (category === 'all' || !category) return;
const categoryList = await getCategoryList();
const categoryInfo: Category | undefined = categoryList.find((item: Category) => item.nameEn === category); // 一级分类
const tagList = await apiGetTagList();
const tagList = await getTagList();
const tagInfo: TagInfo | undefined = tagList.find((item: TagInfo) => item.engName === category); // 二级分类
const title = categoryInfo?.nameCh || tagInfo?.name;
@ -24,7 +40,14 @@ export async function generateMetadata({ params: { category } }: { params: { cat
return { title, description };
}
const apiGetTagList = async () => {
const getCarousel = async () => {
const result = await apiCarousel();
if (result.code === 200) {
return result.data.filter((item: Carousel) => item.outerLink);
}
};
const getTagList = async () => {
const result = await apiGetTags();
if (result.code !== 200) return notFound();
const list: TagInfo[] = result.data.map((item) => {
@ -74,12 +97,13 @@ export default async function Journal({ params }: { params: { category?: string;
// 获取期刊分类
const categoryList = await getCategoryList();
const tagList = await apiGetTagList();
const tagList = await getTagList();
const categoryInfo: TagInfo | undefined = tagList.find((item: TagInfo) => item.engName === category);
// 获取热门推荐、期刊列表
const [recommendList, journalList] = await Promise.all([
const [recommendList, journalList, carouselList] = await Promise.all([
getRecommendList(),
getJournalList({ category: categoryInfo?.id ?? '', page: Number(page) }),
getCarousel(),
]);
return (
@ -117,7 +141,11 @@ export default async function Journal({ params }: { params: { category?: string;
{/* 热门推荐 */}
<div className="w-346px mt-202px">
{!!recommendList?.length ? <JournalRecommendList list={recommendList} /> : <div />}
{!!recommendList?.length && <JournalRecommendList list={recommendList} />}
{!!carouselList?.length && (
<HomeCarousel className="mt-40px w-340px h-120px overflow-hidden" list={carouselList} />
)}
</div>
</main>
);

@ -37,7 +37,7 @@ const CommentList = ({
if (res.code === 200) {
const list = res.data.rows;
if (list.length < pageSize) setHasMore(false); // 如果 length < pageSize 则不再加载
const finalList = list.filter((item) => !newCommentList.some((nItem) => item._id === nItem._id)); // 过滤自己新发布的评论
const finalList = list.filter((item: Comment) => !newCommentList.some((nItem) => item._id === nItem._id)); // 过滤自己新发布的评论
setPage((prePage) => prePage + 1);
setCommentList([...commentList, ...finalList]);
}

@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import Autoplay from 'embla-carousel-autoplay';
import Image from 'next/image';
import Link from 'next/link';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel';
export default function HomeCarousel({ className, list }: { className: string; list: Carousel[] }) {
const plugin = React.useRef(Autoplay({ delay: 2000, stopOnInteraction: false, stopOnMouseEnter: true }));
return (
<Carousel
plugins={[plugin.current]}
className={className}
onMouseEnter={plugin.current.stop}
onMouseLeave={plugin.current.reset}
opts={{
align: 'start',
loop: true,
}}
>
<CarouselContent>
{list.map((item, index) => (
<CarouselItem key={index}>
<Link className="group" href={item.outerLink} target="_blank">
<Image
className="group-hover:transform-scale-103 transition-transform-300"
width={340}
height={120}
unoptimized
src={item.imgPath}
alt=""
/>
</Link>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
);
}

@ -13,6 +13,7 @@ export { default as IconPlayAll } from './Icon/PlayAll';
// Home
export { default as Category } from './Category';
export { default as ContributorCard } from './ContributorCard';
export { default as HomeCarousel } from './HomeCarousel';
// Login
export { default as Login } from './Login/Login';

@ -1,4 +1,4 @@
// @ts-nocheck
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
@ -33,7 +33,7 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'size'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

@ -0,0 +1,226 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/utils';
import { Button } from './button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative group', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn('min-w-0 shrink-0 grow-0 basis-full', orientation === 'horizontal' ? 'pl-4' : 'pt-4', className)}
{...props}
/>
);
},
);
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-6 w-6 rounded-full opacity-0 group-hover:opacity-30 hover:opacity-80',
orientation === 'horizontal'
? 'left-6 top-1/2 -translate-y-1/2'
: 'top-8 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-3 w-3" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-6 w-6 rounded-full opacity-0 group-hover:opacity-30 hover:opacity-80',
orientation === 'horizontal'
? 'right-6 top-1/2 -translate-y-1/2'
: 'bottom-8 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-3 w-3" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = 'CarouselNext';
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

@ -124,3 +124,14 @@ export const apiGetTags: () => Promise<FetchResponse<TagInfo[]>> = async () => {
const res = await serverHttp.get(request);
return res;
};
export const apiCarousel: () => Promise<FetchResponse<Carousel[]>> = async () => {
const request = new Request('http://localhost/', {
method: 'GET',
headers: {
requestUrl: '/user/index/carousel',
},
});
const res = await serverHttp.get(request);
return res;
};

@ -197,3 +197,15 @@ interface TagInfo {
engName: string;
nameCh: string;
}
/**
* 0:1:2:3:
*/
interface Carousel {
carouselId: number;
imgPath: string;
objectType: number;
outerLink: string;
status: number;
publishTime: string;
}

@ -1476,6 +1476,11 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@1.1.0":
version "1.1.0"
resolved "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz#656432461fc8283d7b591dcf0d79152fae9ecc74"
integrity sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==
"@radix-ui/react-context@1.0.1":
version "1.0.1"
resolved "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
@ -1646,7 +1651,7 @@
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-use-size" "1.0.1"
"@radix-ui/react-slot@1.0.2", "@radix-ui/react-slot@^1.0.2":
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
@ -1654,6 +1659,13 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-slot@^1.1.0":
version "1.1.0"
resolved "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84"
integrity sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==
dependencies:
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-toast@^1.1.5":
version "1.1.5"
resolved "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.1.5.tgz#f5788761c0142a5ae9eb97f0051fd3c48106d9e6"
@ -3341,6 +3353,34 @@ electron-to-chromium@^1.4.668:
resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.702.tgz"
integrity sha512-LYLXyEUsZ3nNSwiOWjI88N1PJUAMU2QphQSgGLVkFnb3FxZxNui2Vzi2PaKPgPWbsWbZstZnh6BMf/VQJamjiQ==
embla-carousel-auto-scroll@^8.3.1:
version "8.3.1"
resolved "https://registry.npmmirror.com/embla-carousel-auto-scroll/-/embla-carousel-auto-scroll-8.3.1.tgz#094a09d6c0e5d9f1130197d66432710833d6684b"
integrity sha512-jrLEZLceCdQqTGCf9S4W1/AX4S8BwDmiW3eDKdpFEbhrQ187t7MVRDr9M17ljI7V4DMorExgZzEHnokjGS+0SQ==
embla-carousel-autoplay@^8.3.1:
version "8.3.1"
resolved "https://registry.npmmirror.com/embla-carousel-autoplay/-/embla-carousel-autoplay-8.3.1.tgz#62b45cf55dc001d82b2ac1829f6b3207e7af35a6"
integrity sha512-L8THF1AJJSQtlNa1wZ6lEKh/CiZssE3TsVFtabQNsS+pc1O1O/YTIYCC3khdQAztGMOBf3WfVDIY/4AIfQj3JQ==
embla-carousel-react@^8.3.1:
version "8.3.1"
resolved "https://registry.npmmirror.com/embla-carousel-react/-/embla-carousel-react-8.3.1.tgz#f6d91f484b00704411524cf01feb80fdcff7dde2"
integrity sha512-gBY0zM+2ASvKFwRpTIOn2SLifFqOKKap9R/y0iCpJWS3bc8OHVEn2gAThGYl2uq0N+hu9aBiswffL++OYZOmDQ==
dependencies:
embla-carousel "8.3.1"
embla-carousel-reactive-utils "8.3.1"
embla-carousel-reactive-utils@8.3.1:
version "8.3.1"
resolved "https://registry.npmmirror.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.3.1.tgz#8e92407f92f55e5f38ef22d24c018077476f1e11"
integrity sha512-Js6rTTINNGnUGPu7l5kTcheoSbEnP5Ak2iX0G9uOoI8okTNLMzuWlEIpYFd1WP0Sq82FFcLkKM2oiO6jcElZ/Q==
embla-carousel@8.3.1:
version "8.3.1"
resolved "https://registry.npmmirror.com/embla-carousel/-/embla-carousel-8.3.1.tgz#8f5dcd0dc001977efd2bca54d620b6e6736a1a85"
integrity sha512-DutFjtEO586XptDn4cwvBJwsR/8fMa4jUk5Jk2g+/elKgu8mdn0Z2sx33g4JskvbLc1/6P8Xg4QlfELGJFcP5A==
emoji-regex@^10.3.0:
version "10.3.0"
resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.3.0.tgz"

Loading…
Cancel
Save