add and remove reactions

This commit is contained in:
john 2025-05-28 20:49:08 +02:00
parent dab626f227
commit 48e7094c5e
6 changed files with 185 additions and 39 deletions

View file

@ -103,6 +103,72 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/posts/{postId}/reactions': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post: {
parameters: {
query?: never
header?: never
path: {
postId: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['AddPostReactionRequest']
'text/json': components['schemas']['AddPostReactionRequest']
'application/*+json': components['schemas']['AddPostReactionRequest']
}
}
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete: {
parameters: {
query?: never
header?: never
path: {
postId: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['DeletePostReactionRequest']
'text/json': components['schemas']['DeletePostReactionRequest']
'application/*+json': components['schemas']['DeletePostReactionRequest']
}
}
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
options?: never
head?: never
patch?: never
trace?: never
}
'/media': { '/media': {
parameters: { parameters: {
query?: never query?: never
@ -404,6 +470,9 @@ export interface paths {
export type webhooks = Record<string, never> export type webhooks = Record<string, never>
export interface components { export interface components {
schemas: { schemas: {
AddPostReactionRequest: {
emoji: string
}
CreatePostRequest: { CreatePostRequest: {
/** Format: uuid */ /** Format: uuid */
authorId: string authorId: string
@ -430,6 +499,9 @@ export interface components {
email: string email: string
name: string name: string
} }
DeletePostReactionRequest: {
emoji: string
}
ListSignupCodesResult: { ListSignupCodesResult: {
signupCodes: components['schemas']['SignupCodeDto'][] signupCodes: components['schemas']['SignupCodeDto'][]
} }

View file

@ -6,9 +6,11 @@ import PostItem from './PostItem.tsx'
interface FeedViewProps { interface FeedViewProps {
pages: Post[][] pages: Post[][]
onLoadMore: () => Promise<void> onLoadMore: () => Promise<void>
addReaction: (postId: string, emoji: string) => void
clearReaction: (postId: string, emoji: string) => void
} }
export default function FeedView({ pages, onLoadMore }: FeedViewProps) { export default function FeedView({ pages, onLoadMore, addReaction, clearReaction }: FeedViewProps) {
const sentinelRef = useRef<HTMLDivElement | null>(null) const sentinelRef = useRef<HTMLDivElement | null>(null)
const posts = pages.flat() const posts = pages.flat()
@ -19,7 +21,12 @@ export default function FeedView({ pages, onLoadMore }: FeedViewProps) {
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col gap-6 w-full">
{posts.map((post) => ( {posts.map((post) => (
<PostItem key={post.postId} post={post} /> <PostItem
key={post.postId}
post={post}
addReaction={(emoji) => addReaction(post.postId, emoji)}
clearReaction={(emoji) => clearReaction(post.postId, emoji)}
/>
))} ))}
</div> </div>
</div> </div>

View file

@ -4,9 +4,11 @@ import { useEffect, useState } from 'react'
interface PostItemProps { interface PostItemProps {
post: Post post: Post
addReaction: (emoji: string) => void
clearReaction: (emoji: string) => void
} }
export default function PostItem({ post }: PostItemProps) { export default function PostItem({ post, addReaction, clearReaction }: PostItemProps) {
const formattedDate = post.createdAt.toLocaleString('en-US', { const formattedDate = post.createdAt.toLocaleString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
@ -45,58 +47,71 @@ export default function PostItem({ post }: PostItemProps) {
</div> </div>
)} )}
<PostReactions post={post} /> <PostReactions post={post} addReaction={addReaction} clearReaction={clearReaction} />
</article> </article>
) )
} }
interface PostReactionsProps { interface PostReactionsProps {
post: Post post: Post
addReaction: (emoji: string) => void
clearReaction: (emoji: string) => void
} }
function PostReactions({ post }: PostReactionsProps) { function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) {
// Function to format reaction count const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r]))
const formatCount = (count: number): string => {
if (count < 1000) return count.toString()
if (count < 10000) return `${(count / 1000).toFixed(1)}K`
return `${Math.floor(count / 1000)}K`
}
// NOOP handlers for react/unreact functionality
const handleReactionClick = (emoji: string) => {
console.log(`Reaction clicked: ${emoji}`)
// This would normally call an API to add/remove a reaction
}
// Find existing reactions to display
const reactionMap = new Map(post.reactions.map(r => [r.emoji, r]))
return ( return (
<div className="flex flex-wrap gap-2 mt-3 justify-end"> <div className="flex flex-wrap gap-2 mt-3 justify-end">
{post.possibleReactions.map((emoji) => { {post.possibleReactions.map((emoji) => {
const reaction = reactionMap.get(emoji) const reaction = reactionMap.get(emoji)
const count = reaction?.count || 0 const count = reaction?.count ?? 0
const didReact = reaction?.didReact || false const didReact = reaction?.didReact ?? false
const onClick = () => {
if (didReact) {
clearReaction(emoji)
} else {
addReaction(emoji)
}
}
return ( return (
<button <PostReactionButton
key={emoji} emoji={emoji}
onClick={() => handleReactionClick(emoji)} didReact={didReact}
className={`flex items-center px-2 py-1 rounded-full border ${ count={count}
didReact ? 'bg-gray-100 border-gray-400' : 'bg-white border-gray-200' onClick={() => onClick()}
} hover:bg-gray-100 transition-colors`} />
>
<span className="mr-1">{emoji}</span>
<span className="text-xs text-gray-600">
{formatCount(count)}
</span>
</button>
) )
})} })}
</div> </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 ${
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 { interface PostMediaProps {
media: PostMedia media: PostMedia
} }

View file

@ -24,6 +24,14 @@ export default function AuthorPage({ postsService }: AuthorPageParams) {
const { pages, loadNextPage } = useFeedViewModel(fetchPosts) const { pages, loadNextPage } = useFeedViewModel(fetchPosts)
const addReaction = async (postId: string, emoji: string) => {
await postsService.addReaction(postId, emoji)
}
const clearReaction = async (postId: string, emoji: string) => {
await postsService.removeReaction(postId, emoji)
}
return ( return (
<SingleColumnLayout <SingleColumnLayout
navbar={ navbar={
@ -33,7 +41,12 @@ export default function AuthorPage({ postsService }: AuthorPageParams) {
</NavBar> </NavBar>
} }
> >
<FeedView pages={pages} onLoadMore={loadNextPage} /> <FeedView
pages={pages}
onLoadMore={loadNextPage}
addReaction={addReaction}
clearReaction={clearReaction}
/>
</SingleColumnLayout> </SingleColumnLayout>
) )
} }

View file

@ -63,6 +63,14 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
const isLoggedIn = user != null const isLoggedIn = user != null
const addReaction = async (postId: string, emoji: string) => {
await postsService.addReaction(postId, emoji)
}
const clearReaction = async (postId: string, emoji: string) => {
await postsService.removeReaction(postId, emoji)
}
return ( return (
<SingleColumnLayout <SingleColumnLayout
navbar={ navbar={
@ -73,7 +81,12 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
> >
<main className={`w-full max-w-3xl mx-auto`}> <main className={`w-full max-w-3xl mx-auto`}>
{isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />} {isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />}
<FeedView pages={pages} onLoadMore={loadNextPage} /> <FeedView
pages={pages}
onLoadMore={loadNextPage}
addReaction={addReaction}
clearReaction={clearReaction}
/>
</main> </main>
</SingleColumnLayout> </SingleColumnLayout>
) )

View file

@ -48,9 +48,7 @@ export class PostsService {
amount: number | null, amount: number | null,
): Promise<Post[]> { ): Promise<Post[]> {
const response = await this.client.GET('/posts', { const response = await this.client.GET('/posts', {
params: { query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username },
query: { From: cursor ?? undefined, Amount: amount ?? undefined, Author: username },
},
credentials: 'include', credentials: 'include',
}) })
@ -60,6 +58,34 @@ export class PostsService {
return response.data?.posts.map((post) => Post.fromDto(post)) return response.data?.posts.map((post) => Post.fromDto(post))
} }
async addReaction(postId: string, emoji: string): Promise<void> {
const response = await this.client.POST('/posts/{postId}/reactions', {
params: {
path: { postId }
},
body: { emoji },
credentials: 'include',
})
if (!response.data) {
throw new Error('Failed to add reaction')
}
}
async removeReaction(postId: string, emoji: string): Promise<void> {
const response = await this.client.DELETE('/posts/{postId}/reactions', {
params: {
path: { postId }
},
body: { emoji },
credentials: 'include',
})
if (!response.data) {
throw new Error('Failed to remove reaction')
}
}
} }
interface CreatePostMedia { interface CreatePostMedia {