mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: enhance comments functionality with social sign-in and user management
- Introduced social sign-in options for comments, allowing users to authenticate via various providers. - Updated comments API to handle user data, including fetching user information and managing comment relations. - Refactored comment components to improve user experience, including a new user avatar component and enhanced comment input handling. - Added loading states and error handling for comment submissions and reactions. - Implemented a relative time display for comment timestamps to improve readability. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -13,8 +13,35 @@ export interface SessionPayload {
|
||||
tenant?: unknown
|
||||
}
|
||||
|
||||
export interface SocialProvider {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
callbackPath: string
|
||||
}
|
||||
|
||||
export interface SocialProvidersResponse {
|
||||
providers: SocialProvider[]
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
async getSession(): Promise<SessionPayload | null> {
|
||||
return await apiFetch<SessionPayload | null>('/api/auth/session')
|
||||
},
|
||||
async getSocialProviders(): Promise<SocialProvidersResponse> {
|
||||
return await apiFetch<SocialProvidersResponse>('/api/auth/social/providers')
|
||||
},
|
||||
async signInSocial(provider: string): Promise<{ url: string }> {
|
||||
return await apiFetch<{ url: string }>('/api/auth/social', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
disableRedirect: true,
|
||||
callbackURL: window.location.href,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,11 +28,25 @@ export interface Comment {
|
||||
viewerReactions: string[]
|
||||
}
|
||||
|
||||
export interface CommentUser {
|
||||
id: string
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
export interface CommentListResult {
|
||||
items: Comment[]
|
||||
comments: Comment[]
|
||||
relations: Record<string, Comment>
|
||||
users: Record<string, CommentUser>
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface CreateCommentResult {
|
||||
comments: Comment[]
|
||||
relations: Record<string, Comment>
|
||||
users: Record<string, CommentUser>
|
||||
}
|
||||
|
||||
export interface CreateCommentInput {
|
||||
photoId: string
|
||||
content: string
|
||||
@@ -44,6 +58,25 @@ export interface ToggleReactionInput {
|
||||
reaction: string
|
||||
}
|
||||
|
||||
interface CommentUserDto {
|
||||
id: string
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
interface CommentListResponseDto {
|
||||
comments: CommentDto[]
|
||||
relations: Record<string, CommentDto>
|
||||
users: Record<string, CommentUserDto>
|
||||
next_cursor: string | null
|
||||
}
|
||||
|
||||
interface CreateCommentResponseDto {
|
||||
comments: CommentDto[]
|
||||
relations: Record<string, CommentDto>
|
||||
users: Record<string, CommentUserDto>
|
||||
}
|
||||
|
||||
function mapComment(dto: CommentDto): Comment {
|
||||
return {
|
||||
id: dto.id,
|
||||
@@ -59,6 +92,14 @@ function mapComment(dto: CommentDto): Comment {
|
||||
}
|
||||
}
|
||||
|
||||
function mapRelations(relations: Record<string, CommentDto>): Record<string, Comment> {
|
||||
const result: Record<string, Comment> = {}
|
||||
for (const [key, dto] of Object.entries(relations)) {
|
||||
result[key] = mapComment(dto)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const commentsApi = {
|
||||
async list(photoId: string, cursor?: string | null, limit = 20): Promise<CommentListResult> {
|
||||
const params = new URLSearchParams({
|
||||
@@ -67,17 +108,17 @@ export const commentsApi = {
|
||||
})
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
|
||||
const data = await apiFetch<{ items: CommentDto[]; next_cursor: string | null }>(
|
||||
`/api/comments?${params.toString()}`,
|
||||
)
|
||||
const data = await apiFetch<CommentListResponseDto>(`/api/comments?${params.toString()}`)
|
||||
return {
|
||||
items: data.items.map(mapComment),
|
||||
comments: data.comments.map(mapComment),
|
||||
relations: mapRelations(data.relations),
|
||||
users: data.users,
|
||||
nextCursor: data.next_cursor ?? null,
|
||||
}
|
||||
},
|
||||
|
||||
async create(input: CreateCommentInput): Promise<Comment> {
|
||||
const data = await apiFetch<{ item: CommentDto }>('/api/comments', {
|
||||
async create(input: CreateCommentInput): Promise<CreateCommentResult> {
|
||||
const data = await apiFetch<CreateCommentResponseDto>('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -86,7 +127,11 @@ export const commentsApi = {
|
||||
parentId: input.parentId ?? undefined,
|
||||
}),
|
||||
})
|
||||
return mapComment(data.item)
|
||||
return {
|
||||
comments: data.comments.map(mapComment),
|
||||
relations: mapRelations(data.relations),
|
||||
users: data.users,
|
||||
}
|
||||
},
|
||||
|
||||
async toggleReaction(input: ToggleReactionInput): Promise<Comment> {
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import clsx from 'clsx'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { Comment } from '~/lib/api/comments'
|
||||
|
||||
import { useCommentsContext } from './context'
|
||||
|
||||
interface CommentActionBarProps {
|
||||
reacted: boolean
|
||||
reactionCount: number
|
||||
onReply: () => void
|
||||
onToggleReaction: () => void
|
||||
comment: Comment
|
||||
}
|
||||
|
||||
export const CommentActionBar = ({ reacted, reactionCount, onReply, onToggleReaction }: CommentActionBarProps) => {
|
||||
export const CommentActionBar = ({ reacted, reactionCount, comment }: CommentActionBarProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { atoms, methods } = useCommentsContext()
|
||||
|
||||
const handleReaction = useCallback(() => {
|
||||
onToggleReaction()
|
||||
}, [onToggleReaction])
|
||||
methods.toggleReaction({ comment })
|
||||
}, [methods, comment])
|
||||
|
||||
const setReplyTo = useSetAtom(atoms.replyToAtom)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-xs text-white/60">
|
||||
<div className="-ml-2 flex items-center gap-4 text-xs text-white/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReaction}
|
||||
@@ -31,7 +40,7 @@ export const CommentActionBar = ({ reacted, reactionCount, onReply, onToggleReac
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-full px-2 py-1 hover:bg-white/10"
|
||||
onClick={onReply}
|
||||
onClick={() => setReplyTo(comment)}
|
||||
>
|
||||
<i className="i-mingcute-corner-down-right-line" />
|
||||
{t('comments.reply')}
|
||||
|
||||
@@ -1,55 +1,78 @@
|
||||
import { memo } from 'react'
|
||||
import { clsxm as cn } from '@afilmory/utils'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { selectAtom } from 'jotai/utils'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { Comment } from '~/lib/api/comments'
|
||||
import { sessionUserAtom } from '~/atoms/session'
|
||||
import type { Comment, CommentUser } from '~/lib/api/comments'
|
||||
import { jotaiStore } from '~/lib/jotai'
|
||||
|
||||
import { CommentActionBar } from './CommentActionBar'
|
||||
import { CommentContent } from './CommentContent'
|
||||
import { CommentHeader } from './CommentHeader'
|
||||
import { useCommentsContext } from './context'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
|
||||
interface CommentCardProps {
|
||||
interface CommentItemProps {
|
||||
comment: Comment
|
||||
parent: Comment | null
|
||||
reacted: boolean
|
||||
onReply: () => void
|
||||
onToggleReaction: () => void
|
||||
authorName: (comment: Comment) => string
|
||||
isNew?: boolean
|
||||
|
||||
locale: string
|
||||
|
||||
user?: CommentUser | null
|
||||
}
|
||||
|
||||
export const CommentCard = memo(
|
||||
({ comment, parent, reacted, onReply, onToggleReaction, authorName, locale }: CommentCardProps) => {
|
||||
return (
|
||||
<div
|
||||
className="border-accent/10 relative overflow-hidden rounded-2xl border bg-white/5 p-3 backdrop-blur-xl"
|
||||
style={{
|
||||
boxShadow:
|
||||
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-50"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(120deg, color-mix(in srgb, var(--color-accent) 7%, transparent), transparent 40%, color-mix(in srgb, var(--color-accent) 7%, transparent))',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-sm font-semibold text-white/80">
|
||||
{(authorName(comment) ?? '?').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<CommentHeader comment={comment} author={authorName(comment)} locale={locale} />
|
||||
<CommentContent comment={comment} parent={parent} authorName={authorName} />
|
||||
<CommentActionBar
|
||||
reacted={reacted}
|
||||
reactionCount={comment.reactionCounts.like ?? 0}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
/>
|
||||
</div>
|
||||
export const CommentItem = memo(({ comment, reacted, locale }: CommentItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { atoms } = useCommentsContext()
|
||||
|
||||
const userImage = useAtomValue(
|
||||
useMemo(
|
||||
() => selectAtom(atoms.usersAtom, (users) => users[comment.userId]?.image),
|
||||
[atoms.usersAtom, comment.userId],
|
||||
),
|
||||
)
|
||||
const userName = useAtomValue(
|
||||
useMemo(
|
||||
() => selectAtom(atoms.usersAtom, (users) => users[comment.userId]?.name),
|
||||
[atoms.usersAtom, comment.userId],
|
||||
),
|
||||
)
|
||||
const authorName = useCallback(
|
||||
(comment: Comment) => {
|
||||
const sessionUser = jotaiStore.get(sessionUserAtom)
|
||||
if (sessionUser?.id && comment.userId === sessionUser.id) {
|
||||
return t('comments.you')
|
||||
}
|
||||
|
||||
if (userName) {
|
||||
return userName
|
||||
}
|
||||
if (comment.userId) {
|
||||
return t('comments.user', { id: comment.userId.slice(-6) })
|
||||
}
|
||||
return t('comments.anonymous')
|
||||
},
|
||||
[t, userName],
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative py-2',
|
||||
// isNew && 'animate-highlight-new',
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 flex gap-3">
|
||||
<UserAvatar image={userImage} name={userName ?? comment.userId} fallback="?" size={36} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<CommentHeader comment={comment} author={authorName(comment)} locale={locale} />
|
||||
<CommentContent comment={comment} parentId={comment.parentId} authorName={authorName} />
|
||||
<CommentActionBar reacted={reacted} reactionCount={comment.reactionCounts.like ?? 0} comment={comment} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
CommentCard.displayName = 'CommentCard'
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CommentItem.displayName = 'CommentCard'
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { selectAtom } from 'jotai/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
import type { Comment } from '~/lib/api/comments'
|
||||
|
||||
import { useCommentsContext } from './context'
|
||||
|
||||
interface CommentContentProps {
|
||||
comment: Comment
|
||||
parent: Comment | null
|
||||
parentId: string | null
|
||||
authorName: (comment: Comment) => string
|
||||
}
|
||||
|
||||
export const CommentContent = ({ comment, parent, authorName }: CommentContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
export const CommentContent = ({ comment, parentId, authorName }: CommentContentProps) => {
|
||||
const { atoms } = useCommentsContext()
|
||||
const parent = useAtomValue(
|
||||
useMemo(
|
||||
() => selectAtom(atoms.relationsAtom, (relations) => (parentId ? relations[parentId] : null)),
|
||||
[atoms.relationsAtom, parentId],
|
||||
),
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{parent ? (
|
||||
<div className="rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-xs text-white/70">
|
||||
<div className="mb-1 flex items-center gap-2 text-[11px] tracking-wide text-white/40 uppercase">
|
||||
<i className="i-mingcute-corner-down-right-line" />
|
||||
{t('comments.replyingTo', { user: authorName(parent) })}
|
||||
<div className="mb-1 flex items-center text-[11px] tracking-wide text-white/40 uppercase">
|
||||
<i className="i-lucide-reply mr-2" />
|
||||
|
||||
<Trans
|
||||
i18nKey="comments.replyingTo"
|
||||
components={{ strong: <b className="ml-1 font-medium" /> }}
|
||||
values={{ user: authorName(parent) }}
|
||||
/>
|
||||
</div>
|
||||
<p className="line-clamp-3 text-sm leading-relaxed text-white/70">{parent.content}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,14 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { Comment } from '~/lib/api/comments'
|
||||
|
||||
import { formatRelativeTime } from './format'
|
||||
import { RelativeTime } from './RelativeTime'
|
||||
|
||||
export const CommentHeader = ({ comment, author, locale }: { comment: Comment; author: string; locale: string }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-sm font-medium text-white/90">{author}</span>
|
||||
<span className="text-xs text-white/45">{formatRelativeTime(comment.createdAt, locale)}</span>
|
||||
<RelativeTime timestamp={comment.createdAt} locale={locale} className="text-xs text-white/45" />
|
||||
{comment.status === 'pending' && (
|
||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200/80 uppercase">
|
||||
{t('comments.pending')}
|
||||
|
||||
@@ -1,67 +1,98 @@
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { clsxm as cn } from '@afilmory/utils'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { selectAtom } from 'jotai/utils'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
import { sessionUserAtom } from '~/atoms/session'
|
||||
import type { Comment } from '~/lib/api/comments'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
|
||||
interface CommentInputProps {
|
||||
isMobile: boolean
|
||||
import { useCommentsContext } from './context'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
|
||||
replyTo: Comment | null
|
||||
setReplyTo: (comment: Comment | null) => void
|
||||
newComment: string
|
||||
setNewComment: (value: string) => void
|
||||
onSubmit: (content: string) => void
|
||||
}
|
||||
|
||||
export const CommentInput = ({
|
||||
isMobile,
|
||||
|
||||
replyTo,
|
||||
setReplyTo,
|
||||
newComment,
|
||||
setNewComment,
|
||||
onSubmit,
|
||||
}: CommentInputProps) => {
|
||||
export const CommentInput = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { atoms, methods } = useCommentsContext()
|
||||
const [newComment, setNewComment] = useAtom(atoms.newCommentAtom)
|
||||
const sessionUser = useAtomValue(sessionUserAtom)
|
||||
const submitError = useAtomValue(atoms.submitErrorAtom)
|
||||
const status = useAtomValue(atoms.statusAtom)
|
||||
|
||||
const [replyTo, setReplyTo] = useAtom(atoms.replyToAtom)
|
||||
const isMobile = useMobile()
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNewComment(e.target.value)
|
||||
if (submitError) {
|
||||
methods.clearSubmitError()
|
||||
}
|
||||
},
|
||||
[setNewComment, submitError, methods],
|
||||
)
|
||||
|
||||
const replyUserName = useAtomValue(
|
||||
useMemo(
|
||||
() => selectAtom(atoms.usersAtom, (users) => (replyTo?.userId ? users[replyTo.userId]?.name : null)),
|
||||
[atoms.usersAtom, replyTo?.userId],
|
||||
),
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(newComment)
|
||||
methods.submit(newComment)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-accent/10 shrink-0 border-t p-4">
|
||||
{replyTo ? (
|
||||
<div className="border-accent/20 bg-accent/5 mb-3 flex items-center justify-between rounded-lg border px-3 py-2 text-xs text-white/80">
|
||||
{submitError && (
|
||||
<div className="animate-shake mb-3 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||
<i className="i-lucide-alert-circle shrink-0" />
|
||||
<span>{t(submitError.message as any)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto text-red-400/60 transition hover:text-red-400"
|
||||
onClick={() => methods.clearSubmitError()}
|
||||
>
|
||||
<i className="i-lucide-x" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replyTo && !submitError ? (
|
||||
<div className="border-accent/20 bg-accent/5 mb-3 flex items-center justify-between rounded-lg border px-3 py-2 text-xs text-white/80 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="i-mingcute-reply-line text-accent" />
|
||||
<i className="i-lucide-reply text-accent" />
|
||||
<span>
|
||||
{t('comments.replyingTo', {
|
||||
user: replyTo.userId.slice(-6),
|
||||
})}
|
||||
<Trans
|
||||
i18nKey="comments.replyingTo"
|
||||
components={{ strong: <b className="font-medium" /> }}
|
||||
values={{ user: replyUserName }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="text-white/50 transition hover:text-white" onClick={() => setReplyTo(null)}>
|
||||
{t('comments.cancelReply')}
|
||||
<i className="i-lucide-x" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-sm font-semibold text-white/80">
|
||||
{(sessionUser?.name || sessionUser?.id || 'G')[0]}
|
||||
</div>
|
||||
<UserAvatar image={sessionUser?.image} name={sessionUser?.name || sessionUser?.id} fallback="G" size={36} />
|
||||
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('comments.placeholder')}
|
||||
rows={isMobile ? 2 : 1}
|
||||
className="bg-material-medium focus:ring-accent/50 w-full resize-none rounded-lg border-0 px-3 py-2 text-sm text-white placeholder:text-white/40 focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={status.isLoading}
|
||||
className={cn(
|
||||
'bg-material-medium w-full resize-none rounded-lg border px-3 py-2 text-sm text-white transition-colors placeholder:text-white/40 focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60',
|
||||
submitError
|
||||
? 'border-red-500/50 focus:border-red-500/50 focus:ring-red-500/50'
|
||||
: 'focus:ring-accent/50 focus:border-accent/50 border-transparent',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
@@ -73,14 +104,20 @@ export const CommentInput = ({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newComment.trim()}
|
||||
className="bg-accent shadow-accent/20 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-lg transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!newComment.trim() || status.isLoading}
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-lg transition disabled:cursor-not-allowed disabled:opacity-40',
|
||||
status.isLoading ? 'bg-accent/50' : 'bg-accent shadow-accent/20',
|
||||
)}
|
||||
>
|
||||
<i className="i-mingcute-send-line" />
|
||||
{status.isLoading ? (
|
||||
<i className="i-mingcute-loading-line animate-spin" />
|
||||
) : (
|
||||
<i className="i-mingcute-send-line" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-2 text-xs text-white/40">{t('comments.hint')}</p>
|
||||
{!sessionUser && <p className="mt-1 text-xs text-white/50">{t('comments.loginRequired')}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
35
apps/web/src/modules/social/comments/RelativeTime.tsx
Normal file
35
apps/web/src/modules/social/comments/RelativeTime.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { formatRelativeTime } from './format'
|
||||
|
||||
interface RelativeTimeProps {
|
||||
timestamp: string
|
||||
locale: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const RelativeTime = ({ timestamp, locale, className }: RelativeTimeProps) => {
|
||||
const [relativeTime, setRelativeTime] = useState(() => formatRelativeTime(timestamp, locale))
|
||||
|
||||
useEffect(() => {
|
||||
const updateRelativeTime = () => {
|
||||
setRelativeTime(formatRelativeTime(timestamp, locale))
|
||||
}
|
||||
|
||||
const getUpdateInterval = () => {
|
||||
const diffMs = Date.now() - new Date(timestamp).getTime()
|
||||
const diffMinutes = diffMs / 60000
|
||||
|
||||
if (diffMinutes < 1) return 10000 // Update every 10 seconds for the first minute
|
||||
if (diffMinutes < 60) return 60000 // Update every minute for the first hour
|
||||
if (diffMinutes < 1440) return 300000 // Update every 5 minutes for the first day
|
||||
return 3600000 // Update every hour after that
|
||||
}
|
||||
|
||||
const interval = setInterval(updateRelativeTime, getUpdateInterval())
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [timestamp, locale])
|
||||
|
||||
return <span className={className}>{relativeTime}</span>
|
||||
}
|
||||
43
apps/web/src/modules/social/comments/SignInPanel.tsx
Normal file
43
apps/web/src/modules/social/comments/SignInPanel.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { clsxm as cn } from '@afilmory/utils'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { authApi } from '~/lib/api/auth'
|
||||
|
||||
export const SignInPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: socialProviders } = useQuery({
|
||||
queryKey: ['socialProviders'],
|
||||
queryFn: authApi.getSocialProviders,
|
||||
})
|
||||
|
||||
const handleSignIn = async (provider: string) => {
|
||||
try {
|
||||
const { url } = await authApi.signInSocial(provider)
|
||||
window.location.href = url
|
||||
} catch (error) {
|
||||
console.error('Sign in failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-accent/10 flex items-center justify-between gap-3 border-t p-4">
|
||||
<span className="shrink-0 text-xs text-white/50">{t('comments.chooseProvider')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{socialProviders?.providers.map((provider) => (
|
||||
<button
|
||||
type="button"
|
||||
key={provider.id}
|
||||
onClick={() => handleSignIn(provider.id)}
|
||||
className="bg-material-medium hover:bg-material-light flex h-9 w-9 items-center justify-center rounded-full border border-white/10 text-white transition"
|
||||
aria-label={t('comments.signInWith', { provider: provider.name })}
|
||||
>
|
||||
<i className={cn(provider.icon, 'text-base')} />
|
||||
<span className="sr-only">{provider.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
apps/web/src/modules/social/comments/UserAvatar.tsx
Normal file
30
apps/web/src/modules/social/comments/UserAvatar.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { clsxm as cn } from '@afilmory/utils'
|
||||
|
||||
interface UserAvatarProps {
|
||||
image?: string | null
|
||||
name?: string | null
|
||||
fallback?: string
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const UserAvatar = ({ image, name, fallback = '?', size = 36, className }: UserAvatarProps) => {
|
||||
const displayName = name || fallback
|
||||
const initial = displayName.slice(0, 1).toUpperCase()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-full bg-white/10 text-sm font-semibold text-white/80',
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{image ? (
|
||||
<img src={image} alt={displayName} className="h-full w-full rounded-full object-cover" />
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { produce } from 'immer'
|
||||
import type { PrimitiveAtom } from 'jotai'
|
||||
import { atom } from 'jotai'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { createContext, use, useEffect, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { Comment } from '~/lib/api/comments'
|
||||
import type { Comment, CommentUser } from '~/lib/api/comments'
|
||||
import { commentsApi } from '~/lib/api/comments'
|
||||
import { jotaiStore } from '~/lib/jotai'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export interface SubmitError {
|
||||
type: 'auth' | 'general'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CommentsAtoms {
|
||||
commentsAtom: PrimitiveAtom<Comment[]>
|
||||
relationsAtom: PrimitiveAtom<Record<string, Comment>>
|
||||
usersAtom: PrimitiveAtom<Record<string, CommentUser>>
|
||||
newCommentAtom: PrimitiveAtom<string>
|
||||
replyToAtom: PrimitiveAtom<Comment | null>
|
||||
statusAtom: PrimitiveAtom<{
|
||||
@@ -22,12 +28,15 @@ export interface CommentsAtoms {
|
||||
isLoadingMore: boolean
|
||||
nextCursor: string | null
|
||||
}>
|
||||
submitErrorAtom: PrimitiveAtom<SubmitError | null>
|
||||
lastSubmittedCommentIdAtom: PrimitiveAtom<string | null>
|
||||
}
|
||||
|
||||
export interface CommentsMethods {
|
||||
submit: (content: string) => Promise<void>
|
||||
loadMore: () => Promise<void>
|
||||
toggleReaction: (params: { comment: Comment; reaction?: string }) => Promise<void>
|
||||
clearSubmitError: () => void
|
||||
}
|
||||
|
||||
export interface CommentsContextValue {
|
||||
@@ -39,6 +48,8 @@ const CommentsContext = createContext<CommentsContextValue | null>(null)
|
||||
|
||||
function createCommentsContext(photoId: string): { atoms: CommentsAtoms; methods: CommentsMethods } {
|
||||
const commentsAtom = atom<Comment[]>([])
|
||||
const relationsAtom = atom<Record<string, Comment>>({})
|
||||
const usersAtom = atom<Record<string, CommentUser>>({})
|
||||
const newCommentAtom = atom<string>('')
|
||||
const replyToAtom = atom<Comment | null>(null)
|
||||
const statusAtom = atom({
|
||||
@@ -47,90 +58,166 @@ function createCommentsContext(photoId: string): { atoms: CommentsAtoms; methods
|
||||
isLoadingMore: false,
|
||||
nextCursor: null as string | null,
|
||||
})
|
||||
const submitErrorAtom = atom<SubmitError | null>(null)
|
||||
const lastSubmittedCommentIdAtom = atom<string | null>(null)
|
||||
|
||||
const atoms: CommentsAtoms = {
|
||||
commentsAtom,
|
||||
relationsAtom,
|
||||
usersAtom,
|
||||
newCommentAtom,
|
||||
replyToAtom,
|
||||
statusAtom,
|
||||
submitErrorAtom,
|
||||
lastSubmittedCommentIdAtom,
|
||||
}
|
||||
|
||||
const submit = async (content: string) => {
|
||||
const replyTo = jotaiStore.get(replyToAtom)
|
||||
jotaiStore.set(submitErrorAtom, null)
|
||||
try {
|
||||
jotaiStore.set(statusAtom, (prev) => ({ ...prev, isLoading: true }))
|
||||
const comment = await commentsApi.create({
|
||||
jotaiStore.set(
|
||||
statusAtom,
|
||||
produce((draft) => {
|
||||
draft.isLoading = true
|
||||
}),
|
||||
)
|
||||
const result = await commentsApi.create({
|
||||
photoId,
|
||||
content: content.trim(),
|
||||
parentId: replyTo?.id ?? null,
|
||||
})
|
||||
jotaiStore.set(commentsAtom, (prev) => [comment, ...prev])
|
||||
const newComment = result.comments[0]
|
||||
jotaiStore.set(
|
||||
commentsAtom,
|
||||
produce((draft) => {
|
||||
draft.unshift(newComment)
|
||||
}),
|
||||
)
|
||||
jotaiStore.set(
|
||||
relationsAtom,
|
||||
produce((draft) => {
|
||||
Object.assign(draft, result.relations)
|
||||
}),
|
||||
)
|
||||
jotaiStore.set(
|
||||
usersAtom,
|
||||
produce((draft) => {
|
||||
Object.assign(draft, result.users)
|
||||
}),
|
||||
)
|
||||
jotaiStore.set(newCommentAtom, '')
|
||||
jotaiStore.set(replyToAtom, null)
|
||||
toast.success(i18next.t('comments.posted'))
|
||||
jotaiStore.set(lastSubmittedCommentIdAtom, newComment.id)
|
||||
setTimeout(() => {
|
||||
jotaiStore.set(lastSubmittedCommentIdAtom, null)
|
||||
}, 2000)
|
||||
} catch (error: any) {
|
||||
if (error?.status === 401) {
|
||||
toast.error(i18next.t('comments.loginRequired'))
|
||||
jotaiStore.set(submitErrorAtom, { type: 'auth', message: 'comments.loginRequired' })
|
||||
} else {
|
||||
toast.error(i18next.t('comments.postFailed'))
|
||||
jotaiStore.set(submitErrorAtom, { type: 'general', message: 'comments.postFailed' })
|
||||
}
|
||||
} finally {
|
||||
jotaiStore.set(statusAtom, (prev) => ({ ...prev, isLoading: false }))
|
||||
jotaiStore.set(
|
||||
statusAtom,
|
||||
produce((draft) => {
|
||||
draft.isLoading = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
const status = jotaiStore.get(statusAtom)
|
||||
if (status.isLoadingMore || !status.nextCursor) return
|
||||
jotaiStore.set(statusAtom, { ...status, isLoadingMore: true })
|
||||
jotaiStore.set(
|
||||
statusAtom,
|
||||
produce((draft) => {
|
||||
draft.isLoadingMore = true
|
||||
}),
|
||||
)
|
||||
try {
|
||||
const result = await commentsApi.list(photoId, status.nextCursor, PAGE_SIZE)
|
||||
jotaiStore.set(commentsAtom, (prev) => [...prev, ...result.items])
|
||||
jotaiStore.set(statusAtom, (prev) => ({ ...prev, nextCursor: result.nextCursor, isLoadingMore: false }))
|
||||
jotaiStore.set(
|
||||
commentsAtom,
|
||||
produce((draft) => {
|
||||
draft.push(...result.comments)
|
||||
}),
|
||||
)
|
||||
jotaiStore.set(
|
||||
relationsAtom,
|
||||
produce((draft) => {
|
||||
Object.assign(draft, result.relations)
|
||||
}),
|
||||
)
|
||||
jotaiStore.set(
|
||||
usersAtom,
|
||||
produce((draft) => {
|
||||
Object.assign(draft, result.users)
|
||||
}),
|
||||
)
|
||||
jotaiStore.set(
|
||||
statusAtom,
|
||||
produce((draft) => {
|
||||
draft.nextCursor = result.nextCursor
|
||||
draft.isLoadingMore = false
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
jotaiStore.set(statusAtom, (prev) => ({ ...prev, isLoadingMore: false, isError: true }))
|
||||
jotaiStore.set(
|
||||
statusAtom,
|
||||
produce((draft) => {
|
||||
draft.isLoadingMore = false
|
||||
draft.isError = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleReaction = async ({ comment, reaction = 'like' }: { comment: Comment; reaction?: string }) => {
|
||||
const isActive = comment.viewerReactions.includes(reaction)
|
||||
jotaiStore.set(commentsAtom, (prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== comment.id) return item
|
||||
const counts = { ...item.reactionCounts }
|
||||
counts[reaction] = Math.max(0, (counts[reaction] ?? 0) + (isActive ? -1 : 1))
|
||||
const viewerReactions = isActive
|
||||
? item.viewerReactions.filter((r) => r !== reaction)
|
||||
: [...item.viewerReactions, reaction]
|
||||
return { ...item, reactionCounts: counts, viewerReactions }
|
||||
jotaiStore.set(
|
||||
commentsAtom,
|
||||
produce((draft) => {
|
||||
const item = draft.find((c) => c.id === comment.id)
|
||||
if (!item) return
|
||||
item.reactionCounts[reaction] = Math.max(0, (item.reactionCounts[reaction] ?? 0) + (isActive ? -1 : 1))
|
||||
if (isActive) {
|
||||
item.viewerReactions = item.viewerReactions.filter((r) => r !== reaction)
|
||||
} else {
|
||||
item.viewerReactions.push(reaction)
|
||||
}
|
||||
}),
|
||||
)
|
||||
try {
|
||||
await commentsApi.toggleReaction({ commentId: comment.id, reaction })
|
||||
} catch (error: any) {
|
||||
jotaiStore.set(commentsAtom, (prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== comment.id) return item
|
||||
const counts = { ...item.reactionCounts }
|
||||
counts[reaction] = Math.max(0, (counts[reaction] ?? 0) + (isActive ? 1 : -1))
|
||||
const viewerReactions = isActive
|
||||
? [...item.viewerReactions, reaction]
|
||||
: item.viewerReactions.filter((r) => r !== reaction)
|
||||
return { ...item, reactionCounts: counts, viewerReactions }
|
||||
} catch {
|
||||
jotaiStore.set(
|
||||
commentsAtom,
|
||||
produce((draft) => {
|
||||
const item = draft.find((c) => c.id === comment.id)
|
||||
if (!item) return
|
||||
item.reactionCounts[reaction] = Math.max(0, (item.reactionCounts[reaction] ?? 0) + (isActive ? 1 : -1))
|
||||
if (isActive) {
|
||||
item.viewerReactions.push(reaction)
|
||||
} else {
|
||||
item.viewerReactions = item.viewerReactions.filter((r) => r !== reaction)
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (error?.status === 401) {
|
||||
toast.error(i18next.t('comments.loginRequired'))
|
||||
} else {
|
||||
toast.error(i18next.t('comments.reactionFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearSubmitError = () => {
|
||||
jotaiStore.set(submitErrorAtom, null)
|
||||
}
|
||||
|
||||
const methods: CommentsMethods = {
|
||||
submit,
|
||||
loadMore,
|
||||
toggleReaction,
|
||||
clearSubmitError,
|
||||
}
|
||||
|
||||
return { atoms, methods }
|
||||
@@ -157,27 +244,51 @@ export function CommentsProvider({ photoId, children }: PropsWithChildren<{ phot
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
jotaiStore.set(atoms.statusAtom, (prev) => ({
|
||||
...prev,
|
||||
isLoading: commentsQuery.isLoading,
|
||||
isLoadingMore: commentsQuery.isFetchingNextPage,
|
||||
}))
|
||||
jotaiStore.set(
|
||||
atoms.statusAtom,
|
||||
produce((draft) => {
|
||||
draft.isLoading = commentsQuery.isLoading
|
||||
draft.isLoadingMore = commentsQuery.isFetchingNextPage
|
||||
}),
|
||||
)
|
||||
}, [atoms.statusAtom, commentsQuery.isFetchingNextPage, commentsQuery.isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (commentsQuery.data) {
|
||||
jotaiStore.set(
|
||||
atoms.commentsAtom,
|
||||
commentsQuery.data.pages.flatMap((page) => page.items),
|
||||
commentsQuery.data.pages.flatMap((page) => page.comments),
|
||||
)
|
||||
// Merge all relations and users from all pages
|
||||
const allRelations: Record<string, Comment> = {}
|
||||
const allUsers: Record<string, CommentUser> = {}
|
||||
for (const page of commentsQuery.data.pages) {
|
||||
Object.assign(allRelations, page.relations)
|
||||
Object.assign(allUsers, page.users)
|
||||
}
|
||||
jotaiStore.set(atoms.relationsAtom, allRelations)
|
||||
jotaiStore.set(atoms.usersAtom, allUsers)
|
||||
const nextCursor = commentsQuery.data.pages.at(-1)?.nextCursor ?? null
|
||||
jotaiStore.set(atoms.statusAtom, (prev) => ({ ...prev, isLoading: false, isError: false, nextCursor }))
|
||||
jotaiStore.set(
|
||||
atoms.statusAtom,
|
||||
produce((draft) => {
|
||||
draft.isLoading = false
|
||||
draft.isError = false
|
||||
draft.nextCursor = nextCursor
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, [atoms.commentsAtom, atoms.statusAtom, commentsQuery.data])
|
||||
}, [atoms.commentsAtom, atoms.relationsAtom, atoms.usersAtom, atoms.statusAtom, commentsQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (commentsQuery.isError) {
|
||||
jotaiStore.set(atoms.statusAtom, (prev) => ({ ...prev, isLoading: false, isError: true }))
|
||||
jotaiStore.set(
|
||||
atoms.statusAtom,
|
||||
produce((draft) => {
|
||||
draft.isLoading = false
|
||||
draft.isError = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, [atoms.statusAtom, commentsQuery.isError])
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { sessionUserAtom } from '~/atoms/session'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import type { Comment } from '~/lib/api/comments'
|
||||
|
||||
import { CommentCard } from './CommentCard'
|
||||
import { CommentItem } from './CommentCard'
|
||||
import { CommentInput } from './CommentInput'
|
||||
import { CommentsProvider, useCommentsContext } from './context'
|
||||
import { EmptyState } from './EmptyState'
|
||||
import { ErrorBox } from './ErrorBox'
|
||||
import { SignInPanel } from './SignInPanel'
|
||||
import { SkeletonList } from './SkeletonList'
|
||||
|
||||
export const CommentsPanel: FC<{ photoId: string; visible?: boolean }> = ({ photoId }) => {
|
||||
@@ -24,29 +23,19 @@ export const CommentsPanel: FC<{ photoId: string; visible?: boolean }> = ({ phot
|
||||
|
||||
const CommentsContent: FC = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const isMobile = useMobile()
|
||||
|
||||
const { atoms, methods } = useCommentsContext()
|
||||
const [comments] = useAtom(atoms.commentsAtom)
|
||||
const comments = useAtomValue(atoms.commentsAtom)
|
||||
|
||||
const [status] = useAtom(atoms.statusAtom)
|
||||
const [replyTo, setReplyTo] = useAtom(atoms.replyToAtom)
|
||||
const [newComment, setNewComment] = useAtom(atoms.newCommentAtom)
|
||||
const lastSubmittedCommentId = useAtomValue(atoms.lastSubmittedCommentIdAtom)
|
||||
|
||||
const sessionUser = useAtomValue(sessionUserAtom)
|
||||
|
||||
const authorName = (comment: Comment) => {
|
||||
if (sessionUser?.id && comment.userId === sessionUser.id) {
|
||||
return t('comments.you')
|
||||
}
|
||||
if (comment.userId) {
|
||||
return t('comments.user', { id: comment.userId.slice(-6) })
|
||||
}
|
||||
return t('comments.anonymous')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-2 text-sm text-white/70">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="i-mingcute-comment-line" />
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<i className="i-mingcute-comment-line mr-2" />
|
||||
<span>{t('inspector.tab.comments')}</span>
|
||||
{comments.length > 0 && (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">{comments.length}</span>
|
||||
@@ -65,14 +54,11 @@ const CommentsContent: FC = () => {
|
||||
<EmptyState />
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<CommentCard
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
parent={comment.parentId ? (comments.find((c) => c.id === comment.parentId) ?? null) : null}
|
||||
reacted={comment.viewerReactions.includes('like')}
|
||||
onReply={() => setReplyTo(comment)}
|
||||
onToggleReaction={() => methods.toggleReaction({ comment })}
|
||||
authorName={authorName}
|
||||
isNew={comment.id === lastSubmittedCommentId}
|
||||
locale={i18n.language || 'en'}
|
||||
/>
|
||||
))
|
||||
@@ -92,14 +78,7 @@ const CommentsContent: FC = () => {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<CommentInput
|
||||
isMobile={isMobile}
|
||||
replyTo={replyTo}
|
||||
setReplyTo={setReplyTo}
|
||||
newComment={newComment}
|
||||
setNewComment={setNewComment}
|
||||
onSubmit={(content) => methods.submit(content)}
|
||||
/>
|
||||
{sessionUser ? <CommentInput /> : <SignInPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,3 +37,6 @@ body {
|
||||
background: theme(colors.zinc.100);
|
||||
}
|
||||
}
|
||||
|
||||
@source inline('i-simple-icons-github');
|
||||
@source inline('i-simple-icons-google');
|
||||
|
||||
@@ -203,3 +203,29 @@ html body {
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { commentReactions, comments, photoAssets } from '@afilmory/db'
|
||||
import { authUsers, commentReactions, comments, photoAssets } from '@afilmory/db'
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { getClientIp } from 'core/context/http-context.helper'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
@@ -23,6 +23,12 @@ export interface CommentViewModel {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UserViewModel {
|
||||
id: string
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
interface ViewerContext {
|
||||
userId: string | null
|
||||
role?: string
|
||||
@@ -40,7 +46,14 @@ export class CommentService {
|
||||
@inject(COMMENT_MODERATION_HOOK) private readonly moderationHook: CommentModerationHook,
|
||||
) {}
|
||||
|
||||
async createComment(dto: CreateCommentDto, context: Context): Promise<{ item: CommentResponseItem }> {
|
||||
async createComment(
|
||||
dto: CreateCommentDto,
|
||||
context: Context,
|
||||
): Promise<{
|
||||
comments: CommentResponseItem[]
|
||||
relations: Record<string, CommentResponseItem>
|
||||
users: Record<string, UserViewModel>
|
||||
}> {
|
||||
const tenant = requireTenantContext()
|
||||
const auth = this.requireAuth()
|
||||
const db = this.dbAccessor.get()
|
||||
@@ -87,12 +100,67 @@ export class CommentService {
|
||||
viewerReactions: [],
|
||||
})
|
||||
|
||||
return { item }
|
||||
// Fetch relations (parent comment if exists)
|
||||
const relations: Record<string, CommentResponseItem> = {}
|
||||
if (parent) {
|
||||
const [fullParent] = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
photoId: comments.photoId,
|
||||
parentId: comments.parentId,
|
||||
userId: comments.userId,
|
||||
content: comments.content,
|
||||
status: comments.status,
|
||||
createdAt: comments.createdAt,
|
||||
updatedAt: comments.updatedAt,
|
||||
})
|
||||
.from(comments)
|
||||
.where(eq(comments.id, parent.id))
|
||||
.limit(1)
|
||||
|
||||
if (fullParent) {
|
||||
const parentReactions = await this.fetchReactionAggregations(tenant.tenant.id, [parent.id], auth.userId)
|
||||
relations[parent.id] = this.toResponse({
|
||||
...fullParent,
|
||||
reactionCounts: parentReactions.counts.get(parent.id) ?? {},
|
||||
viewerReactions: parentReactions.viewer.get(parent.id) ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
const users: Record<string, UserViewModel> = {}
|
||||
const userIds = [auth.userId, ...Object.values(relations).map((r) => r.userId)].filter(Boolean)
|
||||
const uniqueUserIds = [...new Set(userIds)]
|
||||
|
||||
if (uniqueUserIds.length > 0) {
|
||||
const userRows = await db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
image: authUsers.image,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, uniqueUserIds))
|
||||
|
||||
for (const user of userRows) {
|
||||
users[user.id] = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { comments: [item], relations, users }
|
||||
}
|
||||
|
||||
async listComments(
|
||||
query: ListCommentsQueryDto,
|
||||
): Promise<{ items: CommentResponseItem[]; nextCursor: string | null }> {
|
||||
async listComments(query: ListCommentsQueryDto): Promise<{
|
||||
comments: CommentResponseItem[]
|
||||
relations: Record<string, CommentResponseItem>
|
||||
users: Record<string, UserViewModel>
|
||||
nextCursor: string | null
|
||||
}> {
|
||||
const tenant = requireTenantContext()
|
||||
const viewer = this.getViewer()
|
||||
const db = this.dbAccessor.get()
|
||||
@@ -151,14 +219,79 @@ export class CommentService {
|
||||
|
||||
const nextCursor = hasMore && items.length > 0 ? items.at(-1)!.id : null
|
||||
|
||||
const commentItems = items.map((item) =>
|
||||
this.toResponse({
|
||||
...item,
|
||||
reactionCounts: reactions.counts.get(item.id) ?? {},
|
||||
viewerReactions: reactions.viewer.get(item.id) ?? [],
|
||||
}),
|
||||
)
|
||||
|
||||
// Build relations map (parentId -> parent comment)
|
||||
const relations: Record<string, CommentResponseItem> = {}
|
||||
const parentIds = [...new Set(items.filter((item) => item.parentId).map((item) => item.parentId!))]
|
||||
|
||||
if (parentIds.length > 0) {
|
||||
const parentRows = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
photoId: comments.photoId,
|
||||
parentId: comments.parentId,
|
||||
userId: comments.userId,
|
||||
content: comments.content,
|
||||
status: comments.status,
|
||||
createdAt: comments.createdAt,
|
||||
updatedAt: comments.updatedAt,
|
||||
})
|
||||
.from(comments)
|
||||
.where(
|
||||
and(eq(comments.tenantId, tenant.tenant.id), inArray(comments.id, parentIds), isNull(comments.deletedAt)),
|
||||
)
|
||||
|
||||
const parentReactions = await this.fetchReactionAggregations(
|
||||
tenant.tenant.id,
|
||||
parentRows.map((p) => p.id),
|
||||
viewer.userId,
|
||||
)
|
||||
|
||||
for (const parent of parentRows) {
|
||||
relations[parent.id] = this.toResponse({
|
||||
...parent,
|
||||
reactionCounts: parentReactions.counts.get(parent.id) ?? {},
|
||||
viewerReactions: parentReactions.viewer.get(parent.id) ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build users map (userId -> user)
|
||||
const users: Record<string, UserViewModel> = {}
|
||||
const allUserIds = [
|
||||
...new Set([...items.map((item) => item.userId), ...Object.values(relations).map((r) => r.userId)]),
|
||||
]
|
||||
|
||||
if (allUserIds.length > 0) {
|
||||
const userRows = await db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
image: authUsers.image,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, allUserIds))
|
||||
|
||||
for (const user of userRows) {
|
||||
users[user.id] = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: items.map((item) =>
|
||||
this.toResponse({
|
||||
...item,
|
||||
reactionCounts: reactions.counts.get(item.id) ?? {},
|
||||
viewerReactions: reactions.viewer.get(item.id) ?? [],
|
||||
}),
|
||||
),
|
||||
comments: commentItems,
|
||||
relations,
|
||||
users,
|
||||
nextCursor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"action.view.title": "View",
|
||||
"comments.anonymous": "Guest",
|
||||
"comments.cancelReply": "Cancel",
|
||||
"comments.chooseProvider": "Choose sign in method",
|
||||
"comments.empty": "No comments yet. Be the first to comment!",
|
||||
"comments.error": "Failed to load comments",
|
||||
"comments.hint": "Press Enter to send, Shift+Enter for new line",
|
||||
@@ -58,10 +59,11 @@
|
||||
"comments.posted": "Comment posted",
|
||||
"comments.reactionFailed": "Failed to react",
|
||||
"comments.reply": "Reply",
|
||||
"comments.replyingTo": "Replying to {{user}}",
|
||||
"comments.replyingTo": "Replying to <strong>{{user}}</strong>",
|
||||
"comments.retry": "Retry",
|
||||
"comments.send": "Send",
|
||||
"comments.sending": "Sending...",
|
||||
"comments.signInWith": "Sign in with {{provider}}",
|
||||
"comments.user": "User {{id}}",
|
||||
"comments.you": "You",
|
||||
"date.day.1": "1st",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"action.view.title": "视图",
|
||||
"comments.anonymous": "访客",
|
||||
"comments.cancelReply": "取消回复",
|
||||
"comments.chooseProvider": "选择登录方式",
|
||||
"comments.empty": "暂无评论。快来发表第一条评论吧!",
|
||||
"comments.error": "评论加载失败",
|
||||
"comments.hint": "按 Enter 发送,Shift+Enter 换行",
|
||||
@@ -55,10 +56,11 @@
|
||||
"comments.posted": "评论已发布",
|
||||
"comments.reactionFailed": "操作失败,请稍后重试",
|
||||
"comments.reply": "回复",
|
||||
"comments.replyingTo": "正在回复 {{user}}",
|
||||
"comments.replyingTo": "正在回复 <strong>{{user}}</strong>",
|
||||
"comments.retry": "重试",
|
||||
"comments.send": "发送",
|
||||
"comments.sending": "发送中…",
|
||||
"comments.signInWith": "使用 {{provider}} 登录",
|
||||
"comments.user": "访客 {{id}}",
|
||||
"comments.you": "你",
|
||||
"date.day.1": "1日",
|
||||
|
||||
Reference in New Issue
Block a user