Compare commits

...

12 commits

Author SHA1 Message Date
88348ed6e1 v1.26.6 2025-08-10 21:23:22 +02:00
91e1116532 comments 2025-08-10 21:20:47 +02:00
52b5a490ac v1.26.5 2025-08-10 18:46:39 +02:00
964ba05724 fix stupid AI bullshit fuckery 2025-08-10 18:45:54 +02:00
a0c450384d v1.26.4 2025-08-10 18:44:18 +02:00
9bc2da8748 remove janky fake alt text 2025-08-10 18:43:32 +02:00
380a9a4f36 v1.26.3 2025-08-10 18:42:34 +02:00
7ecea242f2 add post reaction timeline 2025-08-10 18:41:45 +02:00
5aeed54b20 v1.26.2 2025-08-10 18:15:44 +02:00
30025b4044 refactor post model 2025-08-10 18:08:17 +02:00
62f9de9546 add home link to nav 2025-08-10 16:22:13 +02:00
5f29bc436c use zustand 2025-08-10 16:16:23 +02:00
25 changed files with 688 additions and 245 deletions

View file

@ -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",

View file

@ -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 (
<BrowserRouter>
<UnauthorizedHandler>
@ -23,10 +46,7 @@ export default function App() {
path={'/'}
element={<HomePage postsService={postService} mediaService={mediaService} />}
/>
<Route
path={'/p/:postId'}
element={<PostPage postsService={postService} />}
/>
<Route path={'/p/:postId'} element={<PostPage postsService={postService} />} />
<Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/logout" element={<LogoutPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />

View file

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

View file

@ -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<string, never>
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 */

View file

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

View file

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

View file

@ -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<RefreshUserProps>) {
const user = useUser()
const user = useUserStore((state) => state.user)
const didRefresh = useRef(false)
useEffect(() => {

View file

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

View file

@ -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<HTMLInputElement | null>(null)
const navigate = useNavigate()
const user = useUser()
const user = useUserStore((state) => state.user)
useEffect(() => {
if (user) {

View file

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

View file

@ -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 (
<div className="w-full p-4 border-b border-gray-200">
<FancyTextEditor
value={content}
onInput={onContentInput}
onKeyDown={onInputKeyDown}
className="mb-3"
placeholder={t('post.editor.placeholder')}
/>
<div className="flex justify-end items-center">
<Button onClick={handleSubmit} disabled={isSubmitting || content.trim() === ''}>
{t('post.submit.cta')}
</Button>
</div>
</div>
)
}

View file

@ -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: (

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 { 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
</div>
)}
<PostReactions post={post} addReaction={addReaction} clearReaction={clearReaction} />
<PostReactions
post={post}
reactions={reactions}
addReaction={addReaction}
clearReaction={clearReaction}
/>
</article>
)
}
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 (
<div className="flex flex-wrap gap-2 mt-3 justify-end">
{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"
/>

View file

@ -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: (
<ReactionItem
key={'reaction-' + reaction.authorName + reaction.reactedOn.toString()}
reaction={reaction}
/>
),
})),
...comments.map((comment) => ({
timestamp: comment.postedOn,
component: (
<CommentItem
key={'comment-' + comment.author + comment.postedOn.toString()}
comment={comment}
/>
),
})),
].toSorted((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp))
return (
<div className={`flex flex-col gap-4 mb-4 px-4`}>{items.map((item) => item.component)}</div>
)
}
function ReactionItem({ reaction }: { reaction: PostReaction }) {
return (
<div className={`flex flex-col`}>
<span className={`text-gray-400 text-xs -mb-0.5`}>{formatItemDate(reaction.reactedOn)}</span>
<div className={`flex flex-row items-baseline text-gray-400`}>
<span>@{reaction.authorName}</span>&nbsp;
<span>clicked</span>&nbsp;
<span>{reaction.emoji}</span>
</div>
</div>
)
}
function CommentItem({ comment }: { comment: PostComment }) {
return (
<div className={`flex flex-col`}>
<div className={`text-gray-400 text-xs -mb-0.5`}>{formatItemDate(comment.postedOn)}</div>
<div className={`flex flex-row items-baseline text-gray-500`}>
<span className={`text-gray-400`}>@{comment.author}</span>&nbsp;
</div>
<div className={`ml-1 text-gray-600`}>{comment.content}</div>
</div>
)
}
function formatItemDate(date: Temporal.Instant) {
return date.toLocaleString('en-AU', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
})
}

View file

@ -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<Post[]>([])
const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel()
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(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<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 })
}
}),
)
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<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
}
}),
)
removeReaction(postId, emoji, user!.username)
}
const sentinelRef = useRef<HTMLDivElement | null>(null)
@ -139,6 +116,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
<PostItem
key={post.postId}
post={post}
reactions={reactions[post.postId] ?? []}
addReaction={(emoji) => onAddReaction(post.postId, emoji)}
clearReaction={(emoji) => onClearReaction(post.postId, emoji)}
/>

View file

@ -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<Post | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<SingleColumnLayout
navbar={
<NavBar>
<NavButton to={{ pathname: '/' }}>{t('nav.home')}</NavButton>
<AuthNavButtons />
</NavBar>
}
>
<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 && (
<div className="w-full">
<PostItem
post={post}
reactions={reactions}
addReaction={onAddReaction}
clearReaction={onClearReaction}
hideViewButton={true}
/>
<PostTimeline reactions={reactions} comments={post.comments} />
<NewCommentWidget onSubmit={onSubmitComment} isSubmitting={isSubmittingComment} />
</div>
)}
</main>

View file

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

View file

@ -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<Post | null> {
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<void> {
@ -66,6 +79,17 @@ export class PostsService {
credentials: 'include',
})
}
async addComment(postId: string, content: string): Promise<void> {
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 {

View file

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

@ -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<UserState>()((set) => ({
user: null,
setUser: (user: User | null) => set({ user }),
}))

View file

@ -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<NavBarProps>) {
const user = useUser()
const user = useUserStore((state) => state.user)
const isSuperUser = user?.roles.includes(Role.SuperUser)
return (
<nav className={`w-full flex flex-row justify-between px-4 md:px-8 py-3`}>

View file

@ -1,22 +0,0 @@
import { initUser } from './app/user/user.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'
export function initApp() {
setGlobal('version', import.meta.env.VITE_FEMTO_VERSION)
initUser()
const client = initClient()
const postService = new PostsService(client)
const mediaService = new MediaService(client)
const authService = new AuthService(client)
setGlobal('postsService', postService)
setGlobal('authService', authService)
return { postService, mediaService, authService }
}

View file

@ -1,10 +1,10 @@
import { useEffect } from 'react'
import { useUser } from './app/user/user.ts'
import { AuthService } from './app/auth/authService.ts'
import { useUserStore } from './app/user/user.ts'
// Starts a loop that pings the server to keep the session alive, while also getting any updates on the user profile
export function useRefreshSessionLoop(authService: AuthService) {
const user = useUser()
const user = useUserStore((state) => state.user)
const userId = user?.id ?? null
useEffect(() => {

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
}

View file

@ -2574,3 +2574,8 @@ zod@^3.23.8, zod@^3.24.2:
version "3.24.3"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
zustand@^5.0.7:
version "5.0.7"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc"
integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==