feat: add comment count API and integrate with InspectorPanel

- Implemented a new API endpoint to count comments for a specific photo.
- Updated the InspectorPanel to fetch and display the comment count using React Query.
- Enhanced the UI to indicate the presence of comments with visual cues.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-05 18:02:42 +08:00
parent a71025a65c
commit a4927c5240
5 changed files with 120 additions and 50 deletions

View File

@@ -136,6 +136,11 @@ export const commentsApi = {
}
},
async count(photoId: string): Promise<{ count: number }> {
const params = new URLSearchParams({ photoId })
return apiFetch<{ count: number }>(`/api/comments/count?${params.toString()}`)
},
async toggleReaction(input: ToggleReactionInput): Promise<Comment> {
const data = await apiFetch<{ item: CommentDto }>(`/api/comments/${input.commentId}/reactions`, {
method: 'POST',

View File

@@ -1,6 +1,8 @@
import type { PickedExif } from '@afilmory/builder'
import { MobileTabGroup, MobileTabItem, SegmentGroup, SegmentItem } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { useQuery } from '@tanstack/react-query'
import clsx from 'clsx'
import { m } from 'motion/react'
import type { FC } from 'react'
import { useState } from 'react'
@@ -8,6 +10,7 @@ import { useTranslation } from 'react-i18next'
import { injectConfig } from '~/config'
import { useMobile } from '~/hooks/useMobile'
import { commentsApi } from '~/lib/api/comments'
import { ExifPanelContent } from '~/modules/metadata/ExifPanel'
import { CommentsPanel } from '~/modules/social/comments'
import type { PhotoManifest } from '~/types/photo'
@@ -25,6 +28,13 @@ export const InspectorPanel: FC<{
const [activeTab, setActiveTab] = useState<Tab>('info')
const showSocialFeatures = injectConfig.useCloud
const { data: commentCount } = useQuery({
queryKey: ['comment-count', currentPhoto.id],
queryFn: () => commentsApi.count(currentPhoto.id),
enabled: showSocialFeatures,
})
const hasComments = (commentCount?.count ?? 0) > 0
return (
<m.div
@@ -85,6 +95,7 @@ export const InspectorPanel: FC<{
<div className="flex items-center">
<i className="i-mingcute-comment-line mr-1.5 text-base" />
{t('inspector.tab.comments')}
{hasComments && <div className="bg-accent ml-1.5 size-1.5 rounded-full" />}
</div>
}
/>
@@ -126,9 +137,10 @@ export const InspectorPanel: FC<{
activeBgClassName="bg-accent/20"
className="text-white/60 hover:text-white/80 data-[state=active]:text-white"
label={
<div className="flex items-center">
<div className={clsx('flex items-center', hasComments && 'pr-0.5')}>
<i className="i-mingcute-comment-line mr-1.5" />
{t('inspector.tab.comments')}
{hasComments && <div className="bg-accent absolute top-1 right-1 size-1.5 rounded-full" />}
</div>
}
/>

View File

@@ -2,7 +2,13 @@ import { Body, ContextParam, Controller, Delete, Get, Param, Post, Query } from
import { Roles } from 'core/guards/roles.decorator'
import type { Context } from 'hono'
import { CommentReactionDto, CreateCommentDto, ListAllCommentsQueryDto, ListCommentsQueryDto } from './comment.dto'
import {
CommentReactionDto,
CreateCommentDto,
GetCommentCountQueryDto,
ListAllCommentsQueryDto,
ListCommentsQueryDto,
} from './comment.dto'
import { CommentService } from './comment.service'
@Controller('comments')
@@ -15,6 +21,11 @@ export class CommentController {
return await this.commentService.createComment(body, context)
}
@Get('/count')
async getCommentCount(@Query() query: GetCommentCountQueryDto) {
return await this.commentService.getCommentCount(query)
}
@Get('/')
async listComments(@Query() query: ListCommentsQueryDto) {
return await this.commentService.listComments(query)

View File

@@ -31,3 +31,9 @@ export const CommentReactionSchema = z.object({
})
export class CommentReactionDto extends createZodSchemaDto(CommentReactionSchema) {}
export const GetCommentCountQuerySchema = z.object({
photoId: z.string().trim().min(1, 'photoId is required'),
})
export class GetCommentCountQueryDto extends createZodSchemaDto(GetCommentCountQuerySchema) {}

View File

@@ -34,8 +34,8 @@ export interface UserViewModel {
website?: string | null
}
interface ViewerContext {
userId: string | null
interface AuthUser {
id?: string
role?: string
}
@@ -44,6 +44,13 @@ interface CommentResponseItem extends CommentViewModel {
viewerReactions: string[]
}
type AuthContextValue =
| {
user?: AuthUser
session?: unknown
}
| undefined
@injectable()
export class CommentService {
constructor(
@@ -62,7 +69,11 @@ export class CommentService {
users: Record<string, UserViewModel>
}> {
const tenant = requireTenantContext()
const auth = this.requireAuth()
const authUser = this.getAuthUser()
const userId = authUser?.id
if (!userId) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
const db = this.dbAccessor.get()
await this.ensurePhotoExists(tenant.tenant.id, dto.photoId)
@@ -70,7 +81,7 @@ export class CommentService {
const moderationInput: CommentModerationHookInput = {
tenantId: tenant.tenant.id,
userId: auth.userId,
userId,
photoId: dto.photoId,
parentId: parent?.id,
content: dto.content.trim(),
@@ -93,7 +104,7 @@ export class CommentService {
tenantId: tenant.tenant.id,
photoId: dto.photoId,
parentId: parent?.id ?? null,
userId: auth.userId,
userId,
content: dto.content.trim(),
status,
userAgent: moderationInput.userAgent ?? null,
@@ -126,7 +137,7 @@ export class CommentService {
.limit(1)
if (fullParent) {
const parentReactions = await this.fetchReactionAggregations(tenant.tenant.id, [parent.id], auth.userId)
const parentReactions = await this.fetchReactionAggregations(tenant.tenant.id, [parent.id], userId)
relations[parent.id] = this.toResponse({
...fullParent,
reactionCounts: parentReactions.counts.get(parent.id) ?? {},
@@ -136,7 +147,7 @@ export class CommentService {
}
// Fetch user info
const userIds = [auth.userId, ...Object.values(relations).map((r) => r.userId)].filter(Boolean)
const userIds = [userId, ...Object.values(relations).map((r) => r.userId)].filter(Boolean)
const users = await this.fetchUsersWithProfiles(userIds)
// Emit event asynchronously
@@ -147,7 +158,7 @@ export class CommentService {
record.id,
tenant.tenant.id,
dto.photoId,
auth.userId,
userId,
parent?.id ?? null,
dto.content.trim(),
record.createdAt,
@@ -167,7 +178,10 @@ export class CommentService {
nextCursor: string | null
}> {
const tenant = requireTenantContext()
const viewer = this.getViewer()
const authUser = this.getAuthUser()
const viewerUserId = authUser?.id ?? null
const role = authUser?.role
const isAdmin = role === 'admin' || role === 'superadmin'
const db = this.dbAccessor.get()
const filters = [
@@ -177,12 +191,12 @@ export class CommentService {
]
let statusCondition
if (viewer.isAdmin) {
if (isAdmin) {
statusCondition = inArray(comments.status, ['approved', 'pending'])
} else if (viewer.userId) {
} else if (viewerUserId) {
statusCondition = or(
eq(comments.status, 'approved'),
and(eq(comments.status, 'pending'), eq(comments.userId, viewer.userId)),
and(eq(comments.status, 'pending'), eq(comments.userId, viewerUserId)),
)
} else {
statusCondition = eq(comments.status, 'approved')
@@ -220,7 +234,7 @@ export class CommentService {
const items = rows.slice(0, query.limit)
const commentIds = items.map((item) => item.id)
const reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewer.userId)
const reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewerUserId)
const nextCursor = hasMore && items.length > 0 ? items.at(-1)!.id : null
@@ -256,7 +270,7 @@ export class CommentService {
const parentReactions = await this.fetchReactionAggregations(
tenant.tenant.id,
parentRows.map((p) => p.id),
viewer.userId,
viewerUserId,
)
for (const parent of parentRows) {
@@ -290,14 +304,10 @@ export class CommentService {
nextCursor: string | null
}> {
const tenant = requireTenantContext()
const viewer = this.getViewer()
const authUser = this.getAuthUser()
const viewerUserId = authUser?.id ?? null
const db = this.dbAccessor.get()
// Only admin can access this endpoint
if (!viewer.isAdmin) {
throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '仅管理员可以访问' })
}
const filters = [eq(comments.tenantId, tenant.tenant.id), isNull(comments.deletedAt)]
// Filter by photoId if provided
@@ -341,7 +351,7 @@ export class CommentService {
const items = rows.slice(0, query.limit)
const commentIds = items.map((item) => item.id)
const reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewer.userId)
const reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewerUserId)
const nextCursor = hasMore && items.length > 0 ? items.at(-1)!.id : null
@@ -377,7 +387,7 @@ export class CommentService {
const parentReactions = await this.fetchReactionAggregations(
tenant.tenant.id,
parentRows.map((p) => p.id),
viewer.userId,
viewerUserId,
)
for (const parent of parentRows) {
@@ -406,7 +416,11 @@ export class CommentService {
async toggleReaction(commentId: string, body: CommentReactionDto): Promise<{ item: CommentResponseItem }> {
const tenant = requireTenantContext()
const auth = this.requireAuth()
const authUser = this.getAuthUser()
const userId = authUser?.id
if (!userId) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
const db = this.dbAccessor.get()
const comment = await this.getCommentById(commentId, tenant.tenant.id)
@@ -418,7 +432,7 @@ export class CommentService {
and(
eq(commentReactions.tenantId, tenant.tenant.id),
eq(commentReactions.commentId, comment.id),
eq(commentReactions.userId, auth.userId),
eq(commentReactions.userId, userId),
eq(commentReactions.reaction, body.reaction),
),
)
@@ -430,12 +444,12 @@ export class CommentService {
await db.insert(commentReactions).values({
tenantId: tenant.tenant.id,
commentId: comment.id,
userId: auth.userId,
userId,
reaction: body.reaction,
})
}
const aggregation = await this.fetchReactionAggregations(tenant.tenant.id, [comment.id], auth.userId)
const aggregation = await this.fetchReactionAggregations(tenant.tenant.id, [comment.id], userId)
const item = this.toResponse({
...comment,
reactionCounts: aggregation.counts.get(comment.id) ?? {},
@@ -446,7 +460,13 @@ export class CommentService {
async softDelete(commentId: string): Promise<void> {
const tenant = requireTenantContext()
const auth = this.requireAuth()
const authUser = this.getAuthUser()
const userId = authUser?.id
if (!userId) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
const { role } = authUser!
const isAdmin = role === 'admin' || role === 'superadmin'
const db = this.dbAccessor.get()
const [record] = await db
@@ -463,8 +483,8 @@ export class CommentService {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '评论不存在' })
}
const isAdmin = auth.role === 'admin' || auth.role === 'superadmin'
const isOwner = auth.userId === record.userId
const { userId: authorId } = record
const isOwner = userId === authorId
if (!isAdmin && !isOwner) {
throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '无权删除该评论' })
@@ -479,28 +499,44 @@ export class CommentService {
.where(eq(comments.id, record.id))
}
private requireAuth(): { userId: string; role?: string } {
const authContext = HttpContext.getValue('auth') as
| { user?: { id?: string; role?: string }; session?: unknown }
| undefined
if (!authContext?.user || !authContext.session) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
async getCommentCount(query: { photoId: string }): Promise<{ count: number }> {
const tenant = requireTenantContext()
const authUser = this.getAuthUser()
const viewerUserId = authUser?.id ?? null
const role = authUser?.role
const isAdmin = role === 'admin' || role === 'superadmin'
const db = this.dbAccessor.get()
const filters = [
eq(comments.tenantId, tenant.tenant.id),
eq(comments.photoId, query.photoId),
isNull(comments.deletedAt),
]
let statusCondition
if (isAdmin) {
statusCondition = inArray(comments.status, ['approved', 'pending'])
} else if (viewerUserId) {
statusCondition = or(
eq(comments.status, 'approved'),
and(eq(comments.status, 'pending'), eq(comments.userId, viewerUserId)),
)
} else {
statusCondition = eq(comments.status, 'approved')
}
const userId = (authContext.user as { id?: string }).id
if (!userId) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
return { userId, role: (authContext.user as { role?: string }).role }
filters.push(statusCondition)
const [result] = await db
.select({ count: sql<number>`count(*)` })
.from(comments)
.where(and(...filters))
return { count: Number(result?.count ?? 0) }
}
private getViewer(): ViewerContext & { isAdmin: boolean } {
const authContext = HttpContext.getValue('auth') as
| { user?: { id?: string; role?: string }; session?: unknown }
| undefined
const userId = authContext?.user?.id ?? null
const role = authContext?.user?.role
const isAdmin = role === 'admin' || role === 'superadmin'
return { userId, role, isAdmin }
private getAuthUser(): AuthUser | undefined {
const authContext = HttpContext.getValue('auth') as AuthContextValue
return authContext?.user
}
private async ensurePhotoExists(tenantId: string, photoId: string): Promise<void> {