Compare commits
No commits in common. "main" and "v1.26.4" have entirely different histories.
10 changed files with 34 additions and 226 deletions
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "femto-webapp",
|
"name": "femto-webapp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.26.6",
|
"version": "1.26.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
@ -9,7 +9,7 @@ export interface paths {
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
After?: string
|
From?: string
|
||||||
Amount?: number
|
Amount?: number
|
||||||
AuthorId?: string
|
AuthorId?: string
|
||||||
Author?: string
|
Author?: string
|
||||||
|
@ -192,47 +192,6 @@ 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
|
||||||
|
@ -682,11 +641,6 @@ 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
|
||||||
}
|
}
|
||||||
|
@ -755,12 +709,6 @@ 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 */
|
||||||
|
@ -771,7 +719,6 @@ 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 */
|
||||||
|
|
|
@ -1,58 +0,0 @@
|
||||||
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,67 +1,31 @@
|
||||||
import { PostComment, PostReaction } from '../posts/posts.ts'
|
import { PostReaction } from '../posts/posts.ts'
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
interface PostTimelineProps {
|
interface PostTimelineProps {
|
||||||
reactions: PostReaction[]
|
reactions: PostReaction[]
|
||||||
comments: PostComment[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostTimeline({ reactions, comments }: PostTimelineProps) {
|
export function PostTimeline({ reactions }: 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 (
|
return (
|
||||||
<div className={`flex flex-col gap-4 mb-4 px-4`}>{items.map((item) => item.component)}</div>
|
<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`}>
|
||||||
function ReactionItem({ reaction }: { reaction: PostReaction }) {
|
{Temporal.Now.instant().toLocaleString('en-AU', {
|
||||||
return (
|
year: 'numeric',
|
||||||
<div className={`flex flex-col`}>
|
month: 'numeric',
|
||||||
<span className={`text-gray-400 text-xs -mb-0.5`}>{formatItemDate(reaction.reactedOn)}</span>
|
day: 'numeric',
|
||||||
<div className={`flex flex-row items-baseline text-gray-400`}>
|
hour: '2-digit',
|
||||||
<span>@{reaction.authorName}</span>
|
minute: '2-digit',
|
||||||
<span>clicked</span>
|
})}
|
||||||
<span>{reaction.emoji}</span>
|
</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-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 { 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 { useCallback, useEffect, useState } from 'react'
|
import { useEffect } 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,7 +11,6 @@ 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
|
||||||
|
@ -25,15 +24,11 @@ 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] : []) ?? []
|
||||||
|
|
||||||
const loadPost = useCallback(() => {
|
useEffect(() => {
|
||||||
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
|
||||||
|
@ -51,22 +46,6 @@ 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={
|
||||||
|
@ -86,8 +65,7 @@ export default function PostPage({ postsService }: PostPageProps) {
|
||||||
clearReaction={onClearReaction}
|
clearReaction={onClearReaction}
|
||||||
hideViewButton={true}
|
hideViewButton={true}
|
||||||
/>
|
/>
|
||||||
<PostTimeline reactions={reactions} comments={post.comments} />
|
<PostTimeline reactions={reactions} />
|
||||||
<NewCommentWidget onSubmit={onSubmitComment} isSubmitting={isSubmittingComment} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -8,12 +8,6 @@ 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
|
||||||
|
|
||||||
|
@ -24,7 +18,6 @@ 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,
|
||||||
|
@ -32,9 +25,8 @@ 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
|
||||||
|
@ -43,7 +35,6 @@ 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 {
|
||||||
|
@ -55,7 +46,6 @@ 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,6 +1,5 @@
|
||||||
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) {}
|
||||||
|
@ -79,17 +78,6 @@ 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, PostComment, PostMedia, PostReaction } from './posts.ts'
|
import { Post, 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,7 +10,6 @@ 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[]>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx'
|
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
|
||||||
import Button from '../../../components/buttons/Button.tsx'
|
import Button from './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 '../../i18n/translations.ts'
|
import { useTranslations } from '../app/i18n/translations.ts'
|
||||||
|
|
||||||
interface NewPostWidgetProps {
|
interface NewPostWidgetProps {
|
||||||
onSubmit: (
|
onSubmit: (
|
Loading…
Add table
Add a link
Reference in a new issue