14 changed files with 26 additions and 685 deletions
@ -1,129 +0,0 @@ |
|||||||
import { Carousel, CarouselApi, CarouselContent, CarouselItem } from '@/components/ui/carousel' |
|
||||||
import { isTouchDevice } from '@/lib/utils' |
|
||||||
import { TImageInfo } from '@/types' |
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' |
|
||||||
import { useEffect, useState } from 'react' |
|
||||||
import Lightbox from 'yet-another-react-lightbox' |
|
||||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom' |
|
||||||
import Image from '../Image' |
|
||||||
|
|
||||||
export function ImageCarousel({ images }: { images: TImageInfo[] }) { |
|
||||||
const [api, setApi] = useState<CarouselApi>() |
|
||||||
const [currentIndex, setCurrentIndex] = useState(0) |
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(-1) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!api) { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
setCurrentIndex(api.selectedScrollSnap()) |
|
||||||
|
|
||||||
api.on('select', () => { |
|
||||||
setCurrentIndex(api.selectedScrollSnap()) |
|
||||||
}) |
|
||||||
}, [api]) |
|
||||||
|
|
||||||
const handlePhotoClick = (event: React.MouseEvent, current: number) => { |
|
||||||
event.preventDefault() |
|
||||||
setLightboxIndex(current) |
|
||||||
} |
|
||||||
|
|
||||||
const onDotClick = (index: number) => { |
|
||||||
api?.scrollTo(index) |
|
||||||
setCurrentIndex(index) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="space-y-2"> |
|
||||||
<Carousel className="w-full" setApi={setApi}> |
|
||||||
<CarouselContent className="xl:px-4"> |
|
||||||
{images.map((image, index) => ( |
|
||||||
<CarouselItem key={index} className="xl:basis-2/3 cursor-zoom-in"> |
|
||||||
<Image |
|
||||||
className="xl:rounded-lg max-h-[75vh]" |
|
||||||
classNames={{ |
|
||||||
errorPlaceholder: 'aspect-square' |
|
||||||
}} |
|
||||||
image={image} |
|
||||||
onClick={(e) => handlePhotoClick(e, index)} |
|
||||||
/> |
|
||||||
</CarouselItem> |
|
||||||
))} |
|
||||||
</CarouselContent> |
|
||||||
</Carousel> |
|
||||||
{!isTouchDevice() && ( |
|
||||||
<ArrowButton total={images.length} currentIndex={currentIndex} onClick={onDotClick} /> |
|
||||||
)} |
|
||||||
{images.length > 1 && ( |
|
||||||
<CarouselDot total={images.length} currentIndex={currentIndex} onClick={onDotClick} /> |
|
||||||
)} |
|
||||||
<Lightbox |
|
||||||
index={lightboxIndex} |
|
||||||
slides={images.map(({ url }) => ({ src: url }))} |
|
||||||
plugins={[Zoom]} |
|
||||||
open={lightboxIndex >= 0} |
|
||||||
close={() => setLightboxIndex(-1)} |
|
||||||
controller={{ |
|
||||||
closeOnBackdropClick: true, |
|
||||||
closeOnPullUp: true, |
|
||||||
closeOnPullDown: true |
|
||||||
}} |
|
||||||
styles={{ toolbar: { paddingTop: '2.25rem' } }} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function CarouselDot({ |
|
||||||
total, |
|
||||||
currentIndex, |
|
||||||
onClick |
|
||||||
}: { |
|
||||||
total: number |
|
||||||
currentIndex: number |
|
||||||
onClick: (index: number) => void |
|
||||||
}) { |
|
||||||
return ( |
|
||||||
<div className="w-full flex gap-1 justify-center"> |
|
||||||
{Array.from({ length: total }).map((_, index) => ( |
|
||||||
<div |
|
||||||
key={index} |
|
||||||
className={`w-2 h-2 rounded-full cursor-pointer ${index === currentIndex ? 'bg-foreground/40' : 'bg-muted'}`} |
|
||||||
onClick={() => onClick(index)} |
|
||||||
/> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function ArrowButton({ |
|
||||||
total, |
|
||||||
currentIndex, |
|
||||||
onClick |
|
||||||
}: { |
|
||||||
total: number |
|
||||||
currentIndex: number |
|
||||||
onClick: (index: number) => void |
|
||||||
}) { |
|
||||||
return ( |
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity"> |
|
||||||
<div className="w-full flex justify-between px-2 xl:px-4"> |
|
||||||
<button |
|
||||||
onClick={() => onClick(currentIndex - 1)} |
|
||||||
className="w-8 h-8 rounded-full bg-background/50 flex justify-center items-center pointer-events-auto disabled:pointer-events-none disabled:opacity-0" |
|
||||||
disabled={currentIndex === 0} |
|
||||||
> |
|
||||||
<ChevronLeftIcon className="w-4 h-4" /> |
|
||||||
</button> |
|
||||||
<button |
|
||||||
onClick={() => onClick(currentIndex + 1)} |
|
||||||
className="w-8 h-8 rounded-full bg-background/50 flex justify-center items-center pointer-events-auto disabled:pointer-events-none disabled:opacity-0" |
|
||||||
disabled={currentIndex === total - 1} |
|
||||||
> |
|
||||||
<ChevronRightIcon className="w-4 h-4" /> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,74 +0,0 @@ |
|||||||
import { |
|
||||||
EmbeddedEmojiParser, |
|
||||||
EmbeddedHashtagParser, |
|
||||||
EmbeddedLNInvoiceParser, |
|
||||||
EmbeddedMentionParser, |
|
||||||
EmbeddedNormalUrlParser, |
|
||||||
EmbeddedWebsocketUrlParser, |
|
||||||
parseContent |
|
||||||
} from '@/lib/content-parser' |
|
||||||
import { getImageInfosFromEvent } from '@/lib/event' |
|
||||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { memo, useMemo } from 'react' |
|
||||||
import { |
|
||||||
EmbeddedHashtag, |
|
||||||
EmbeddedLNInvoice, |
|
||||||
EmbeddedMention, |
|
||||||
EmbeddedNormalUrl, |
|
||||||
EmbeddedWebsocketUrl |
|
||||||
} from '../Embedded' |
|
||||||
import Emoji from '../Emoji' |
|
||||||
import { ImageCarousel } from '../ImageCarousel' |
|
||||||
|
|
||||||
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => { |
|
||||||
const images = useMemo(() => getImageInfosFromEvent(event), [event]) |
|
||||||
|
|
||||||
const nodes = parseContent(event.content, [ |
|
||||||
EmbeddedNormalUrlParser, |
|
||||||
EmbeddedLNInvoiceParser, |
|
||||||
EmbeddedWebsocketUrlParser, |
|
||||||
EmbeddedHashtagParser, |
|
||||||
EmbeddedMentionParser, |
|
||||||
EmbeddedEmojiParser |
|
||||||
]) |
|
||||||
|
|
||||||
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}> |
|
||||||
<ImageCarousel images={images} /> |
|
||||||
<div className="px-4"> |
|
||||||
{nodes.map((node, index) => { |
|
||||||
if (node.type === 'text') { |
|
||||||
return node.data |
|
||||||
} |
|
||||||
if (node.type === 'url') { |
|
||||||
return <EmbeddedNormalUrl key={index} url={node.data} /> |
|
||||||
} |
|
||||||
if (node.type === 'invoice') { |
|
||||||
return <EmbeddedLNInvoice invoice={node.data} key={index} /> |
|
||||||
} |
|
||||||
if (node.type === 'websocket-url') { |
|
||||||
return <EmbeddedWebsocketUrl key={index} url={node.data} /> |
|
||||||
} |
|
||||||
if (node.type === 'hashtag') { |
|
||||||
return <EmbeddedHashtag key={index} hashtag={node.data} /> |
|
||||||
} |
|
||||||
if (node.type === 'mention') { |
|
||||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> |
|
||||||
} |
|
||||||
if (node.type === 'emoji') { |
|
||||||
const shortcode = node.data.split(':')[1] |
|
||||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode) |
|
||||||
if (!emoji) return node.data |
|
||||||
return <Emoji key={index} emoji={emoji} /> |
|
||||||
} |
|
||||||
})} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
}) |
|
||||||
PictureContent.displayName = 'PictureContent' |
|
||||||
export default PictureContent |
|
||||||
@ -1,57 +0,0 @@ |
|||||||
import { getUsingClient } from '@/lib/event' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { FormattedTimestamp } from '../FormattedTimestamp' |
|
||||||
import NoteStats from '../NoteStats' |
|
||||||
import UserAvatar from '../UserAvatar' |
|
||||||
import Username from '../Username' |
|
||||||
import PictureContent from '../PictureContent' |
|
||||||
import NoteOptions from '../NoteOptions' |
|
||||||
|
|
||||||
export default function PictureNote({ |
|
||||||
event, |
|
||||||
className, |
|
||||||
hideStats = false, |
|
||||||
fetchNoteStats = false |
|
||||||
}: { |
|
||||||
event: Event |
|
||||||
className?: string |
|
||||||
hideStats?: boolean |
|
||||||
fetchNoteStats?: boolean |
|
||||||
}) { |
|
||||||
const usingClient = useMemo(() => getUsingClient(event), [event]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={className}> |
|
||||||
<div className="px-4 flex justify-between items-start gap-2"> |
|
||||||
<div className="flex items-center space-x-2 flex-1"> |
|
||||||
<UserAvatar userId={event.pubkey} /> |
|
||||||
<div className="flex-1 w-0"> |
|
||||||
<div className="flex gap-2 items-center"> |
|
||||||
<Username |
|
||||||
userId={event.pubkey} |
|
||||||
className="font-semibold flex truncate" |
|
||||||
skeletonClassName="h-4" |
|
||||||
/> |
|
||||||
{usingClient && ( |
|
||||||
<div className="text-xs text-muted-foreground truncate">using {usingClient}</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
<div className="text-xs text-muted-foreground line-clamp-1"> |
|
||||||
<FormattedTimestamp timestamp={event.created_at} /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" /> |
|
||||||
</div> |
|
||||||
<PictureContent className="mt-2" event={event} /> |
|
||||||
{!hideStats && ( |
|
||||||
<NoteStats |
|
||||||
className="px-4 mt-3 sm:mt-4" |
|
||||||
event={event} |
|
||||||
fetchIfNotExisting={fetchNoteStats} |
|
||||||
/> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,64 +0,0 @@ |
|||||||
import { EmbeddedHashtagParser, EmbeddedMentionParser, parseContent } from '@/lib/content-parser' |
|
||||||
import { getImageInfosFromEvent } from '@/lib/event' |
|
||||||
import { toNote } from '@/lib/link' |
|
||||||
import { tagNameEquals } from '@/lib/tag' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useSecondaryPage } from '@/PageManager' |
|
||||||
import { Images } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { EmbeddedHashtag, EmbeddedMention } from '../Embedded' |
|
||||||
import Image from '../Image' |
|
||||||
import LikeButton from '../NoteStats/LikeButton' |
|
||||||
import UserAvatar from '../UserAvatar' |
|
||||||
import Username from '../Username' |
|
||||||
|
|
||||||
export default function PictureNoteCard({ |
|
||||||
event, |
|
||||||
className |
|
||||||
}: { |
|
||||||
event: Event |
|
||||||
className?: string |
|
||||||
}) { |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const images = useMemo(() => getImageInfosFromEvent(event), [event]) |
|
||||||
const title = useMemo(() => { |
|
||||||
const nodes = parseContent(event.tags.find(tagNameEquals('title'))?.[1] ?? event.content, [ |
|
||||||
EmbeddedMentionParser, |
|
||||||
EmbeddedHashtagParser |
|
||||||
]) |
|
||||||
return nodes.map((node, index) => { |
|
||||||
if (node.type === 'text') { |
|
||||||
return node.data |
|
||||||
} |
|
||||||
if (node.type === 'mention') { |
|
||||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> |
|
||||||
} |
|
||||||
if (node.type === 'hashtag') { |
|
||||||
return <EmbeddedHashtag key={index} hashtag={node.data} /> |
|
||||||
} |
|
||||||
}) |
|
||||||
}, [event]) |
|
||||||
if (!images.length) return null |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={cn('cursor-pointer relative', className)} onClick={() => push(toNote(event))}> |
|
||||||
<Image className="w-full aspect-[6/8] rounded-lg" image={images[0]} /> |
|
||||||
{images.length > 1 && ( |
|
||||||
<div className="absolute top-2 right-2 bg-background/50 rounded-full p-2"> |
|
||||||
<Images size={16} /> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
<div className="p-2 space-y-1"> |
|
||||||
<div className="line-clamp-2 font-semibold">{title}</div> |
|
||||||
<div className="flex items-center justify-between gap-2"> |
|
||||||
<div className="flex items-center gap-2 flex-1 w-0"> |
|
||||||
<UserAvatar userId={event.pubkey} size="xSmall" /> |
|
||||||
<Username userId={event.pubkey} className="text-sm text-muted-foreground truncate" /> |
|
||||||
</div> |
|
||||||
<LikeButton event={event} /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,40 +0,0 @@ |
|||||||
import { cn } from '@/lib/utils' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import PictureNoteCard from '../PictureNoteCard' |
|
||||||
|
|
||||||
export function PictureNoteCardMasonry({ |
|
||||||
events, |
|
||||||
columnCount, |
|
||||||
className |
|
||||||
}: { |
|
||||||
events: Event[] |
|
||||||
columnCount: 2 | 3 |
|
||||||
className?: string |
|
||||||
}) { |
|
||||||
const columns = useMemo(() => { |
|
||||||
const newColumns: React.ReactNode[][] = Array.from({ length: columnCount }, () => []) |
|
||||||
events.forEach((event, i) => { |
|
||||||
newColumns[i % columnCount].push( |
|
||||||
<PictureNoteCard key={event.id} className="w-full" event={event} /> |
|
||||||
) |
|
||||||
}) |
|
||||||
return newColumns |
|
||||||
}, [events, columnCount]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={cn( |
|
||||||
'grid', |
|
||||||
columnCount === 2 ? 'grid-cols-2 gap-2' : 'grid-cols-3 gap-4', |
|
||||||
className |
|
||||||
)} |
|
||||||
> |
|
||||||
{columns.map((column, i) => ( |
|
||||||
<div key={i} className={columnCount === 2 ? 'space-y-2' : 'space-y-4'}> |
|
||||||
{column} |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,233 +0,0 @@ |
|||||||
import * as React from 'react' |
|
||||||
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react' |
|
||||||
import { ArrowLeft, ArrowRight } from 'lucide-react' |
|
||||||
|
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { Button } from '@/components/ui/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', 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-8 w-8 rounded-full', |
|
||||||
orientation === 'horizontal' |
|
||||||
? '-left-12 top-1/2 -translate-y-1/2' |
|
||||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90', |
|
||||||
className |
|
||||||
)} |
|
||||||
disabled={!canScrollPrev} |
|
||||||
onClick={scrollPrev} |
|
||||||
{...props} |
|
||||||
> |
|
||||||
<ArrowLeft className="h-4 w-4" /> |
|
||||||
<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-8 w-8 rounded-full', |
|
||||||
orientation === 'horizontal' |
|
||||||
? '-right-12 top-1/2 -translate-y-1/2' |
|
||||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', |
|
||||||
className |
|
||||||
)} |
|
||||||
disabled={!canScrollNext} |
|
||||||
onClick={scrollNext} |
|
||||||
{...props} |
|
||||||
> |
|
||||||
<ArrowRight className="h-4 w-4" /> |
|
||||||
<span className="sr-only">Next slide</span> |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
) |
|
||||||
CarouselNext.displayName = 'CarouselNext' |
|
||||||
|
|
||||||
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } |
|
||||||
Loading…
Reference in new issue