14 changed files with 26 additions and 685 deletions
@ -1,129 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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