This commit is contained in:
john 2025-08-10 19:57:33 +02:00
parent 52b5a490ac
commit ebc314f638
9 changed files with 226 additions and 33 deletions

View file

@ -9,7 +9,7 @@ export interface paths {
get: {
parameters: {
query?: {
From?: string
After?: string
Amount?: number
AuthorId?: string
Author?: string
@ -192,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
@ -641,6 +682,11 @@ export interface paths {
export type webhooks = Record<string, never>
export interface components {
schemas: {
AddPostCommentRequest: {
/** Format: uuid */
authorId: string
content: string
}
AddPostReactionRequest: {
emoji: string
}
@ -709,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 */
@ -719,6 +771,7 @@ export interface components {
/** Format: date-time */
createdAt: string
possibleReactions: string[]
comments: components['schemas']['PostCommentDto'][]
}
PostMediaDto: {
/** Format: uri */

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,30 +1,67 @@
import { PostReaction } from '../posts/posts.ts'
import { PostComment, PostReaction } from '../posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill'
interface PostTimelineProps {
reactions: PostReaction[]
comments: PostComment[]
}
export function PostTimeline({ reactions }: PostTimelineProps) {
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`}>
{reactions.map((reaction) => (
<div className={`flex flex-col`}>
<div className={`text-gray-400 text-xs -mb-1`}>
{reaction.reactedOn.toLocaleString('en-AU', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
<div className={`flex flex-row items-baseline text-gray-500`}>
<span className={`text-gray-400`}>@{reaction.authorName}</span>&nbsp;
<span>did</span>&nbsp;
<span className={`text-lg`}>{reaction.emoji}</span>
</div>
</div>
))}
<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`}>
<div className={`text-gray-400 text-xs -mb-1`}>{formatItemDate(reaction.reactedOn)}</div>
<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-1`}>{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

@ -2,7 +2,7 @@ import { useRef, useState } from 'react'
import { PostsService } from '../posts/postsService.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'

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { PostsService } from '../posts/postsService.ts'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
@ -11,6 +11,7 @@ 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
@ -24,11 +25,15 @@ export default function PostPage({ postsService }: PostPageProps) {
const post = posts.at(0)
const reactions = (post?.postId ? _reactions[post.postId] : []) ?? []
useEffect(() => {
const loadPost = useCallback(() => {
if (!postId) return
postsService.load(postId).then((post) => setPosts(post ? [post] : []))
}, [postId, postsService, setPosts])
useEffect(() => {
loadPost()
}, [loadPost])
const onAddReaction = async (emoji: string) => {
if (!username) return
if (!post) return
@ -46,6 +51,22 @@ export default function PostPage({ postsService }: PostPageProps) {
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={
@ -65,7 +86,8 @@ export default function PostPage({ postsService }: PostPageProps) {
clearReaction={onClearReaction}
hideViewButton={true}
/>
<PostTimeline reactions={reactions} />
<PostTimeline reactions={reactions} comments={post.comments} />
<NewCommentWidget onSubmit={onSubmitComment} isSubmitting={isSubmittingComment} />
</div>
)}
</main>

View file

@ -8,6 +8,12 @@ export interface PostReaction {
reactedOn: Temporal.Instant
}
export interface PostComment {
author: string
content: string
postedOn: Temporal.Instant
}
export class Post {
[immerable] = true
@ -18,6 +24,7 @@ export class Post {
public readonly authorName: string
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: PostReaction[] = [],
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 {
@ -46,6 +55,7 @@ export class Post {
dto.author.username,
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) {}
@ -78,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

@ -1,5 +1,5 @@
import { useCallback, useState } from 'react'
import { Post, PostMedia, PostReaction } from './posts.ts'
import { Post, PostComment, PostMedia, PostReaction } from './posts.ts'
import { Temporal } from '@js-temporal/polyfill'
import { produce } from 'immer'
@ -10,6 +10,7 @@ export interface PostInfo {
createdAt: Temporal.Instant
media: PostMedia[]
possibleReactions: string[]
comments: PostComment[]
}
type ReactionMap = Record<string, PostReaction[]>