diff --git a/package.json b/package.json index bde1bd7..ada1684 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.26.0", + "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 3694fdf..9063592 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import HomePage from './app/feed/pages/HomePage.tsx' +import PostPage from './app/feed/pages/PostPage.tsx' import SignupPage from './app/auth/pages/SignupPage.tsx' import LoginPage from './app/auth/pages/LoginPage.tsx' import LogoutPage from './app/auth/pages/LogoutPage.tsx' @@ -7,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 ( @@ -22,6 +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 a0a541c..db766f6 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,13 +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 }: PostItemProps) { +export default function PostItem({ + post, + reactions, + addReaction, + clearReaction, + hideViewButton = false, +}: PostItemProps) { const formattedDate = post.createdAt.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -31,6 +42,14 @@ export default function PostItem({ post, addReaction, clearReaction }: PostItemP
@{post.authorName}• {formattedDate} + {!hideViewButton && ( + <> + {' • '} + + View + + + )}
{post.content}
@@ -43,26 +62,30 @@ export default function PostItem({ post, addReaction, clearReaction }: PostItemP )} - +
) } 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) @@ -99,7 +122,7 @@ function PostReactionButton({ emoji, didReact, onClick, count }: PostReactionBut