diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index f03c6b0..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 @@ -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 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 */ 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/PostTimeline.tsx b/src/app/feed/components/PostTimeline.tsx index 450d5e9..c9653f1 100644 --- a/src/app/feed/components/PostTimeline.tsx +++ b/src/app/feed/components/PostTimeline.tsx @@ -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: ( + + ), + })), + ...comments.map((comment) => ({ + timestamp: comment.postedOn, + component: ( + + ), + })), + ].toSorted((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp)) + return ( -
- {reactions.map((reaction) => ( -
-
- {reaction.reactedOn.toLocaleString('en-AU', { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} -
-
- @{reaction.authorName}  - did  - {reaction.emoji} -
-
- ))} +
{items.map((item) => item.component)}
+ ) +} + +function ReactionItem({ reaction }: { reaction: PostReaction }) { + return ( +
+ {formatItemDate(reaction.reactedOn)} +
+ @{reaction.authorName}  + clicked  + {reaction.emoji} +
) } + +function CommentItem({ comment }: { comment: PostComment }) { + return ( +
+
{formatItemDate(comment.postedOn)}
+
+ @{comment.author}  +
+
{comment.content}
+
+ ) +} + +function formatItemDate(date: Temporal.Instant) { + return date.toLocaleString('en-AU', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) +} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index edef521..b505d83 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -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' diff --git a/src/app/feed/pages/PostPage.tsx b/src/app/feed/pages/PostPage.tsx index d8929d3..b275b68 100644 --- a/src/app/feed/pages/PostPage.tsx +++ b/src/app/feed/pages/PostPage.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 ( - + +
)} diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index 7fe2547..837e2b8 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -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) })), ) } } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 11272e9..72e55f7 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -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 { + 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 { diff --git a/src/app/feed/posts/usePostViewModel.ts b/src/app/feed/posts/usePostViewModel.ts index 74004ef..2fb06bd 100644 --- a/src/app/feed/posts/usePostViewModel.ts +++ b/src/app/feed/posts/usePostViewModel.ts @@ -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