comments
This commit is contained in:
parent
52b5a490ac
commit
ebc314f638
9 changed files with 226 additions and 33 deletions
|
@ -9,7 +9,7 @@ export interface paths {
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
From?: string
|
After?: string
|
||||||
Amount?: number
|
Amount?: number
|
||||||
AuthorId?: string
|
AuthorId?: string
|
||||||
Author?: string
|
Author?: string
|
||||||
|
@ -192,6 +192,47 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: 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': {
|
'/media': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
@ -641,6 +682,11 @@ export interface paths {
|
||||||
export type webhooks = Record<string, never>
|
export type webhooks = Record<string, never>
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
AddPostCommentRequest: {
|
||||||
|
/** Format: uuid */
|
||||||
|
authorId: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
AddPostReactionRequest: {
|
AddPostReactionRequest: {
|
||||||
emoji: string
|
emoji: string
|
||||||
}
|
}
|
||||||
|
@ -709,6 +755,12 @@ export interface components {
|
||||||
authorId: string
|
authorId: string
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
PostCommentDto: {
|
||||||
|
author: string
|
||||||
|
content: string
|
||||||
|
/** Format: date-time */
|
||||||
|
postedOn: string
|
||||||
|
}
|
||||||
PostDto: {
|
PostDto: {
|
||||||
author: components['schemas']['PostAuthorDto']
|
author: components['schemas']['PostAuthorDto']
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
@ -719,6 +771,7 @@ export interface components {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string
|
createdAt: string
|
||||||
possibleReactions: string[]
|
possibleReactions: string[]
|
||||||
|
comments: components['schemas']['PostCommentDto'][]
|
||||||
}
|
}
|
||||||
PostMediaDto: {
|
PostMediaDto: {
|
||||||
/** Format: uri */
|
/** 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 { useState } from 'react'
|
||||||
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
|
import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx'
|
||||||
import Button from './buttons/Button.tsx'
|
import Button from '../../../components/buttons/Button.tsx'
|
||||||
import { openFileDialog } from '../utils/openFileDialog.ts'
|
import { openFileDialog } from '../../../utils/openFileDialog.ts'
|
||||||
import makePica from 'pica'
|
import makePica from 'pica'
|
||||||
import { useTranslations } from '../app/i18n/translations.ts'
|
import { useTranslations } from '../../i18n/translations.ts'
|
||||||
|
|
||||||
interface NewPostWidgetProps {
|
interface NewPostWidgetProps {
|
||||||
onSubmit: (
|
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 {
|
interface PostTimelineProps {
|
||||||
reactions: PostReaction[]
|
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 (
|
return (
|
||||||
<div className={`flex flex-col gap-4 mb-4 px-4`}>
|
|
||||||
{reactions.map((reaction) => (
|
|
||||||
<div className={`flex flex-col`}>
|
<div className={`flex flex-col`}>
|
||||||
<div className={`text-gray-400 text-xs -mb-1`}>
|
<div className={`text-gray-400 text-xs -mb-1`}>{formatItemDate(reaction.reactedOn)}</div>
|
||||||
{reaction.reactedOn.toLocaleString('en-AU', {
|
<div className={`flex flex-row items-baseline text-gray-400`}>
|
||||||
year: 'numeric',
|
<span>@{reaction.authorName}</span>
|
||||||
month: 'numeric',
|
<span>clicked</span>
|
||||||
day: 'numeric',
|
<span>{reaction.emoji}</span>
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
</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 { PostsService } from '../posts/postsService.ts'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
import { useUserStore } from '../../user/user.ts'
|
||||||
import { MediaService } from '../../media/mediaService.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 SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||||
import NavBar from '../../../components/NavBar.tsx'
|
import NavBar from '../../../components/NavBar.tsx'
|
||||||
import AuthNavButtons from '../../auth/components/AuthNavButtons.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 { useParams } from 'react-router-dom'
|
||||||
import { PostsService } from '../posts/postsService.ts'
|
import { PostsService } from '../posts/postsService.ts'
|
||||||
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||||
|
@ -11,6 +11,7 @@ import { usePostViewModel } from '../posts/usePostViewModel.ts'
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
import { useUserStore } from '../../user/user.ts'
|
import { useUserStore } from '../../user/user.ts'
|
||||||
import { PostTimeline } from '../components/PostTimeline.tsx'
|
import { PostTimeline } from '../components/PostTimeline.tsx'
|
||||||
|
import NewCommentWidget from '../components/NewCommentWidget.tsx'
|
||||||
|
|
||||||
interface PostPageProps {
|
interface PostPageProps {
|
||||||
postsService: PostsService
|
postsService: PostsService
|
||||||
|
@ -24,11 +25,15 @@ export default function PostPage({ postsService }: PostPageProps) {
|
||||||
const post = posts.at(0)
|
const post = posts.at(0)
|
||||||
const reactions = (post?.postId ? _reactions[post.postId] : []) ?? []
|
const reactions = (post?.postId ? _reactions[post.postId] : []) ?? []
|
||||||
|
|
||||||
useEffect(() => {
|
const loadPost = useCallback(() => {
|
||||||
if (!postId) return
|
if (!postId) return
|
||||||
postsService.load(postId).then((post) => setPosts(post ? [post] : []))
|
postsService.load(postId).then((post) => setPosts(post ? [post] : []))
|
||||||
}, [postId, postsService, setPosts])
|
}, [postId, postsService, setPosts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPost()
|
||||||
|
}, [loadPost])
|
||||||
|
|
||||||
const onAddReaction = async (emoji: string) => {
|
const onAddReaction = async (emoji: string) => {
|
||||||
if (!username) return
|
if (!username) return
|
||||||
if (!post) return
|
if (!post) return
|
||||||
|
@ -46,6 +51,22 @@ export default function PostPage({ postsService }: PostPageProps) {
|
||||||
removeReaction(post.postId, emoji, username)
|
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 (
|
return (
|
||||||
<SingleColumnLayout
|
<SingleColumnLayout
|
||||||
navbar={
|
navbar={
|
||||||
|
@ -65,7 +86,8 @@ export default function PostPage({ postsService }: PostPageProps) {
|
||||||
clearReaction={onClearReaction}
|
clearReaction={onClearReaction}
|
||||||
hideViewButton={true}
|
hideViewButton={true}
|
||||||
/>
|
/>
|
||||||
<PostTimeline reactions={reactions} />
|
<PostTimeline reactions={reactions} comments={post.comments} />
|
||||||
|
<NewCommentWidget onSubmit={onSubmitComment} isSubmitting={isSubmittingComment} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -8,6 +8,12 @@ export interface PostReaction {
|
||||||
reactedOn: Temporal.Instant
|
reactedOn: Temporal.Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostComment {
|
||||||
|
author: string
|
||||||
|
content: string
|
||||||
|
postedOn: Temporal.Instant
|
||||||
|
}
|
||||||
|
|
||||||
export class Post {
|
export class Post {
|
||||||
[immerable] = true
|
[immerable] = true
|
||||||
|
|
||||||
|
@ -18,6 +24,7 @@ export class Post {
|
||||||
public readonly authorName: string
|
public readonly authorName: string
|
||||||
public readonly reactions: PostReaction[]
|
public readonly reactions: PostReaction[]
|
||||||
public readonly possibleReactions: string[]
|
public readonly possibleReactions: string[]
|
||||||
|
public readonly comments: PostComment[]
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
postId: string,
|
postId: string,
|
||||||
|
@ -25,8 +32,9 @@ export class Post {
|
||||||
media: PostMedia[],
|
media: PostMedia[],
|
||||||
createdAt: string | Temporal.Instant,
|
createdAt: string | Temporal.Instant,
|
||||||
authorName: string,
|
authorName: string,
|
||||||
reactions: PostReaction[] = [],
|
reactions: PostReaction[],
|
||||||
possibleReactions: string[] = [],
|
possibleReactions: string[],
|
||||||
|
comments: PostComment[],
|
||||||
) {
|
) {
|
||||||
this.postId = postId
|
this.postId = postId
|
||||||
this.content = content
|
this.content = content
|
||||||
|
@ -35,6 +43,7 @@ export class Post {
|
||||||
this.authorName = authorName
|
this.authorName = authorName
|
||||||
this.reactions = reactions
|
this.reactions = reactions
|
||||||
this.possibleReactions = possibleReactions
|
this.possibleReactions = possibleReactions
|
||||||
|
this.comments = comments
|
||||||
}
|
}
|
||||||
|
|
||||||
public static fromDto(dto: components['schemas']['PostDto']): Post {
|
public static fromDto(dto: components['schemas']['PostDto']): Post {
|
||||||
|
@ -46,6 +55,7 @@ export class Post {
|
||||||
dto.author.username,
|
dto.author.username,
|
||||||
dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })),
|
dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })),
|
||||||
dto.possibleReactions,
|
dto.possibleReactions,
|
||||||
|
dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Post } from './posts.ts'
|
import { Post } from './posts.ts'
|
||||||
import { ApiClient } from '../../api/client.ts'
|
import { ApiClient } from '../../api/client.ts'
|
||||||
|
import { useUserStore } from '../../user/user.ts'
|
||||||
|
|
||||||
export class PostsService {
|
export class PostsService {
|
||||||
constructor(private readonly client: ApiClient) {}
|
constructor(private readonly client: ApiClient) {}
|
||||||
|
@ -78,6 +79,17 @@ export class PostsService {
|
||||||
credentials: 'include',
|
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 {
|
interface CreatePostMedia {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useState } from 'react'
|
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 { Temporal } from '@js-temporal/polyfill'
|
||||||
import { produce } from 'immer'
|
import { produce } from 'immer'
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ export interface PostInfo {
|
||||||
createdAt: Temporal.Instant
|
createdAt: Temporal.Instant
|
||||||
media: PostMedia[]
|
media: PostMedia[]
|
||||||
possibleReactions: string[]
|
possibleReactions: string[]
|
||||||
|
comments: PostComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReactionMap = Record<string, PostReaction[]>
|
type ReactionMap = Record<string, PostReaction[]>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue