diff --git a/package.json b/package.json index 28aaca5..ada1684 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.26.1", + "version": "1.26.6", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", @@ -20,7 +20,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.0", - "tailwindcss": "^4.1.5" + "tailwindcss": "^4.1.5", + "zustand": "^5.0.7" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/src/App.tsx b/src/App.tsx index 2e407da..9063592 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,13 +8,36 @@ import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx' import AdminPage from './app/admin/pages/AdminPage.tsx' import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx' import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts' -import { initApp } from './initApp.ts' +import { setGlobal } from './app/femtoApp.ts' +import { PostsService } from './app/feed/posts/postsService.ts' +import { MediaService } from './app/media/mediaService.ts' +import { AuthService } from './app/auth/authService.ts' +import { initClient } from './app/api/client.ts' +import { useEffect } from 'react' +import { useUserStore } from './app/user/user.ts' +import { getUserFromCookie } from './app/auth/getUserFromCookie.ts' -const { postService, mediaService, authService } = initApp() +setGlobal('version', import.meta.env.VITE_FEMTO_VERSION) + +const client = initClient() +const postService = new PostsService(client) +const mediaService = new MediaService(client) +const authService = new AuthService(client) + +setGlobal('postsService', postService) +setGlobal('authService', authService) export default function App() { + const setUser = useUserStore((state) => state.setUser) + useRefreshSessionLoop(authService) + useEffect(() => { + const user = getUserFromCookie() + console.debug('got user cookie', user) + setUser(user) + }, [setUser]) + return ( @@ -23,10 +46,7 @@ export default function App() { path={'/'} element={} /> - } - /> + } /> } /> } /> } /> diff --git a/src/app/api/client.ts b/src/app/api/client.ts index 63cce0c..eebacdf 100644 --- a/src/app/api/client.ts +++ b/src/app/api/client.ts @@ -1,6 +1,8 @@ import { paths } from './schema.ts' import createClient, { Middleware } from 'openapi-fetch' import { dispatchMessage } from '../messageBus/messageBus.ts' +import { useUserStore } from '../user/user.ts' +import { getUserFromCookie } from '../auth/getUserFromCookie.ts' export const initClient = () => { const client = createClient({ baseUrl: import.meta.env.VITE_API_URL }) @@ -9,6 +11,10 @@ export const initClient = () => { if (response.status === 401) { dispatchMessage('auth:unauthorized', null) } + + const user = getUserFromCookie() + console.debug('got user cookie', user) + useUserStore.getState().setUser(user) }, } diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index b809ef0..6af29b3 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -9,7 +9,7 @@ export interface paths { get: { parameters: { query?: { - From?: string + After?: string Amount?: number AuthorId?: string Author?: string @@ -75,7 +75,30 @@ export interface paths { path?: 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 post?: never delete: { @@ -169,6 +192,47 @@ export interface paths { patch?: never trace?: never } + '/posts/{postId}/comments': { + 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']['AddPostCommentRequest'] + 'text/json': components['schemas']['AddPostCommentRequest'] + 'application/*+json': components['schemas']['AddPostCommentRequest'] + } + } + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/media': { parameters: { query?: never @@ -188,7 +252,8 @@ export interface paths { requestBody: { content: { 'multipart/form-data': { - file?: components['schemas']['IFormFile'] + /** Format: binary */ + file?: string } } } @@ -333,6 +398,78 @@ export interface paths { patch?: 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': { parameters: { query?: never @@ -390,9 +527,9 @@ export interface paths { [name: string]: unknown } content: { - 'text/plain': components['schemas']['RefreshUserResult'] - 'application/json': components['schemas']['RefreshUserResult'] - 'text/json': components['schemas']['RefreshUserResult'] + 'text/plain': components['schemas']['GetUserInfoResult'] + 'application/json': components['schemas']['GetUserInfoResult'] + 'text/json': components['schemas']['GetUserInfoResult'] } } } @@ -465,13 +602,99 @@ export interface paths { patch?: 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 export interface components { schemas: { + AddPostCommentRequest: { + /** Format: uuid */ + authorId: string + content: string + } AddPostReactionRequest: { emoji: string } + ChangePasswordRequestBody: { + /** Format: uuid */ + userId: string + newPassword: string + } CreatePostRequest: { /** Format: uuid */ authorId: string @@ -501,20 +724,25 @@ export interface components { DeletePostReactionRequest: { emoji: string } - /** Format: binary */ - IFormFile: string + GetPostResponse: { + post: components['schemas']['PostDto'] + } + GetUserInfoResult: { + /** Format: uuid */ + userId: string + username: string + isSuperUser: boolean + } ListSignupCodesResult: { signupCodes: components['schemas']['SignupCodeDto'][] } LoadPostsResponse: { posts: components['schemas']['PostDto'][] - /** Format: uuid */ - next: string | null } LoginRequest: { username: string password: string - rememberMe: boolean | null + rememberMe: boolean } LoginResponse: { /** Format: uuid */ @@ -527,6 +755,12 @@ export interface components { authorId: string username: string } + PostCommentDto: { + author: string + content: string + /** Format: date-time */ + postedOn: string + } PostDto: { author: components['schemas']['PostAuthorDto'] /** Format: uuid */ @@ -537,6 +771,7 @@ export interface components { /** Format: date-time */ createdAt: string possibleReactions: string[] + comments: components['schemas']['PostCommentDto'][] } PostMediaDto: { /** Format: uri */ @@ -548,21 +783,15 @@ export interface components { } PostReactionDto: { emoji: string - /** Format: int32 */ - count: number - didReact: boolean - } - RefreshUserResult: { - /** Format: uuid */ - userId: string - username: string - isSuperUser: boolean + authorName: string + /** Format: date-time */ + reactedOn: string } RegisterRequest: { username: string password: string signupCode: string - rememberMe: boolean | null + rememberMe: boolean } RegisterResponse: { /** Format: uuid */ diff --git a/src/app/auth/components/AuthNavButtons.tsx b/src/app/auth/components/AuthNavButtons.tsx index c646888..0c5141b 100644 --- a/src/app/auth/components/AuthNavButtons.tsx +++ b/src/app/auth/components/AuthNavButtons.tsx @@ -1,11 +1,11 @@ -import { useUser } from '../../user/user.ts' import NavButton from '../../../components/buttons/NavButton.tsx' import { useLocation } from 'react-router-dom' import { useTranslations } from '../../i18n/translations.ts' +import { useUserStore } from '../../user/user.ts' export default function AuthNavButtons() { const { t } = useTranslations() - const user = useUser() + const user = useUserStore((state) => state.user) const { pathname } = useLocation() diff --git a/src/app/auth/components/Protected.tsx b/src/app/auth/components/Protected.tsx index f5ea238..19d71bb 100644 --- a/src/app/auth/components/Protected.tsx +++ b/src/app/auth/components/Protected.tsx @@ -1,9 +1,9 @@ -import { useUser } from '../../user/user.ts' import { useNavigate, Outlet } from 'react-router-dom' import { useEffect } from 'react' +import { useUserStore } from '../../user/user.ts' export default function Protected() { - const user = useUser() + const user = useUserStore((state) => state.user) const navigate = useNavigate() diff --git a/src/app/auth/components/RefreshUser.tsx b/src/app/auth/components/RefreshUser.tsx index 8796381..7d1db08 100644 --- a/src/app/auth/components/RefreshUser.tsx +++ b/src/app/auth/components/RefreshUser.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, useEffect, useRef } from 'react' import { AuthService } from '../authService.ts' -import { useUser } from '../../user/user.ts' +import { useUserStore } from '../../user/user.ts' interface RefreshUserProps { authService: AuthService @@ -10,7 +10,7 @@ export default function RefreshUser({ authService, children, }: PropsWithChildren) { - const user = useUser() + const user = useUserStore((state) => state.user) const didRefresh = useRef(false) useEffect(() => { diff --git a/src/app/auth/getUserFromCookie.ts b/src/app/auth/getUserFromCookie.ts new file mode 100644 index 0000000..00bd891 --- /dev/null +++ b/src/app/auth/getUserFromCookie.ts @@ -0,0 +1,11 @@ +import { User } from '../user/user.ts' +import { getCookie } from './cookies.ts' + +export function getUserFromCookie(): User | null { + const userCookie = getCookie('user') + + if (!userCookie) return null + + // TODO validate but it should be fine + return JSON.parse(decodeURIComponent(userCookie)) as User +} diff --git a/src/app/auth/pages/LoginPage.tsx b/src/app/auth/pages/LoginPage.tsx index 6c072d7..8b914ca 100644 --- a/src/app/auth/pages/LoginPage.tsx +++ b/src/app/auth/pages/LoginPage.tsx @@ -4,7 +4,7 @@ import TextInput from '../../../components/inputs/TextInput.tsx' import Button from '../../../components/buttons/Button.tsx' import { AuthService } from '../authService.ts' import { useNavigate } from 'react-router-dom' -import { useUser } from '../../user/user.ts' +import { useUserStore } from '../../user/user.ts' import NavBar from '../../../components/NavBar.tsx' import NavButton from '../../../components/buttons/NavButton.tsx' import LinkButton from '../../../components/buttons/LinkButton.tsx' @@ -26,7 +26,7 @@ export default function LoginPage({ authService }: LoginPageProps) { const passwordInputRef = useRef(null) const navigate = useNavigate() - const user = useUser() + const user = useUserStore((state) => state.user) useEffect(() => { if (user) { diff --git a/src/app/auth/pages/LogoutPage.tsx b/src/app/auth/pages/LogoutPage.tsx index dea8a72..dfceb5c 100644 --- a/src/app/auth/pages/LogoutPage.tsx +++ b/src/app/auth/pages/LogoutPage.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom' import { AuthService } from '../authService.ts' import { useEffect } from 'react' -import { useUser } from '../../user/user.ts' +import { useUserStore } from '../../user/user.ts' interface LogoutPageProps { authService: AuthService @@ -9,7 +9,7 @@ interface LogoutPageProps { export default function LogoutPage({ authService }: LogoutPageProps) { const navigate = useNavigate() - const user = useUser() + const user = useUserStore((state) => state.user) useEffect(() => { if (!user) { diff --git a/src/app/feed/components/NewCommentWidget.tsx b/src/app/feed/components/NewCommentWidget.tsx new file mode 100644 index 0000000..3d2e4ea --- /dev/null +++ b/src/app/feed/components/NewCommentWidget.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import FancyTextEditor, { + TextInputKeyDownEvent, +} from '../../../components/inputs/FancyTextEditor.tsx' +import Button from '../../../components/buttons/Button.tsx' +import { useTranslations } from '../../i18n/translations.ts' + +interface NewCommentWidgetProps { + onSubmit: (content: string) => void + isSubmitting?: boolean +} + +export default function NewCommentWidget({ + onSubmit, + isSubmitting = false, +}: NewCommentWidgetProps) { + const { t } = useTranslations() + const [content, setContent] = useState('') + + const onContentInput = (value: string) => { + setContent(value) + } + + const handleSubmit = () => { + if (!content.trim()) { + return + } + + onSubmit(content) + + setContent('') + } + + const onInputKeyDown = (e: TextInputKeyDownEvent) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault() + handleSubmit() + } + } + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/src/components/NewPostWidget.tsx b/src/app/feed/components/NewPostWidget.tsx similarity index 95% rename from src/components/NewPostWidget.tsx rename to src/app/feed/components/NewPostWidget.tsx index c943577..e0bd87a 100644 --- a/src/components/NewPostWidget.tsx +++ b/src/app/feed/components/NewPostWidget.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' -import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx' -import Button from './buttons/Button.tsx' -import { openFileDialog } from '../utils/openFileDialog.ts' +import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx' +import Button from '../../../components/buttons/Button.tsx' +import { openFileDialog } from '../../../utils/openFileDialog.ts' import makePica from 'pica' -import { useTranslations } from '../app/i18n/translations.ts' +import { useTranslations } from '../../i18n/translations.ts' interface NewPostWidgetProps { onSubmit: ( diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index 05fcbb3..db766f6 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,15 +1,24 @@ -import { Post, PostMedia } from '../posts/posts.ts' +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: Post + post: PostInfo + reactions: PostReaction[] addReaction: (emoji: string) => void clearReaction: (emoji: string) => void 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', { year: 'numeric', month: 'short', @@ -53,26 +62,30 @@ export default function PostItem({ post, addReaction, clearReaction, hideViewBut )} - + ) } interface PostReactionsProps { - post: Post + post: PostInfo + reactions: PostReaction[] addReaction: (emoji: string) => void clearReaction: (emoji: string) => void } -function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) { - const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r])) - +function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) { + const username = useUserStore((state) => state.user?.username) return (
{post.possibleReactions.map((emoji) => { - const reaction = reactionMap.get(emoji) - const count = reaction?.count ?? 0 - const didReact = reaction?.didReact ?? false + 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) @@ -137,7 +150,6 @@ function PostMediaItem({ media }: PostMediaProps) { width={width} height={height} src={url.toString()} - alt="todo sry :(" className="w-full h-auto" loading="lazy" /> diff --git a/src/app/feed/components/PostTimeline.tsx b/src/app/feed/components/PostTimeline.tsx new file mode 100644 index 0000000..c9653f1 --- /dev/null +++ b/src/app/feed/components/PostTimeline.tsx @@ -0,0 +1,67 @@ +import { PostComment, PostReaction } from '../posts/posts.ts' +import { Temporal } from '@js-temporal/polyfill' + +interface PostTimelineProps { + reactions: PostReaction[] + comments: PostComment[] +} + +export function PostTimeline({ reactions, comments }: PostTimelineProps) { + const items = [ + ...reactions.map((reaction) => ({ + timestamp: reaction.reactedOn, + component: ( + + ), + })), + ...comments.map((comment) => ({ + timestamp: comment.postedOn, + component: ( + + ), + })), + ].toSorted((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp)) + + return ( +
{items.map((item) => item.component)}
+ ) +} + +function ReactionItem({ reaction }: { reaction: PostReaction }) { + return ( +
+ {formatItemDate(reaction.reactedOn)} +
+ @{reaction.authorName}  + clicked  + {reaction.emoji} +
+
+ ) +} + +function CommentItem({ comment }: { comment: PostComment }) { + return ( +
+
{formatItemDate(comment.postedOn)}
+
+ @{comment.author}  +
+
{comment.content}
+
+ ) +} + +function formatItemDate(date: Temporal.Instant) { + return date.toLocaleString('en-AU', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) +} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index c470d39..b505d83 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -1,17 +1,17 @@ import { useRef, useState } from 'react' import { PostsService } from '../posts/postsService.ts' -import { useUser } from '../../user/user.ts' +import { useUserStore } from '../../user/user.ts' import { MediaService } from '../../media/mediaService.ts' -import NewPostWidget from '../../../components/NewPostWidget.tsx' +import NewPostWidget from '../components/NewPostWidget.tsx' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import NavBar from '../../../components/NavBar.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts' -import { Post } from '../posts/posts.ts' -import { produce, WritableDraft } from 'immer' import PostItem from '../components/PostItem.tsx' import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts' import { delay } from '../../../utils/delay.ts' +import { usePostViewModel } from '../posts/usePostViewModel.ts' +import { Temporal } from '@js-temporal/polyfill' interface HomePageProps { postsService: PostsService @@ -21,11 +21,12 @@ interface HomePageProps { const PageSize = 20 export default function HomePage({ postsService, mediaService }: HomePageProps) { - const user = useUser() + const user = useUserStore((state) => state.user) useSaveSignupCodeToLocalStorage() const [isSubmitting, setIsSubmitting] = useState(false) - const [posts, setPosts] = useState([]) + const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel() + const [hasMore, setHasMore] = useState(true) const [error, setError] = useState(null) @@ -37,14 +38,14 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) loading.current = true try { - const [{ posts, next }] = await Promise.all([ + const [{ posts }] = await Promise.all([ postsService.loadPublicFeed(cursor.current, PageSize), delay(500), ]) setHasMore(posts.length >= PageSize) - cursor.current = next - setPosts((prev) => [...prev, ...posts]) + cursor.current = posts.at(-1)?.postId ?? null + addPosts(posts) } catch (e: unknown) { setError((e as Error).message) } finally { @@ -73,7 +74,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) }), ) const post = await postsService.createNew(user.id, content, media, isPublic) - setPosts((pages) => [post, ...pages]) + addPosts([post]) } catch (error) { console.error('Failed to create post:', error) } finally { @@ -86,37 +87,13 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) const onAddReaction = async (postId: string, emoji: string) => { await postsService.addReaction(postId, emoji) - setPosts((prev) => - produce(prev, (draft: WritableDraft) => { - 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 }) - } - }), - ) + addReaction(postId, emoji, user!.username, Temporal.Now.instant()) } const onClearReaction = async (postId: string, emoji: string) => { await postsService.removeReaction(postId, emoji) - setPosts((prev) => - produce(prev, (draft: WritableDraft) => { - 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 - } - }), - ) + removeReaction(postId, emoji, user!.username) } const sentinelRef = useRef(null) @@ -139,6 +116,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) onAddReaction(post.postId, emoji)} clearReaction={(emoji) => onClearReaction(post.postId, emoji)} /> diff --git a/src/app/feed/pages/PostPage.tsx b/src/app/feed/pages/PostPage.tsx index ed4c7cc..b275b68 100644 --- a/src/app/feed/pages/PostPage.tsx +++ b/src/app/feed/pages/PostPage.tsx @@ -1,11 +1,17 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useParams } from 'react-router-dom' -import { Post } from '../posts/posts.ts' import { PostsService } from '../posts/postsService.ts' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import NavBar from '../../../components/NavBar.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import PostItem from '../components/PostItem.tsx' +import NavButton from '../../../components/buttons/NavButton.tsx' +import { useTranslations } from '../../i18n/translations.ts' +import { usePostViewModel } from '../posts/usePostViewModel.ts' +import { Temporal } from '@js-temporal/polyfill' +import { useUserStore } from '../../user/user.ts' +import { PostTimeline } from '../components/PostTimeline.tsx' +import NewCommentWidget from '../components/NewCommentWidget.tsx' interface PostPageProps { postsService: PostsService @@ -13,107 +19,75 @@ interface PostPageProps { export default function PostPage({ postsService }: PostPageProps) { const { postId } = useParams<{ postId: string }>() - const [post, setPost] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const { posts, setPosts, addReaction, reactions: _reactions, removeReaction } = usePostViewModel() + const { t } = useTranslations() + const username = useUserStore((state) => state.user?.username) + const post = posts.at(0) + const reactions = (post?.postId ? _reactions[post.postId] : []) ?? [] + + const loadPost = useCallback(() => { + if (!postId) return + postsService.load(postId).then((post) => setPosts(post ? [post] : [])) + }, [postId, postsService, setPosts]) useEffect(() => { - const fetchPost = async () => { - if (!postId) { - setError('Post ID is required') - 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]) + loadPost() + }, [loadPost]) const onAddReaction = async (emoji: string) => { + if (!username) return if (!post) return await postsService.addReaction(post.postId, emoji) - setPost((prevPost) => { - 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, - } - }) + addReaction(post.postId, emoji, username, Temporal.Now.instant()) } const onClearReaction = async (emoji: string) => { + if (!username) return if (!post) return await postsService.removeReaction(post.postId, emoji) - - 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, - } - }) + removeReaction(post.postId, emoji, username) } + async function onSubmitComment(content: string) { + if (!postId) return + if (!content.trim()) return + + try { + setIsSubmittingComment(true) + await postsService.addComment(postId, content) + } finally { + setIsSubmittingComment(false) + } + + loadPost() + } + + const [isSubmittingComment, setIsSubmittingComment] = useState(false) + return ( + {t('nav.home')} } >
- {loading &&
Loading...
} - - {error &&
Error: {error}
} - {post && (
+ +
)}
diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index bcd1b0b..837e2b8 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -2,10 +2,16 @@ import { Temporal } from '@js-temporal/polyfill' import { components } from '../../api/schema.ts' import { immerable } from 'immer' -export interface EmojiReaction { +export interface PostReaction { emoji: string - count: number - didReact: boolean + authorName: string + reactedOn: Temporal.Instant +} + +export interface PostComment { + author: string + content: string + postedOn: Temporal.Instant } export class Post { @@ -16,8 +22,9 @@ export class Post { public readonly media: PostMedia[] public readonly createdAt: Temporal.Instant public readonly authorName: string - public readonly reactions: EmojiReaction[] + public readonly reactions: PostReaction[] public readonly possibleReactions: string[] + public readonly comments: PostComment[] constructor( postId: string, @@ -25,8 +32,9 @@ export class Post { media: PostMedia[], createdAt: string | Temporal.Instant, authorName: string, - reactions: EmojiReaction[] = [], - possibleReactions: string[] = [], + reactions: PostReaction[], + possibleReactions: string[], + comments: PostComment[], ) { this.postId = postId this.content = content @@ -35,6 +43,7 @@ export class Post { this.authorName = authorName this.reactions = reactions this.possibleReactions = possibleReactions + this.comments = comments } public static fromDto(dto: components['schemas']['PostDto']): Post { @@ -44,12 +53,9 @@ export class Post { dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)), Temporal.Instant.from(dto.createdAt), dto.author.username, - dto.reactions.map((r) => ({ - emoji: r.emoji, - count: r.count, - didReact: r.didReact, - })), + dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })), dto.possibleReactions, + dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })), ) } } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 06fa2ff..72e55f7 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -1,5 +1,6 @@ import { Post } from './posts.ts' import { ApiClient } from '../../api/client.ts' +import { useUserStore } from '../../user/user.ts' export class PostsService { constructor(private readonly client: ApiClient) {} @@ -29,22 +30,34 @@ export class PostsService { return Post.fromDto(response.data.post) } - async loadPublicFeed( - cursor: string | null, - amount: number | null, - ): Promise<{ posts: Post[]; next: string | null }> { + async load(postId: string): Promise { + const response = await this.client.GET('/posts/{postId}', { + params: { + 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', { params: { - query: { From: cursor ?? undefined, Amount: amount ?? undefined }, + query: { After: cursor ?? undefined, Amount: amount ?? undefined }, }, credentials: 'include', }) 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 { @@ -66,6 +79,17 @@ export class PostsService { credentials: 'include', }) } + + async addComment(postId: string, content: string): Promise { + const authorId = useUserStore.getState().user?.id + if (!authorId) return + + await this.client.POST('/posts/{postId}/comments', { + params: { path: { postId } }, + body: { content, authorId }, + credentials: 'include', + }) + } } interface CreatePostMedia { diff --git a/src/app/feed/posts/usePostViewModel.ts b/src/app/feed/posts/usePostViewModel.ts new file mode 100644 index 0000000..2fb06bd --- /dev/null +++ b/src/app/feed/posts/usePostViewModel.ts @@ -0,0 +1,83 @@ +import { useCallback, useState } from 'react' +import { Post, PostComment, 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[] + comments: PostComment[] +} + +type ReactionMap = Record + +export function usePostViewModel() { + const [posts, _setPosts] = useState([]) + const [reactions, setReactions] = useState({}) + + 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 } +} diff --git a/src/app/user/user.ts b/src/app/user/user.ts index fb5b520..8ce3446 100644 --- a/src/app/user/user.ts +++ b/src/app/user/user.ts @@ -1,8 +1,4 @@ -import { addMessageListener, dispatchMessage } from '../messageBus/messageBus.ts' -import { getCookie } from '../auth/cookies.ts' -import { useMessageListener } from '../../hooks/useMessageListener.ts' -import { useState } from 'react' -import { setGlobal } from '../femtoApp.ts' +import { create } from 'zustand' export interface User { id: string @@ -15,38 +11,12 @@ export enum Role { SuperUser = 1, } -let globalUser: User | null - -export function initUser() { - updateUser() - - addMessageListener('auth:logged-in', updateUser) - addMessageListener('auth:registered', updateUser) - addMessageListener('auth:logged-out', updateUser) - addMessageListener('auth:refreshed', updateUser) +interface UserState { + user: User | null + setUser: (user: User | null) => void } -function updateUser() { - globalUser = getUserFromCookie() - setGlobal('user', globalUser) - dispatchMessage('user:updated', globalUser) -} - -export function useUser(): User | null { - const [user, setUser] = useState(globalUser) - - useMessageListener('user:updated', (u) => { - setUser(u) - }) - - return user -} - -function getUserFromCookie(): User | null { - const userCookie = getCookie('user') - - if (!userCookie) return null - - // TODO validate but it should be fine - return JSON.parse(decodeURIComponent(userCookie)) as User -} +export const useUserStore = create()((set) => ({ + user: null, + setUser: (user: User | null) => set({ user }), +})) diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 330c7aa..89691c9 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,11 +1,13 @@ -import { PropsWithChildren } from 'react' -import { Role, useUser } from '../app/user/user.ts' +import { PropsWithChildren, ReactNode } from 'react' +import { Role, useUserStore } from '../app/user/user.ts' import NavButton from './buttons/NavButton.tsx' -type NavBarProps = unknown +type NavBarProps = { + leftChildren?: ReactNode +} export default function NavBar({ children }: PropsWithChildren) { - const user = useUser() + const user = useUserStore((state) => state.user) const isSuperUser = user?.roles.includes(Role.SuperUser) return (