feat: support kind 20
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { isNsfwEvent } from '@/lib/event'
|
||||
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
|
||||
import { extractImetaUrlFromTag } from '@/lib/tag'
|
||||
import { isImage, isVideo } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { memo } from 'react'
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import VideoPlayer from '../VideoPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import { URL_REGEX } from '@/constants'
|
||||
|
||||
const Content = memo(
|
||||
({
|
||||
@@ -25,7 +28,7 @@ const Content = memo(
|
||||
className?: string
|
||||
size?: 'normal' | 'small'
|
||||
}) => {
|
||||
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event.content)
|
||||
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event)
|
||||
const isNsfw = isNsfwEvent(event)
|
||||
const nodes = embedded(content, [
|
||||
embeddedNormalUrlRenderer,
|
||||
@@ -39,7 +42,7 @@ const Content = memo(
|
||||
if (images.length) {
|
||||
nodes.push(
|
||||
<ImageGallery
|
||||
className={`w-fit ${size === 'small' ? 'mt-1' : 'mt-2'}`}
|
||||
className={`${size === 'small' ? 'mt-1' : 'mt-2'}`}
|
||||
key={`image-gallery-${event.id}`}
|
||||
images={images}
|
||||
isNsfw={isNsfw}
|
||||
@@ -95,9 +98,9 @@ const Content = memo(
|
||||
Content.displayName = 'Content'
|
||||
export default Content
|
||||
|
||||
function preprocess(content: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s"']+)/g
|
||||
const urls = content.match(urlRegex) || []
|
||||
function preprocess(event: Event) {
|
||||
const content = event.content
|
||||
const urls = content.match(URL_REGEX) || []
|
||||
let lastNonMediaUrl: string | undefined
|
||||
|
||||
let c = content
|
||||
@@ -116,6 +119,15 @@ function preprocess(content: string) {
|
||||
}
|
||||
})
|
||||
|
||||
if (isPictureEvent(event)) {
|
||||
event.tags.forEach((tag) => {
|
||||
const imageUrl = extractImetaUrlFromTag(tag)
|
||||
if (imageUrl) {
|
||||
images.push(imageUrl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const embeddedNotes: string[] = []
|
||||
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
|
||||
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
|
||||
@@ -123,23 +135,7 @@ function preprocess(content: string) {
|
||||
embeddedNotes.push(note)
|
||||
})
|
||||
|
||||
c = c.replace(/\n{3,}/g, '\n\n').trim()
|
||||
|
||||
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
|
||||
}
|
||||
|
||||
function isImage(url: string) {
|
||||
try {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.svg']
|
||||
return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isVideo(url: string) {
|
||||
try {
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov']
|
||||
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
35
src/components/Image/index.tsx
Normal file
35
src/components/Image/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { HTMLAttributes, useState } from 'react'
|
||||
|
||||
export default function Image({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
classNames = {},
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
src: string
|
||||
alt?: string
|
||||
classNames?: {
|
||||
wrapper?: string
|
||||
}
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
return (
|
||||
<div className={cn('relative', classNames.wrapper ?? '')} {...props}>
|
||||
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'object-cover transition-opacity duration-700',
|
||||
isLoading ? 'opacity-0' : 'opacity-100',
|
||||
className
|
||||
)}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/components/ImageCarousel/index.tsx
Normal file
43
src/components/ImageCarousel/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'
|
||||
import { useState } from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox'
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||
import Image from '../Image'
|
||||
import NsfwOverlay from '../NsfwOverlay'
|
||||
|
||||
export function ImageCarousel({ images, isNsfw = false }: { images: string[]; isNsfw?: boolean }) {
|
||||
const [index, setIndex] = useState(-1)
|
||||
|
||||
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
|
||||
event.preventDefault()
|
||||
setIndex(current)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Carousel className="w-full">
|
||||
<CarouselContent>
|
||||
{images.map((url, index) => (
|
||||
<CarouselItem key={index}>
|
||||
<Image src={url} onClick={(e) => handlePhotoClick(e, index)} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
<Lightbox
|
||||
index={index}
|
||||
slides={images.map((src) => ({ src }))}
|
||||
plugins={[Zoom]}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
controller={{
|
||||
closeOnBackdropClick: true,
|
||||
closeOnPullUp: true,
|
||||
closeOnPullDown: true
|
||||
}}
|
||||
styles={{ toolbar: { paddingTop: '2.25rem' } }}
|
||||
/>
|
||||
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Image } from '@nextui-org/image'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox'
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||
import Image from '../Image'
|
||||
import NsfwOverlay from '../NsfwOverlay'
|
||||
|
||||
export default function ImageGallery({
|
||||
@@ -17,45 +17,84 @@ export default function ImageGallery({
|
||||
isNsfw?: boolean
|
||||
size?: 'normal' | 'small'
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [index, setIndex] = useState(-1)
|
||||
|
||||
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
setIndex(current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex space-x-2">
|
||||
{images.map((src, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
className={cn(
|
||||
'rounded-lg cursor-pointer z-0 object-cover',
|
||||
size === 'small' ? 'h-[15vh]' : 'h-[30vh]'
|
||||
)}
|
||||
src={src}
|
||||
onClick={(e) => handlePhotoClick(e, index)}
|
||||
removeWrapper
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<Lightbox
|
||||
index={index}
|
||||
slides={images.map((src) => ({ src }))}
|
||||
plugins={[Zoom]}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
controller={{
|
||||
closeOnBackdropClick: true,
|
||||
closeOnPullUp: true,
|
||||
closeOnPullDown: true
|
||||
}}
|
||||
styles={{ toolbar: { paddingTop: '2.25rem' } }}
|
||||
let imageContent: ReactNode | null = null
|
||||
if (images.length === 1) {
|
||||
imageContent = (
|
||||
<Image
|
||||
key={index}
|
||||
className={cn('rounded-lg cursor-pointer', size === 'small' ? 'h-[15vh]' : 'h-[30vh]')}
|
||||
src={images[0]}
|
||||
onClick={(e) => handlePhotoClick(e, 0)}
|
||||
/>
|
||||
)
|
||||
} else if (size === 'small') {
|
||||
imageContent = (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{images.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="rounded-lg cursor-pointer aspect-square"
|
||||
src={src}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (isSmallScreen && (images.length === 2 || images.length === 4)) {
|
||||
imageContent = (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{images.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="rounded-lg cursor-pointer aspect-square"
|
||||
src={src}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
imageContent = (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{images.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="rounded-lg cursor-pointer aspect-square"
|
||||
src={src}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-fit max-w-full', className)}>
|
||||
{imageContent}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Lightbox
|
||||
index={index}
|
||||
slides={images.map((src) => ({ src }))}
|
||||
plugins={[Zoom]}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
controller={{
|
||||
closeOnBackdropClick: true,
|
||||
closeOnPullUp: true,
|
||||
closeOnPullDown: true
|
||||
}}
|
||||
styles={{ toolbar: { paddingTop: '2.25rem' } }}
|
||||
/>
|
||||
</div>
|
||||
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
||||
</div>
|
||||
)
|
||||
|
||||
190
src/components/Nip22ReplyNoteList/index.tsx
Normal file
190
src/components/Nip22ReplyNoteList/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { COMMENT_EVENT_KIND } from '@/constants'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event as NEvent } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReplyNote from '../ReplyNote'
|
||||
import { isCommentEvent } from '@/lib/event'
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export default function Nip22ReplyNoteList({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: NEvent
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
|
||||
const [replies, setReplies] = useState<NEvent[]>([])
|
||||
const [replyMap, setReplyMap] = useState<
|
||||
Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
|
||||
>({})
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||
const { updateNoteReplyCount } = useNoteStats()
|
||||
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleEventPublished = (data: Event) => {
|
||||
const customEvent = data as CustomEvent<NEvent>
|
||||
const evt = customEvent.detail
|
||||
if (
|
||||
isCommentEvent(evt) &&
|
||||
evt.tags.some(([tagName, tagValue]) => tagName === 'E' && tagValue === event.id)
|
||||
) {
|
||||
onNewReply(evt)
|
||||
}
|
||||
}
|
||||
|
||||
client.addEventListener('eventPublished', handleEventPublished)
|
||||
return () => {
|
||||
client.removeEventListener('eventPublished', handleEventPublished)
|
||||
}
|
||||
}, [event])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
|
||||
const init = async () => {
|
||||
setLoading(true)
|
||||
setReplies([])
|
||||
|
||||
try {
|
||||
const relayList = await client.fetchRelayList(event.pubkey)
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
relayList.read.slice(0, 5),
|
||||
{
|
||||
'#E': [event.id],
|
||||
kinds: [COMMENT_EVENT_KIND],
|
||||
limit: LIMIT
|
||||
},
|
||||
{
|
||||
onEvents: (evts, eosed) => {
|
||||
setReplies(evts.reverse())
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
||||
}
|
||||
},
|
||||
onNew: (evt) => {
|
||||
onNewReply(evt)
|
||||
}
|
||||
}
|
||||
)
|
||||
setTimelineKey(timelineKey)
|
||||
return closer
|
||||
} catch {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const promise = init()
|
||||
return () => {
|
||||
promise.then((closer) => closer?.())
|
||||
}
|
||||
}, [event])
|
||||
|
||||
useEffect(() => {
|
||||
updateNoteReplyCount(event.id, replies.length)
|
||||
|
||||
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
|
||||
{}
|
||||
for (const reply of replies) {
|
||||
const parentEventId = reply.tags.find(tagNameEquals('e'))?.[1]
|
||||
if (parentEventId && parentEventId !== event.id) {
|
||||
const parentReplyInfo = replyMap[parentEventId]
|
||||
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
|
||||
replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event }
|
||||
continue
|
||||
}
|
||||
|
||||
replyMap[reply.id] = { event: reply, level: 1 }
|
||||
continue
|
||||
}
|
||||
setReplyMap(replyMap)
|
||||
}, [replies, event.id, updateNoteReplyCount])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || !until || !timelineKey) return
|
||||
|
||||
setLoading(true)
|
||||
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||
const olderReplies = events.reverse()
|
||||
if (olderReplies.length > 0) {
|
||||
setReplies((pre) => [...olderReplies, ...pre])
|
||||
}
|
||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const onNewReply = (evt: NEvent) => {
|
||||
setReplies((pre) => {
|
||||
if (pre.some((reply) => reply.id === evt.id)) return pre
|
||||
return [...pre, evt]
|
||||
})
|
||||
if (evt.pubkey === pubkey) {
|
||||
setTimeout(() => {
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
highlightReply(evt.id, false)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightReply = (eventId: string, scrollTo = true) => {
|
||||
if (scrollTo) {
|
||||
const ref = replyRefs.current[eventId]
|
||||
if (ref) {
|
||||
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
setHighlightReplyId(eventId)
|
||||
setTimeout(() => {
|
||||
setHighlightReplyId((pre) => (pre === eventId ? undefined : pre))
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loading ? t('loading...') : until ? t('load more older replies') : null}
|
||||
</div>
|
||||
{replies.length > 0 && (loading || until) && <Separator className="my-2" />}
|
||||
<div className={cn('mb-4', className)}>
|
||||
{replies.map((reply) => {
|
||||
const info = replyMap[reply.id]
|
||||
return (
|
||||
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
||||
<ReplyNote
|
||||
event={reply}
|
||||
parentEvent={info?.parent}
|
||||
onClickParent={highlightReply}
|
||||
highlight={highlightReplyId === reply.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{replies.length === 0 && !loading && !until && (
|
||||
<div className="text-sm text-center text-muted-foreground">{t('no replies')}</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export default function Note({
|
||||
)}
|
||||
<Content className="mt-2" event={event} />
|
||||
{!hideStats && (
|
||||
<NoteStats className="mt-3 sm:mt-4" event={event} fetchIfNotExisting={fetchNoteStats} />
|
||||
<NoteStats className="mt-3" event={event} fetchIfNotExisting={fetchNoteStats} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { isReplyNoteEvent } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, Filter, kinds } from 'nostr-tools'
|
||||
@@ -10,10 +12,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
import NoteCard from '../NoteCard'
|
||||
import PictureNoteCard from '../PictureNoteCard'
|
||||
import SimpleMasonryGrid from '../SimpleMasonryGrid'
|
||||
|
||||
const NORMAL_RELAY_LIMIT = 100
|
||||
const ALGO_RELAY_LIMIT = 500
|
||||
|
||||
type TListMode = 'posts' | 'postsAndReplies' | 'pictures'
|
||||
|
||||
export default function NoteList({
|
||||
relayUrls,
|
||||
filter = {},
|
||||
@@ -24,6 +30,7 @@ export default function NoteList({
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { signEvent, checkLogin } = useNostr()
|
||||
const { isFetching: isFetchingRelayInfo, areAlgoRelays } = useFetchRelayInfos([...relayUrls])
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
@@ -32,15 +39,23 @@ export default function NoteList({
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [displayReplies, setDisplayReplies] = useState(false)
|
||||
const [listMode, setListMode] = useState<TListMode>('posts')
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const isPictures = useMemo(() => listMode === 'pictures', [listMode])
|
||||
const noteFilter = useMemo(() => {
|
||||
if (isPictures) {
|
||||
return {
|
||||
kinds: [PICTURE_EVENT_KIND],
|
||||
limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT,
|
||||
...filter
|
||||
}
|
||||
}
|
||||
return {
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost],
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost, PICTURE_EVENT_KIND],
|
||||
limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT,
|
||||
...filter
|
||||
}
|
||||
}, [JSON.stringify(filter), areAlgoRelays])
|
||||
}, [JSON.stringify(filter), areAlgoRelays, isPictures])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingRelayInfo || relayUrls.length === 0) return
|
||||
@@ -151,55 +166,65 @@ export default function NoteList({
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2 sm:space-y-2', className)}>
|
||||
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
|
||||
<div className="space-y-2 sm:space-y-2">
|
||||
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PullToRefresh
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div>
|
||||
{events
|
||||
.filter((event) => displayReplies || !isReplyNoteEvent(event))
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
<ListModeSwitch listMode={listMode} setListMode={setListMode} />
|
||||
<PullToRefresh
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div className="space-y-2 sm:space-y-2">
|
||||
{newEvents.filter((event) => listMode !== 'posts' || !isReplyNoteEvent(event)).length >
|
||||
0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isPictures ? (
|
||||
<SimpleMasonryGrid
|
||||
className="px-2 sm:px-4"
|
||||
columnCount={isSmallScreen ? 2 : 3}
|
||||
items={events.map((event) => (
|
||||
<PictureNoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{events
|
||||
.filter((event) => listMode === 'postsAndReplies' || !isReplyNoteEvent(event))
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{hasMore || refreshing ? (
|
||||
<div ref={bottomRef}>{t('loading...')}</div>
|
||||
) : events.length ? (
|
||||
t('no more notes')
|
||||
) : (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={() => setRefreshCount((pre) => pre + 1)}>
|
||||
{t('reload notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{hasMore || refreshing ? (
|
||||
<div ref={bottomRef}>{t('loading...')}</div>
|
||||
) : events.length ? (
|
||||
t('no more notes')
|
||||
) : (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={() => setRefreshCount((pre) => pre + 1)}>
|
||||
{t('reload notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisplayRepliesSwitch({
|
||||
displayReplies,
|
||||
setDisplayReplies
|
||||
function ListModeSwitch({
|
||||
listMode,
|
||||
setListMode
|
||||
}: {
|
||||
displayReplies: boolean
|
||||
setDisplayReplies: (value: boolean) => void
|
||||
listMode: TListMode
|
||||
setListMode: (listMode: TListMode) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -207,20 +232,26 @@ function DisplayRepliesSwitch({
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
|
||||
onClick={() => setDisplayReplies(false)}
|
||||
className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'posts' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setListMode('posts')}
|
||||
>
|
||||
{t('Notes')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setDisplayReplies(true)}
|
||||
className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'postsAndReplies' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setListMode('postsAndReplies')}
|
||||
>
|
||||
{t('Notes & Replies')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'pictures' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setListMode('pictures')}
|
||||
>
|
||||
{t('Pictures')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 px-4 sm:px-6 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
|
||||
className={`w-1/3 px-4 sm:px-6 transition-transform duration-500 ${listMode === 'postsAndReplies' ? 'translate-x-full' : listMode === 'pictures' ? 'translate-x-[200%]' : ''} `}
|
||||
>
|
||||
<div className="w-full h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
@@ -5,7 +6,7 @@ import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Heart, MessageCircle, Repeat } from 'lucide-react'
|
||||
import { Heart, MessageCircle, Repeat, ThumbsUp } from 'lucide-react'
|
||||
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -41,7 +42,7 @@ export default function NotificationList() {
|
||||
: relayList.read.concat(client.getDefaultRelayUrls()).slice(0, 4),
|
||||
{
|
||||
'#p': [pubkey],
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, COMMENT_EVENT_KIND],
|
||||
limit: LIMIT
|
||||
},
|
||||
{
|
||||
@@ -147,6 +148,9 @@ function NotificationItem({ notification }: { notification: Event }) {
|
||||
if (notification.kind === kinds.Repost) {
|
||||
return <RepostNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === COMMENT_EVENT_KIND) {
|
||||
return <CommentNotification notification={notification} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -162,7 +166,9 @@ function ReactionNotification({ notification }: { notification: Event }) {
|
||||
: undefined
|
||||
}, [notification])
|
||||
const { event } = useFetchEvent(bech32Id)
|
||||
if (!event || !bech32Id || event.kind !== kinds.ShortTextNote) return null
|
||||
if (!event || !bech32Id || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -172,6 +178,7 @@ function ReactionNotification({ notification }: { notification: Event }) {
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Heart size={24} className="text-red-400" />
|
||||
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
|
||||
<ContentPreview event={event} />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
@@ -228,8 +235,37 @@ function RepostNotification({ notification }: { notification: Event }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CommentNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1]
|
||||
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1]
|
||||
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1]
|
||||
if (
|
||||
!rootEventId ||
|
||||
!rootPubkey ||
|
||||
!rootKind ||
|
||||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview event={notification} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentPreview({ event }: { event?: Event }) {
|
||||
if (!event || event.kind !== kinds.ShortTextNote) return null
|
||||
if (!event) return null
|
||||
|
||||
return <div className="truncate flex-1 w-0">{event.content}</div>
|
||||
}
|
||||
|
||||
48
src/components/PictureContent/index.tsx
Normal file
48
src/components/PictureContent/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { extractImetaUrlFromTag } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { memo, ReactNode } from 'react'
|
||||
import {
|
||||
embedded,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNostrProfileRenderer,
|
||||
embeddedWebsocketUrlRenderer
|
||||
} from '../Embedded'
|
||||
import { ImageCarousel } from '../ImageCarousel'
|
||||
import { isNsfwEvent } from '@/lib/event'
|
||||
|
||||
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
|
||||
const images: string[] = []
|
||||
event.tags.forEach((tag) => {
|
||||
const imageUrl = extractImetaUrlFromTag(tag)
|
||||
if (imageUrl) {
|
||||
images.push(imageUrl)
|
||||
}
|
||||
})
|
||||
const isNsfw = isNsfwEvent(event)
|
||||
|
||||
const nodes: ReactNode[] = [
|
||||
<ImageCarousel key={`image-gallery-${event.id}`} images={images} isNsfw={isNsfw} />
|
||||
]
|
||||
nodes.push(
|
||||
<div className="px-4">
|
||||
{embedded(event.content, [
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedWebsocketUrlRenderer,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNostrProfileRenderer
|
||||
])}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}>
|
||||
{nodes}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
PictureContent.displayName = 'PictureContent'
|
||||
export default PictureContent
|
||||
53
src/components/PictureNote/index.tsx
Normal file
53
src/components/PictureNote/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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'
|
||||
|
||||
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 items-center space-x-2">
|
||||
<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"
|
||||
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>
|
||||
<PictureContent className="mt-2" event={event} />
|
||||
{!hideStats && (
|
||||
<NoteStats
|
||||
className="px-4 mt-3 sm:mt-4"
|
||||
event={event}
|
||||
fetchIfNotExisting={fetchNoteStats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/PictureNoteCard/index.tsx
Normal file
31
src/components/PictureNoteCard/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { extractFirstPictureFromPictureEvent } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Event } from 'nostr-tools'
|
||||
import Image from '../Image'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function PictureNoteCard({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const firstImage = extractFirstPictureFromPictureEvent(event)
|
||||
if (!firstImage) return null
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1 cursor-pointer', className)} onClick={() => push(toNote(event))}>
|
||||
<Image className="rounded-lg w-full aspect-[6/8]" src={firstImage} />
|
||||
<div className="line-clamp-2 px-2">{event.content}</div>
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<UserAvatar userId={event.pubkey} size="xSmall" />
|
||||
<Username userId={event.pubkey} className="text-sm text-muted-foreground truncate" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,16 +4,21 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StorageKey } from '@/constants'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||
import {
|
||||
createCommentDraftEvent,
|
||||
createPictureNoteDraftEvent,
|
||||
createShortTextNoteDraftEvent
|
||||
} from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Mentions from './Mentions'
|
||||
import Preview from './Preview'
|
||||
import Uploader from './Uploader'
|
||||
import { extractImagesFromContent } from '@/lib/event'
|
||||
|
||||
export default function PostContent({
|
||||
defaultContent = '',
|
||||
@@ -31,12 +36,19 @@ export default function PostContent({
|
||||
const [posting, setPosting] = useState(false)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [isPictureNote, setIsPictureNote] = useState(false)
|
||||
const [hasImages, setHasImages] = useState(false)
|
||||
const canPost = !!content && !posting
|
||||
|
||||
useEffect(() => {
|
||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const { images } = extractImagesFromContent(content)
|
||||
setHasImages(!!images && images.length > 0)
|
||||
}, [content])
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
}
|
||||
@@ -56,10 +68,18 @@ export default function PostContent({
|
||||
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
||||
additionalRelayUrls.push(...relayList.read.slice(0, 5))
|
||||
}
|
||||
const draftEvent = await createShortTextNoteDraftEvent(content, {
|
||||
parentEvent,
|
||||
addClientTag
|
||||
})
|
||||
if (isPictureNote && !hasImages) {
|
||||
throw new Error(t('Picture note requires images'))
|
||||
}
|
||||
const draftEvent =
|
||||
isPictureNote && !parentEvent && hasImages
|
||||
? await createPictureNoteDraftEvent(content, { addClientTag })
|
||||
: parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
||||
? await createCommentDraftEvent(content, parentEvent, { addClientTag })
|
||||
: await createShortTextNoteDraftEvent(content, {
|
||||
parentEvent,
|
||||
addClientTag
|
||||
})
|
||||
await publish(draftEvent, additionalRelayUrls)
|
||||
setContent('')
|
||||
close()
|
||||
@@ -151,6 +171,21 @@ export default function PostContent({
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('Show others this was sent via Jumble')}
|
||||
</div>
|
||||
{!parentEvent && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="picture-note">{t('Picture note')}</Label>
|
||||
<Switch
|
||||
id="picture-note"
|
||||
checked={isPictureNote}
|
||||
onCheckedChange={setIsPictureNote}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('A special note for picture-first clients like Olas')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-center justify-around sm:hidden">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Image } from '@nextui-org/image'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from '../Image'
|
||||
|
||||
export default function ProfileBanner({
|
||||
pubkey,
|
||||
@@ -27,9 +26,8 @@ export default function ProfileBanner({
|
||||
<Image
|
||||
src={bannerUrl}
|
||||
alt={`${pubkey} banner`}
|
||||
className={cn('z-0', className)}
|
||||
className={className}
|
||||
onError={() => setBannerUrl(defaultBanner)}
|
||||
removeWrapper
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
36
src/components/SimpleMasonryGrid/index.tsx
Normal file
36
src/components/SimpleMasonryGrid/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMemo, ReactNode } from 'react'
|
||||
|
||||
export default function SimpleMasonryGrid({
|
||||
items,
|
||||
columnCount,
|
||||
className
|
||||
}: {
|
||||
items: ReactNode[]
|
||||
columnCount: 2 | 3
|
||||
className?: string
|
||||
}) {
|
||||
const columns = useMemo(() => {
|
||||
const newColumns: ReactNode[][] = Array.from({ length: columnCount }, () => [])
|
||||
items.forEach((item, i) => {
|
||||
newColumns[i % columnCount].push(item)
|
||||
})
|
||||
return newColumns
|
||||
}, [items])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const UserAvatarSizeCnMap = {
|
||||
big: 'w-16 h-16',
|
||||
normal: 'w-10 h-10',
|
||||
small: 'w-7 h-7',
|
||||
xSmall: 'w-5 h-5',
|
||||
tiny: 'w-4 h-4'
|
||||
}
|
||||
|
||||
@@ -24,7 +25,7 @@ export default function UserAvatar({
|
||||
}: {
|
||||
userId: string
|
||||
className?: string
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
const defaultAvatar = useMemo(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Image } from '@nextui-org/image'
|
||||
import { useMemo } from 'react'
|
||||
import Image from '../Image'
|
||||
|
||||
export default function WebPreview({
|
||||
url,
|
||||
@@ -30,7 +30,7 @@ export default function WebPreview({
|
||||
if (isSmallScreen && image) {
|
||||
return (
|
||||
<div className="rounded-lg border mt-2">
|
||||
<Image src={image} className="rounded-t-lg object-cover w-full h-44" removeWrapper />
|
||||
<Image src={image} className="rounded-t-lg w-full h-44" />
|
||||
<div className="bg-muted p-2 w-full rounded-b-lg">
|
||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||
<div className="font-semibold line-clamp-1">{title}</div>
|
||||
@@ -48,11 +48,7 @@ export default function WebPreview({
|
||||
}}
|
||||
>
|
||||
{image && (
|
||||
<Image
|
||||
src={image}
|
||||
className={`rounded-l-lg object-cover ${size === 'normal' ? 'h-44' : 'h-24'}`}
|
||||
removeWrapper
|
||||
/>
|
||||
<Image src={image} className={`rounded-l-lg ${size === 'normal' ? 'h-44' : 'h-24'}`} />
|
||||
)}
|
||||
<div className="flex-1 w-0 p-2">
|
||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||
|
||||
260
src/components/ui/carousel.tsx
Normal file
260
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user