refactor post model

This commit is contained in:
john 2025-08-10 18:08:17 +02:00
parent 62f9de9546
commit 30025b4044
8 changed files with 373 additions and 151 deletions

View file

@ -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 */

View file

@ -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)

View file

@ -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)}
/> />

View file

@ -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}

View file

@ -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,
) )
} }

View file

@ -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> {

View 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 }
}

View 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
}