refactor post model
This commit is contained in:
parent
62f9de9546
commit
30025b4044
8 changed files with 373 additions and 151 deletions
|
@ -75,7 +75,30 @@ export interface paths {
|
||||||
path?: never
|
path?: never
|
||||||
cookie?: never
|
cookie?: never
|
||||||
}
|
}
|
||||||
get?: never
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path: {
|
||||||
|
postId: string
|
||||||
|
}
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'text/plain': components['schemas']['GetPostResponse']
|
||||||
|
'application/json': components['schemas']['GetPostResponse']
|
||||||
|
'text/json': components['schemas']['GetPostResponse']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
put?: never
|
put?: never
|
||||||
post?: never
|
post?: never
|
||||||
delete: {
|
delete: {
|
||||||
|
@ -188,7 +211,8 @@ export interface paths {
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
'multipart/form-data': {
|
'multipart/form-data': {
|
||||||
file?: components['schemas']['IFormFile']
|
/** Format: binary */
|
||||||
|
file?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -333,6 +357,78 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
|
'/auth/change-password': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
get?: never
|
||||||
|
put?: never
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['ChangePasswordRequestBody']
|
||||||
|
'text/json': components['schemas']['ChangePasswordRequestBody']
|
||||||
|
'application/*+json': components['schemas']['ChangePasswordRequestBody']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content?: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
|
'/auth/delete-current-session': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
get?: never
|
||||||
|
put?: never
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content?: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
'/auth/session': {
|
'/auth/session': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
@ -390,9 +486,9 @@ export interface paths {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
}
|
}
|
||||||
content: {
|
content: {
|
||||||
'text/plain': components['schemas']['RefreshUserResult']
|
'text/plain': components['schemas']['GetUserInfoResult']
|
||||||
'application/json': components['schemas']['RefreshUserResult']
|
'application/json': components['schemas']['GetUserInfoResult']
|
||||||
'text/json': components['schemas']['RefreshUserResult']
|
'text/json': components['schemas']['GetUserInfoResult']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -465,6 +561,82 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
|
'/auth/create-signup-code': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
get?: never
|
||||||
|
put?: never
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['CreateSignupCodeRequest']
|
||||||
|
'text/json': components['schemas']['CreateSignupCodeRequest']
|
||||||
|
'application/*+json': components['schemas']['CreateSignupCodeRequest']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content?: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
|
'/auth/list-signup-codes': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'text/plain': components['schemas']['ListSignupCodesResult']
|
||||||
|
'application/json': components['schemas']['ListSignupCodesResult']
|
||||||
|
'text/json': components['schemas']['ListSignupCodesResult']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put?: never
|
||||||
|
post?: never
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>
|
export type webhooks = Record<string, never>
|
||||||
export interface components {
|
export interface components {
|
||||||
|
@ -472,6 +644,11 @@ export interface components {
|
||||||
AddPostReactionRequest: {
|
AddPostReactionRequest: {
|
||||||
emoji: string
|
emoji: string
|
||||||
}
|
}
|
||||||
|
ChangePasswordRequestBody: {
|
||||||
|
/** Format: uuid */
|
||||||
|
userId: string
|
||||||
|
newPassword: string
|
||||||
|
}
|
||||||
CreatePostRequest: {
|
CreatePostRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
authorId: string
|
authorId: string
|
||||||
|
@ -501,20 +678,25 @@ export interface components {
|
||||||
DeletePostReactionRequest: {
|
DeletePostReactionRequest: {
|
||||||
emoji: string
|
emoji: string
|
||||||
}
|
}
|
||||||
/** Format: binary */
|
GetPostResponse: {
|
||||||
IFormFile: string
|
post: components['schemas']['PostDto']
|
||||||
|
}
|
||||||
|
GetUserInfoResult: {
|
||||||
|
/** Format: uuid */
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
isSuperUser: boolean
|
||||||
|
}
|
||||||
ListSignupCodesResult: {
|
ListSignupCodesResult: {
|
||||||
signupCodes: components['schemas']['SignupCodeDto'][]
|
signupCodes: components['schemas']['SignupCodeDto'][]
|
||||||
}
|
}
|
||||||
LoadPostsResponse: {
|
LoadPostsResponse: {
|
||||||
posts: components['schemas']['PostDto'][]
|
posts: components['schemas']['PostDto'][]
|
||||||
/** Format: uuid */
|
|
||||||
next: string | null
|
|
||||||
}
|
}
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
rememberMe: boolean | null
|
rememberMe: boolean
|
||||||
}
|
}
|
||||||
LoginResponse: {
|
LoginResponse: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
@ -548,21 +730,15 @@ export interface components {
|
||||||
}
|
}
|
||||||
PostReactionDto: {
|
PostReactionDto: {
|
||||||
emoji: string
|
emoji: string
|
||||||
/** Format: int32 */
|
authorName: string
|
||||||
count: number
|
/** Format: date-time */
|
||||||
didReact: boolean
|
reactedOn: string
|
||||||
}
|
|
||||||
RefreshUserResult: {
|
|
||||||
/** Format: uuid */
|
|
||||||
userId: string
|
|
||||||
username: string
|
|
||||||
isSuperUser: boolean
|
|
||||||
}
|
}
|
||||||
RegisterRequest: {
|
RegisterRequest: {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
signupCode: string
|
signupCode: string
|
||||||
rememberMe: boolean | null
|
rememberMe: boolean
|
||||||
}
|
}
|
||||||
RegisterResponse: {
|
RegisterResponse: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
import { Post, PostMedia } from '../posts/posts.ts'
|
import { PostMedia, PostReaction } from '../posts/posts.ts'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { PostInfo } from '../posts/usePostViewModel.ts'
|
||||||
|
import { useUserStore } from '../../user/user.ts'
|
||||||
|
|
||||||
interface PostItemProps {
|
interface PostItemProps {
|
||||||
post: Post
|
post: PostInfo
|
||||||
|
reactions: PostReaction[]
|
||||||
addReaction: (emoji: string) => void
|
addReaction: (emoji: string) => void
|
||||||
clearReaction: (emoji: string) => void
|
clearReaction: (emoji: string) => void
|
||||||
hideViewButton?: boolean
|
hideViewButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostItem({ post, addReaction, clearReaction, hideViewButton = false }: PostItemProps) {
|
export default function PostItem({
|
||||||
|
post,
|
||||||
|
reactions,
|
||||||
|
addReaction,
|
||||||
|
clearReaction,
|
||||||
|
hideViewButton = false,
|
||||||
|
}: PostItemProps) {
|
||||||
const formattedDate = post.createdAt.toLocaleString('en-US', {
|
const formattedDate = post.createdAt.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
@ -53,26 +62,30 @@ export default function PostItem({ post, addReaction, clearReaction, hideViewBut
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PostReactions post={post} addReaction={addReaction} clearReaction={clearReaction} />
|
<PostReactions
|
||||||
|
post={post}
|
||||||
|
reactions={reactions}
|
||||||
|
addReaction={addReaction}
|
||||||
|
clearReaction={clearReaction}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PostReactionsProps {
|
interface PostReactionsProps {
|
||||||
post: Post
|
post: PostInfo
|
||||||
|
reactions: PostReaction[]
|
||||||
addReaction: (emoji: string) => void
|
addReaction: (emoji: string) => void
|
||||||
clearReaction: (emoji: string) => void
|
clearReaction: (emoji: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) {
|
function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) {
|
||||||
const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r]))
|
const username = useUserStore((state) => state.user?.username)
|
||||||
|
|
||||||
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 count = reactions.filter((r) => r.emoji === emoji).length
|
||||||
const count = reaction?.count ?? 0
|
const didReact = reactions.some((r) => r.emoji == emoji && r.authorName == username)
|
||||||
const didReact = reaction?.didReact ?? false
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (didReact) {
|
if (didReact) {
|
||||||
clearReaction(emoji)
|
clearReaction(emoji)
|
||||||
|
|
|
@ -7,11 +7,11 @@ import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||||
import NavBar from '../../../components/NavBar.tsx'
|
import NavBar from '../../../components/NavBar.tsx'
|
||||||
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
|
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
|
||||||
import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts'
|
import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts'
|
||||||
import { Post } from '../posts/posts.ts'
|
|
||||||
import { produce, WritableDraft } from 'immer'
|
|
||||||
import PostItem from '../components/PostItem.tsx'
|
import PostItem from '../components/PostItem.tsx'
|
||||||
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
|
import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts'
|
||||||
import { delay } from '../../../utils/delay.ts'
|
import { delay } from '../../../utils/delay.ts'
|
||||||
|
import { usePostViewModel } from '../posts/usePostViewModel.ts'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
interface HomePageProps {
|
interface HomePageProps {
|
||||||
postsService: PostsService
|
postsService: PostsService
|
||||||
|
@ -25,7 +25,8 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
useSaveSignupCodeToLocalStorage()
|
useSaveSignupCodeToLocalStorage()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const [posts, setPosts] = useState<Post[]>([])
|
const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel()
|
||||||
|
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
@ -37,14 +38,14 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
loading.current = true
|
loading.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [{ posts, next }] = await Promise.all([
|
const [{ posts }] = await Promise.all([
|
||||||
postsService.loadPublicFeed(cursor.current, PageSize),
|
postsService.loadPublicFeed(cursor.current, PageSize),
|
||||||
delay(500),
|
delay(500),
|
||||||
])
|
])
|
||||||
|
|
||||||
setHasMore(posts.length >= PageSize)
|
setHasMore(posts.length >= PageSize)
|
||||||
cursor.current = next
|
cursor.current = posts.at(-1)?.postId ?? null
|
||||||
setPosts((prev) => [...prev, ...posts])
|
addPosts(posts)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -73,7 +74,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const post = await postsService.createNew(user.id, content, media, isPublic)
|
const post = await postsService.createNew(user.id, content, media, isPublic)
|
||||||
setPosts((pages) => [post, ...pages])
|
addPosts([post])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create post:', error)
|
console.error('Failed to create post:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -86,37 +87,13 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
const onAddReaction = async (postId: string, emoji: string) => {
|
const onAddReaction = async (postId: string, emoji: string) => {
|
||||||
await postsService.addReaction(postId, emoji)
|
await postsService.addReaction(postId, emoji)
|
||||||
|
|
||||||
setPosts((prev) =>
|
addReaction(postId, emoji, user!.username, Temporal.Now.instant())
|
||||||
produce(prev, (draft: WritableDraft<Post[]>) => {
|
|
||||||
const post = draft.find((p) => p.postId === postId)
|
|
||||||
if (!post) return
|
|
||||||
|
|
||||||
const theReaction = post.reactions.find((r) => r.emoji === emoji)
|
|
||||||
if (theReaction) {
|
|
||||||
theReaction.count++
|
|
||||||
theReaction.didReact = true
|
|
||||||
} else {
|
|
||||||
post.reactions.push({ emoji, count: 1, didReact: true })
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClearReaction = async (postId: string, emoji: string) => {
|
const onClearReaction = async (postId: string, emoji: string) => {
|
||||||
await postsService.removeReaction(postId, emoji)
|
await postsService.removeReaction(postId, emoji)
|
||||||
|
|
||||||
setPosts((prev) =>
|
removeReaction(postId, emoji, user!.username)
|
||||||
produce(prev, (draft: WritableDraft<Post[]>) => {
|
|
||||||
const post = draft.find((p) => p.postId === postId)
|
|
||||||
if (!post) return
|
|
||||||
|
|
||||||
const theReaction = post.reactions.find((r) => r.emoji === emoji)
|
|
||||||
if (theReaction) {
|
|
||||||
theReaction.count = Math.max(theReaction.count - 1, 0)
|
|
||||||
theReaction.didReact = false
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
@ -139,6 +116,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
<PostItem
|
<PostItem
|
||||||
key={post.postId}
|
key={post.postId}
|
||||||
post={post}
|
post={post}
|
||||||
|
reactions={reactions[post.postId] ?? []}
|
||||||
addReaction={(emoji) => onAddReaction(post.postId, emoji)}
|
addReaction={(emoji) => onAddReaction(post.postId, emoji)}
|
||||||
clearReaction={(emoji) => onClearReaction(post.postId, emoji)}
|
clearReaction={(emoji) => onClearReaction(post.postId, emoji)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Post } from '../posts/posts.ts'
|
|
||||||
import { PostsService } from '../posts/postsService.ts'
|
import { PostsService } from '../posts/postsService.ts'
|
||||||
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||||
import NavBar from '../../../components/NavBar.tsx'
|
import NavBar from '../../../components/NavBar.tsx'
|
||||||
|
@ -8,6 +7,9 @@ import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
|
||||||
import PostItem from '../components/PostItem.tsx'
|
import PostItem from '../components/PostItem.tsx'
|
||||||
import NavButton from '../../../components/buttons/NavButton.tsx'
|
import NavButton from '../../../components/buttons/NavButton.tsx'
|
||||||
import { useTranslations } from '../../i18n/translations.ts'
|
import { useTranslations } from '../../i18n/translations.ts'
|
||||||
|
import { usePostViewModel } from '../posts/usePostViewModel.ts'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
import { useUserStore } from '../../user/user.ts'
|
||||||
|
|
||||||
interface PostPageProps {
|
interface PostPageProps {
|
||||||
postsService: PostsService
|
postsService: PostsService
|
||||||
|
@ -15,85 +17,32 @@ interface PostPageProps {
|
||||||
|
|
||||||
export default function PostPage({ postsService }: PostPageProps) {
|
export default function PostPage({ postsService }: PostPageProps) {
|
||||||
const { postId } = useParams<{ postId: string }>()
|
const { postId } = useParams<{ postId: string }>()
|
||||||
const [post, setPost] = useState<Post | null>(null)
|
const { posts, setPosts, addReaction, reactions, removeReaction } = usePostViewModel()
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const { t } = useTranslations()
|
const { t } = useTranslations()
|
||||||
|
const username = useUserStore((state) => state.user?.username)
|
||||||
|
|
||||||
|
const post = posts.at(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPost = async () => {
|
if (!postId) return
|
||||||
if (!postId) {
|
postsService.load(postId).then((post) => setPosts(post ? [post] : []))
|
||||||
setError('Post ID is required')
|
}, [postId, postsService, setPosts])
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load posts and find the one with matching ID
|
|
||||||
const { posts } = await postsService.loadPublicFeed(null, 100)
|
|
||||||
const foundPost = posts.find((p) => p.postId === postId)
|
|
||||||
|
|
||||||
if (foundPost) {
|
|
||||||
setPost(foundPost)
|
|
||||||
} else {
|
|
||||||
setError('Post not found')
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError((e as Error).message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPost()
|
|
||||||
}, [postId, postsService])
|
|
||||||
|
|
||||||
const onAddReaction = async (emoji: string) => {
|
const onAddReaction = async (emoji: string) => {
|
||||||
|
if (!username) return
|
||||||
if (!post) return
|
if (!post) return
|
||||||
|
|
||||||
await postsService.addReaction(post.postId, emoji)
|
await postsService.addReaction(post.postId, emoji)
|
||||||
|
|
||||||
setPost((prevPost) => {
|
addReaction(post.postId, emoji, username, Temporal.Now.instant())
|
||||||
if (!prevPost) return null
|
|
||||||
|
|
||||||
const updatedReactions = [...prevPost.reactions]
|
|
||||||
const theReaction = updatedReactions.find((r) => r.emoji === emoji)
|
|
||||||
|
|
||||||
if (theReaction) {
|
|
||||||
theReaction.count++
|
|
||||||
theReaction.didReact = true
|
|
||||||
} else {
|
|
||||||
updatedReactions.push({ emoji, count: 1, didReact: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prevPost,
|
|
||||||
reactions: updatedReactions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClearReaction = async (emoji: string) => {
|
const onClearReaction = async (emoji: string) => {
|
||||||
|
if (!username) return
|
||||||
if (!post) return
|
if (!post) return
|
||||||
|
|
||||||
await postsService.removeReaction(post.postId, emoji)
|
await postsService.removeReaction(post.postId, emoji)
|
||||||
|
removeReaction(post.postId, emoji, username)
|
||||||
setPost((prevPost) => {
|
|
||||||
if (!prevPost) return null
|
|
||||||
|
|
||||||
const updatedReactions = [...prevPost.reactions]
|
|
||||||
const theReaction = updatedReactions.find((r) => r.emoji === emoji)
|
|
||||||
|
|
||||||
if (theReaction) {
|
|
||||||
theReaction.count = Math.max(theReaction.count - 1, 0)
|
|
||||||
theReaction.didReact = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prevPost,
|
|
||||||
reactions: updatedReactions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -106,14 +55,11 @@ export default function PostPage({ postsService }: PostPageProps) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<main className="w-full max-w-3xl mx-auto">
|
<main className="w-full max-w-3xl mx-auto">
|
||||||
{loading && <div className="text-center py-8">Loading...</div>}
|
|
||||||
|
|
||||||
{error && <div className="text-center py-8 text-red-500">Error: {error}</div>}
|
|
||||||
|
|
||||||
{post && (
|
{post && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<PostItem
|
<PostItem
|
||||||
post={post}
|
post={post}
|
||||||
|
reactions={reactions[post.postId] ?? []}
|
||||||
addReaction={onAddReaction}
|
addReaction={onAddReaction}
|
||||||
clearReaction={onClearReaction}
|
clearReaction={onClearReaction}
|
||||||
hideViewButton={true}
|
hideViewButton={true}
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { Temporal } from '@js-temporal/polyfill'
|
||||||
import { components } from '../../api/schema.ts'
|
import { components } from '../../api/schema.ts'
|
||||||
import { immerable } from 'immer'
|
import { immerable } from 'immer'
|
||||||
|
|
||||||
export interface EmojiReaction {
|
export interface PostReaction {
|
||||||
emoji: string
|
emoji: string
|
||||||
count: number
|
authorName: string
|
||||||
didReact: boolean
|
reactedOn: Temporal.Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Post {
|
export class Post {
|
||||||
|
@ -16,7 +16,7 @@ export class Post {
|
||||||
public readonly media: PostMedia[]
|
public readonly media: PostMedia[]
|
||||||
public readonly createdAt: Temporal.Instant
|
public readonly createdAt: Temporal.Instant
|
||||||
public readonly authorName: string
|
public readonly authorName: string
|
||||||
public readonly reactions: EmojiReaction[]
|
public readonly reactions: PostReaction[]
|
||||||
public readonly possibleReactions: string[]
|
public readonly possibleReactions: string[]
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -25,7 +25,7 @@ export class Post {
|
||||||
media: PostMedia[],
|
media: PostMedia[],
|
||||||
createdAt: string | Temporal.Instant,
|
createdAt: string | Temporal.Instant,
|
||||||
authorName: string,
|
authorName: string,
|
||||||
reactions: EmojiReaction[] = [],
|
reactions: PostReaction[] = [],
|
||||||
possibleReactions: string[] = [],
|
possibleReactions: string[] = [],
|
||||||
) {
|
) {
|
||||||
this.postId = postId
|
this.postId = postId
|
||||||
|
@ -44,11 +44,7 @@ export class Post {
|
||||||
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
|
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
|
||||||
Temporal.Instant.from(dto.createdAt),
|
Temporal.Instant.from(dto.createdAt),
|
||||||
dto.author.username,
|
dto.author.username,
|
||||||
dto.reactions.map((r) => ({
|
dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })),
|
||||||
emoji: r.emoji,
|
|
||||||
count: r.count,
|
|
||||||
didReact: r.didReact,
|
|
||||||
})),
|
|
||||||
dto.possibleReactions,
|
dto.possibleReactions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,22 +29,34 @@ export class PostsService {
|
||||||
return Post.fromDto(response.data.post)
|
return Post.fromDto(response.data.post)
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPublicFeed(
|
async load(postId: string): Promise<Post | null> {
|
||||||
cursor: string | null,
|
const response = await this.client.GET('/posts/{postId}', {
|
||||||
amount: number | null,
|
params: {
|
||||||
): Promise<{ posts: Post[]; next: string | null }> {
|
path: { postId },
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.data?.post) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Post.fromDto(response.data.post)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<{ posts: Post[] }> {
|
||||||
const response = await this.client.GET('/posts', {
|
const response = await this.client.GET('/posts', {
|
||||||
params: {
|
params: {
|
||||||
query: { From: cursor ?? undefined, Amount: amount ?? undefined },
|
query: { After: cursor ?? undefined, Amount: amount ?? undefined },
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
return { posts: [], next: null }
|
return { posts: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { posts: response.data.posts.map(Post.fromDto), next: response.data.next }
|
return { posts: response.data.posts.map(Post.fromDto) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async addReaction(postId: string, emoji: string): Promise<void> {
|
async addReaction(postId: string, emoji: string): Promise<void> {
|
||||||
|
|
82
src/app/feed/posts/usePostViewModel.ts
Normal file
82
src/app/feed/posts/usePostViewModel.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { Post, PostMedia, PostReaction } from './posts.ts'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
import { produce } from 'immer'
|
||||||
|
|
||||||
|
export interface PostInfo {
|
||||||
|
postId: string
|
||||||
|
authorName: string
|
||||||
|
content: string
|
||||||
|
createdAt: Temporal.Instant
|
||||||
|
media: PostMedia[]
|
||||||
|
possibleReactions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactionMap = Record<string, PostReaction[]>
|
||||||
|
|
||||||
|
export function usePostViewModel() {
|
||||||
|
const [posts, _setPosts] = useState<PostInfo[]>([])
|
||||||
|
const [reactions, setReactions] = useState<ReactionMap>({})
|
||||||
|
|
||||||
|
const setPosts = useCallback((posts: Post[]) => {
|
||||||
|
_setPosts([...posts])
|
||||||
|
|
||||||
|
setReactions(
|
||||||
|
posts.reduce((acc, post) => {
|
||||||
|
acc[post.postId] = [...post.reactions]
|
||||||
|
return acc
|
||||||
|
}, {} as ReactionMap),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addPosts = useCallback((posts: Post[]) => {
|
||||||
|
_setPosts((current) => {
|
||||||
|
return [...current, ...posts]
|
||||||
|
})
|
||||||
|
|
||||||
|
setReactions((current) =>
|
||||||
|
produce(current, (draft) => {
|
||||||
|
for (const post of posts) {
|
||||||
|
draft[post.postId] = [...post.reactions]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function addReaction(
|
||||||
|
postId: string,
|
||||||
|
emoji: string,
|
||||||
|
authorName: string,
|
||||||
|
reactedOn: Temporal.Instant,
|
||||||
|
) {
|
||||||
|
setReactions((current) =>
|
||||||
|
produce(current, (draft) => {
|
||||||
|
if (draft[postId]?.some((r) => r.emoji === emoji && r.authorName == authorName)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reaction: PostReaction = { emoji, authorName, reactedOn }
|
||||||
|
|
||||||
|
if (!draft[postId]) {
|
||||||
|
draft[postId] = [{ ...reaction }]
|
||||||
|
} else {
|
||||||
|
draft[postId].push({ ...reaction })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReaction(postId: string, emoji: string, authorName: string) {
|
||||||
|
setReactions((current) =>
|
||||||
|
produce(current, (draft) => {
|
||||||
|
if (!draft[postId]) return
|
||||||
|
|
||||||
|
draft[postId] = draft[postId].filter(
|
||||||
|
(r) => r.emoji !== emoji || r.authorName !== authorName,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { posts, reactions, addPosts, setPosts, addReaction, removeReaction }
|
||||||
|
}
|
19
src/utils/groupByAndMap.ts
Normal file
19
src/utils/groupByAndMap.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export function groupByAndMap<T, U>(
|
||||||
|
items: T[],
|
||||||
|
groupBy: (item: T) => string,
|
||||||
|
map: (item: T) => U,
|
||||||
|
): Record<string, U[]> {
|
||||||
|
const groupings: Record<string, U[]> = {}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const key = groupBy(item)
|
||||||
|
|
||||||
|
if (!groupings[key]) {
|
||||||
|
groupings[key] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
groupings[key].push(map(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupings
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue