comments
This commit is contained in:
parent
52b5a490ac
commit
91e1116532
9 changed files with 226 additions and 33 deletions
|
@ -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 */
|
||||
|
|
58
src/app/feed/components/NewCommentWidget.tsx
Normal file
58
src/app/feed/components/NewCommentWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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: (
|
|
@ -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`}>{items.map((item) => item.component)}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionItem({ reaction }: { reaction: PostReaction }) {
|
||||
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',
|
||||
})}
|
||||
<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>
|
||||
<span>clicked</span>
|
||||
<span>{reaction.emoji}</span>
|
||||
</div>
|
||||
<div className={`flex flex-row items-baseline text-gray-500`}>
|
||||
<span className={`text-gray-400`}>@{reaction.authorName}</span>
|
||||
<span>did</span>
|
||||
<span className={`text-lg`}>{reaction.emoji}</span>
|
||||
</div>
|
||||
</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>
|
||||
</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',
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) })),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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[]>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue