style: resize images and videos for better visuals

This commit is contained in:
codytseng
2025-05-08 22:34:33 +08:00
parent ee6780dc69
commit aa24ad83e5
6 changed files with 106 additions and 169 deletions

View File

@@ -27,128 +27,99 @@ import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer' import VideoPlayer from '../VideoPlayer'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
const Content = memo( const Content = memo(({ event, className }: { event: Event; className?: string }) => {
({ const nodes = parseContent(event.content, [
event, EmbeddedImageParser,
className, EmbeddedVideoParser,
size = 'normal' EmbeddedNormalUrlParser,
}: { EmbeddedWebsocketUrlParser,
event: Event EmbeddedEventParser,
className?: string EmbeddedMentionParser,
size?: 'normal' | 'small' EmbeddedHashtagParser,
}) => { EmbeddedEmojiParser
const nodes = parseContent(event.content, [ ])
EmbeddedImageParser,
EmbeddedVideoParser,
EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const imageInfos = event.tags const imageInfos = event.tags
.map((tag) => extractImageInfoFromTag(tag)) .map((tag) => extractImageInfoFromTag(tag))
.filter(Boolean) as TImageInfo[] .filter(Boolean) as TImageInfo[]
const allImages = nodes const allImages = nodes
.map((node) => { .map((node) => {
if (node.type === 'image') { if (node.type === 'image') {
const imageInfo = imageInfos.find((image) => image.url === node.data) const imageInfo = imageInfos.find((image) => image.url === node.data)
return imageInfo ?? { url: node.data } return imageInfo ?? { url: node.data }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imageInfos.find((image) => image.url === url)
return imageInfo ?? { url }
})
}
return null
})
.filter(Boolean)
.flat() as TImageInfo[]
let imageIndex = 0
const emojiInfos = extractEmojiInfosFromTags(event.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
} }
if (node.type === 'images') { if (node.type === 'image' || node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data] const start = imageIndex
return urls.map((url) => { const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
const imageInfo = imageInfos.find((image) => image.url === url) imageIndex = end
return imageInfo ?? { url } return (
}) <ImageGallery
className="mt-2"
key={index}
images={allImages}
isNsfw={isNsfwEvent(event)}
start={start}
end={end}
/>
)
}
if (node.type === 'video') {
return (
<VideoPlayer className="mt-2" key={index} src={node.data} isNsfw={isNsfwEvent(event)} />
)
}
if (node.type === 'url') {
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
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 emoji={emoji} key={index} className="size-4" />
} }
return null return null
}) })}
.filter(Boolean) {lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
.flat() as TImageInfo[] </div>
let imageIndex = 0 )
})
const emojiInfos = extractEmojiInfosFromTags(event.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
return (
<ImageGallery
className={`${size === 'small' ? 'mt-1' : 'mt-2'}`}
key={index}
images={allImages}
isNsfw={isNsfwEvent(event)}
size={size}
start={start}
end={end}
/>
)
}
if (node.type === 'video') {
return (
<VideoPlayer
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={index}
src={node.data}
isNsfw={isNsfwEvent(event)}
size={size}
/>
)
}
if (node.type === 'url') {
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return (
<EmbeddedNote
key={index}
noteId={id}
className={size === 'small' ? 'mt-1' : 'mt-2'}
/>
)
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
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 emoji={emoji} key={index} className="size-4" />
}
return null
})}
{lastNormalUrl && (
<WebPreview
className={size === 'small' ? 'mt-1' : 'mt-2'}
url={lastNormalUrl}
size={size}
/>
)}
</div>
)
}
)
Content.displayName = 'Content' Content.displayName = 'Content'
export default Content export default Content

View File

@@ -1,6 +1,5 @@
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TImageInfo } from '@/types' import { TImageInfo } from '@/types'
import { ReactNode, useEffect, useMemo, useState } from 'react' import { ReactNode, useEffect, useMemo, useState } from 'react'
@@ -14,19 +13,16 @@ export default function ImageGallery({
className, className,
images, images,
isNsfw = false, isNsfw = false,
size = 'normal',
start = 0, start = 0,
end = images.length end = images.length
}: { }: {
className?: string className?: string
images: TImageInfo[] images: TImageInfo[]
isNsfw?: boolean isNsfw?: boolean
size?: 'normal' | 'small'
start?: number start?: number
end?: number end?: number
}) { }) {
const id = useMemo(() => `image-gallery-${randomString()}`, []) const id = useMemo(() => `image-gallery-${randomString()}`, [])
const { isSmallScreen } = useScreenSize()
const [index, setIndex] = useState(-1) const [index, setIndex] = useState(-1)
useEffect(() => { useEffect(() => {
if (index >= 0) { if (index >= 0) {
@@ -50,34 +46,21 @@ export default function ImageGallery({
imageContent = ( imageContent = (
<Image <Image
key={0} key={0}
className={cn('rounded-lg', size === 'small' ? 'max-h-[15vh]' : 'max-h-[30vh]')} className="rounded-lg max-h-[80vh] sm:max-h-[50vh] border"
classNames={{ classNames={{
errorPlaceholder: cn('aspect-square', size === 'small' ? 'h-[15vh]' : 'h-[30vh]') errorPlaceholder: 'aspect-square h-[30vh]'
}} }}
image={displayImages[0]} image={displayImages[0]}
onClick={(e) => handlePhotoClick(e, 0)} onClick={(e) => handlePhotoClick(e, 0)}
/> />
) )
} else if (size === 'small') { } else if (displayImages.length === 2 || displayImages.length === 4) {
imageContent = ( imageContent = (
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-2 gap-2 w-full">
{displayImages.map((image, i) => ( {displayImages.map((image, i) => (
<Image <Image
key={i} key={i}
className={cn('aspect-square w-full rounded-lg')} className="aspect-square w-full rounded-lg border"
image={image}
onClick={(e) => handlePhotoClick(e, i)}
/>
))}
</div>
)
} else if (isSmallScreen && (displayImages.length === 2 || displayImages.length === 4)) {
imageContent = (
<div className="grid grid-cols-2 gap-2">
{displayImages.map((image, i) => (
<Image
key={i}
className={cn('aspect-square w-full rounded-lg')}
image={image} image={image}
onClick={(e) => handlePhotoClick(e, i)} onClick={(e) => handlePhotoClick(e, i)}
/> />
@@ -90,7 +73,7 @@ export default function ImageGallery({
{displayImages.map((image, i) => ( {displayImages.map((image, i) => (
<Image <Image
key={i} key={i}
className={cn('aspect-square w-full rounded-lg')} className="aspect-square w-full rounded-lg border"
image={image} image={image}
onClick={(e) => handlePhotoClick(e, i)} onClick={(e) => handlePhotoClick(e, i)}
/> />

View File

@@ -1,7 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -35,7 +34,7 @@ export default function MainNoteCard({
const checkHeight = () => { const checkHeight = () => {
const fullHeight = contentEl.scrollHeight const fullHeight = contentEl.scrollHeight
if (fullHeight > 900) { if (fullHeight > 1000) {
setShouldCollapse(true) setShouldCollapse(true)
} }
} }
@@ -91,7 +90,7 @@ export default function MainNoteCard({
</div> </div>
)} )}
</div> </div>
{!embedded && <NoteStats className={cn('mt-3', embedded ? '' : 'px-4')} event={event} />} {!embedded && <NoteStats className="mt-3 px-4" event={event} />}
</div> </div>
{!embedded && <Separator />} {!embedded && <Separator />}
</div> </div>

View File

@@ -55,7 +55,7 @@ export default function ReplyNote({
</div> </div>
{parentEvent && ( {parentEvent && (
<ParentNotePreview <ParentNotePreview
className="mt-1" className="mt-2"
event={parentEvent} event={parentEvent}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -65,7 +65,7 @@ export default function ReplyNote({
)} )}
{show ? ( {show ? (
<> <>
<Content className="mt-1" event={event} size="small" /> <Content className="mt-2" event={event} />
<NoteStats className="mt-2" event={event} variant="reply" /> <NoteStats className="mt-2" event={event} variant="reply" />
</> </>
) : ( ) : (

View File

@@ -6,13 +6,11 @@ import NsfwOverlay from '../NsfwOverlay'
export default function VideoPlayer({ export default function VideoPlayer({
src, src,
className, className,
isNsfw = false, isNsfw = false
size = 'normal'
}: { }: {
src: string src: string
className?: string className?: string
isNsfw?: boolean isNsfw?: boolean
size?: 'normal' | 'small'
}) { }) {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -51,7 +49,7 @@ export default function VideoPlayer({
ref={videoRef} ref={videoRef}
controls controls
playsInline playsInline
className={cn('rounded-lg', size === 'small' ? 'max-h-[30vh]' : 'max-h-[50vh]', className)} className={cn('rounded-lg max-h-[80vh] sm:max-h-[50vh] border', className)}
src={src} src={src}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onPlay={(event) => { onPlay={(event) => {

View File

@@ -4,15 +4,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
export default function WebPreview({ export default function WebPreview({ url, className }: { url: string; className?: string }) {
url,
className,
size = 'normal'
}: {
url: string
className?: string
size?: 'normal' | 'small'
}) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { title, description, image } = useFetchWebMetadata(url) const { title, description, image } = useFetchWebMetadata(url)
const hostname = useMemo(() => { const hostname = useMemo(() => {
@@ -56,20 +48,14 @@ export default function WebPreview({
{image && ( {image && (
<Image <Image
image={{ url: image }} image={{ url: image }}
className={`rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground ${size === 'normal' ? 'h-44' : 'h-24'}`} className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
hideIfError hideIfError
/> />
)} )}
<div className="flex-1 w-0 p-2"> <div className="flex-1 w-0 p-2">
<div className="text-xs text-muted-foreground">{hostname}</div> <div className="text-xs text-muted-foreground">{hostname}</div>
<div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}> <div className="font-semibold line-clamp-2">{title}</div>
{title} <div className="text-xs text-muted-foreground line-clamp-5">{description}</div>
</div>
<div
className={`text-xs text-muted-foreground ${size === 'normal' ? 'line-clamp-5' : 'line-clamp-2'}`}
>
{description}
</div>
</div> </div>
</div> </div>
) )