femto-webapp/src/app/feed/components/PostItem.tsx
2025-08-10 18:43:32 +02:00

157 lines
4 KiB
TypeScript

import { PostMedia, PostReaction } from '../posts/posts.ts'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { PostInfo } from '../posts/usePostViewModel.ts'
import { useUserStore } from '../../user/user.ts'
interface PostItemProps {
post: PostInfo
reactions: PostReaction[]
addReaction: (emoji: string) => void
clearReaction: (emoji: string) => void
hideViewButton?: boolean
}
export default function PostItem({
post,
reactions,
addReaction,
clearReaction,
hideViewButton = false,
}: PostItemProps) {
const formattedDate = post.createdAt.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
const [visible, setVisible] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setVisible(true))
return () => {
clearTimeout(timeout)
}
}, [])
const opacity = visible ? 'opacity-100' : 'opacity-0'
return (
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
<div className="text-sm text-gray-500 mb-3">
<span className="text-gray-400 mr-2">@{post.authorName}</span> {formattedDate}
{!hideViewButton && (
<>
{' • '}
<Link to={`/p/${post.postId}`} className="ml-2 text-primary-400 hover:underline">
View
</Link>
</>
)}
</div>
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
{post.media.length > 0 && (
<div className="grid gap-4 grid-cols-1">
{post.media.map((media) => (
<PostMediaItem key={media.url.toString()} media={media} />
))}
</div>
)}
<PostReactions
post={post}
reactions={reactions}
addReaction={addReaction}
clearReaction={clearReaction}
/>
</article>
)
}
interface PostReactionsProps {
post: PostInfo
reactions: PostReaction[]
addReaction: (emoji: string) => void
clearReaction: (emoji: string) => void
}
function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) {
const username = useUserStore((state) => state.user?.username)
return (
<div className="flex flex-wrap gap-2 mt-3 justify-end">
{post.possibleReactions.map((emoji) => {
const count = reactions.filter((r) => r.emoji === emoji).length
const didReact = reactions.some((r) => r.emoji == emoji && r.authorName == username)
const onClick = () => {
if (didReact) {
clearReaction(emoji)
} else {
addReaction(emoji)
}
}
return (
<PostReactionButton
key={emoji}
emoji={emoji}
didReact={didReact}
count={count}
onClick={onClick}
/>
)
})}
</div>
)
}
interface PostReactionButtonProps {
emoji: string
didReact: boolean
count: number
onClick: () => void
}
function PostReactionButton({ emoji, didReact, onClick, count }: PostReactionButtonProps) {
const formattedCount = count < 100 ? count.toString() : `99+`
return (
<button
key={emoji}
onClick={onClick}
className={`flex items-center px-2 py-1 rounded-full border cursor-pointer ${
didReact ? 'bg-gray-100 border-gray-400' : 'bg-white border-gray-200'
} hover:bg-gray-100 transition-colors`}
>
<span className="mr-1">{emoji}</span>
<span className="text-xs text-gray-600">{formattedCount}</span>
</button>
)
}
interface PostMediaProps {
media: PostMedia
}
function PostMediaItem({ media }: PostMediaProps) {
const url = new URL(media.url.toString())
if (location.protocol === 'https:' && url.protocol !== 'https:') {
url.protocol = 'https:'
}
const width = media.width ?? undefined
const height = media.height ?? undefined
return (
<img
width={width}
height={height}
src={url.toString()}
className="w-full h-auto"
loading="lazy"
/>
)
}