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:
Innei
2025-11-26 23:23:09 +08:00
parent 630c95aa08
commit 2a95dcecae
17 changed files with 721 additions and 200 deletions

View File

@@ -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,
}),
})
},
}

View File

@@ -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> {

View File

@@ -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')}

View File

@@ -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'

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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>
)
}

View 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>
}

View 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>
)
}

View 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>
)
}

View File

@@ -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])

View File

@@ -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>
)
}

View File

@@ -37,3 +37,6 @@ body {
background: theme(colors.zinc.100);
}
}
@source inline('i-simple-icons-github');
@source inline('i-simple-icons-google');

View File

@@ -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);
}
}

View File

@@ -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,
}
}

View File

@@ -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",

View File

@@ -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日",