feat: add website field to CommentUser and update comment components

- Introduced an optional `website` field in the `CommentUser` interface to allow users to provide their website links.
- Updated `CommentCard` and `CommentHeader` components to utilize the new `website` field, enabling clickable links for user websites in the comment header.
- Refactored user data fetching in `CommentService` to include the website information when retrieving user profiles.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-03 21:16:35 +08:00
parent ced9e747aa
commit 9fb7f5525f
5 changed files with 169 additions and 133 deletions

View File

@@ -32,6 +32,7 @@ export interface CommentUser {
id: string id: string
name: string name: string
image: string | null image: string | null
website?: string | null
} }
export interface CommentListResult { export interface CommentListResult {
@@ -62,6 +63,7 @@ interface CommentUserDto {
id: string id: string
name: string name: string
image: string | null image: string | null
website?: string | null
} }
interface CommentListResponseDto { interface CommentListResponseDto {

View File

@@ -34,6 +34,9 @@ export const CommentItem = memo(({ comment, reacted, locale }: CommentItemProps)
[atoms.usersAtom, comment.userId], [atoms.usersAtom, comment.userId],
), ),
) )
const user = useAtomValue(
useMemo(() => selectAtom(atoms.usersAtom, (users) => users[comment.userId]), [atoms.usersAtom, comment.userId]),
)
const userName = useAtomValue( const userName = useAtomValue(
useMemo( useMemo(
() => selectAtom(atoms.usersAtom, (users) => users[comment.userId]?.name), () => selectAtom(atoms.usersAtom, (users) => users[comment.userId]?.name),
@@ -67,7 +70,7 @@ export const CommentItem = memo(({ comment, reacted, locale }: CommentItemProps)
<div className="relative z-10 flex min-w-0 flex-row gap-3"> <div className="relative z-10 flex min-w-0 flex-row gap-3">
<UserAvatar image={userImage} name={userName ?? comment.userId} fallback="?" size={36} /> <UserAvatar image={userImage} name={userName ?? comment.userId} fallback="?" size={36} />
<div className="flex min-w-0 flex-1 flex-col space-y-2"> <div className="flex min-w-0 flex-1 flex-col space-y-2">
<CommentHeader comment={comment} author={authorName(comment)} locale={locale} /> <CommentHeader comment={comment} author={authorName(comment)} locale={locale} user={user} />
<CommentContent comment={comment} parentId={comment.parentId} authorName={authorName} /> <CommentContent comment={comment} parentId={comment.parentId} authorName={authorName} />
<CommentActionBar reacted={reacted} reactionCount={comment.reactionCounts.like ?? 0} comment={comment} /> <CommentActionBar reacted={reacted} reactionCount={comment.reactionCounts.like ?? 0} comment={comment} />
</div> </div>

View File

@@ -1,14 +1,37 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { Comment } from '~/lib/api/comments' import type { Comment, CommentUser } from '~/lib/api/comments'
import { RelativeTime } from './RelativeTime' import { RelativeTime } from './RelativeTime'
export const CommentHeader = ({ comment, author, locale }: { comment: Comment; author: string; locale: string }) => { export const CommentHeader = ({
comment,
author,
locale,
user,
}: {
comment: Comment
author: string
locale: string
user?: CommentUser | null
}) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex flex-wrap items-baseline gap-2"> <div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-medium text-white/90">{author}</span> <span className="text-sm font-medium text-white/90">
{user?.website ? (
<a
href={user.website}
target="_blank"
rel="noopener noreferrer"
className="decoration-white/20 hover:underline hover:decoration-white/40"
>
{author}
</a>
) : (
author
)}
</span>
<RelativeTime timestamp={comment.createdAt} locale={locale} className="text-xs text-white/45" /> <RelativeTime timestamp={comment.createdAt} locale={locale} className="text-xs text-white/45" />
{comment.status === 'pending' && ( {comment.status === 'pending' && (
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200/80 uppercase"> <span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200/80 uppercase">

View File

@@ -1,9 +1,11 @@
import { authUsers, commentReactions, comments, photoAssets } from '@afilmory/db' import { authAccounts, authUsers, commentReactions, comments, photoAssets, tenantDomains, tenants } from '@afilmory/db'
import { EventEmitterService, HttpContext } from '@afilmory/framework' import { EventEmitterService, HttpContext } from '@afilmory/framework'
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import { getClientIp } from 'core/context/http-context.helper' import { getClientIp } from 'core/context/http-context.helper'
import { DbAccessor } from 'core/database/database.provider' import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors' import { BizException, ErrorCode } from 'core/errors'
import { logger } from 'core/helpers/logger.helper' import { logger } from 'core/helpers/logger.helper'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { CommentCreatedEvent } from 'core/modules/content/comment/events/comment-created.event' import { CommentCreatedEvent } from 'core/modules/content/comment/events/comment-created.event'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context' import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { and, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm' import { and, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm'
@@ -29,6 +31,7 @@ export interface UserViewModel {
id: string id: string
name: string name: string
image: string | null image: string | null
website?: string | null
} }
interface ViewerContext { interface ViewerContext {
@@ -47,6 +50,7 @@ export class CommentService {
private readonly dbAccessor: DbAccessor, private readonly dbAccessor: DbAccessor,
@inject(COMMENT_MODERATION_HOOK) private readonly moderationHook: CommentModerationHook, @inject(COMMENT_MODERATION_HOOK) private readonly moderationHook: CommentModerationHook,
private readonly eventEmitter: EventEmitterService, private readonly eventEmitter: EventEmitterService,
private readonly systemSettings: SystemSettingService,
) {} ) {}
async createComment( async createComment(
@@ -132,28 +136,8 @@ export class CommentService {
} }
// Fetch user info // Fetch user info
const users: Record<string, UserViewModel> = {}
const userIds = [auth.userId, ...Object.values(relations).map((r) => r.userId)].filter(Boolean) const userIds = [auth.userId, ...Object.values(relations).map((r) => r.userId)].filter(Boolean)
const uniqueUserIds = [...new Set(userIds)] const users = await this.fetchUsersWithProfiles(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,
}
}
}
// Emit event asynchronously // Emit event asynchronously
this.eventEmitter this.eventEmitter
@@ -285,29 +269,11 @@ export class CommentService {
} }
// Build users map (userId -> user) // Build users map (userId -> user)
const users: Record<string, UserViewModel> = {}
const allUserIds = [ const allUserIds = [
...new Set([...items.map((item) => item.userId), ...Object.values(relations).map((r) => r.userId)]), ...new Set([...items.map((item) => item.userId), ...Object.values(relations).map((r) => r.userId)]),
] ]
if (allUserIds.length > 0) { const users = await this.fetchUsersWithProfiles(allUserIds)
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 { return {
comments: commentItems, comments: commentItems,
@@ -424,29 +390,11 @@ export class CommentService {
} }
// Build users map (userId -> user) // Build users map (userId -> user)
const users: Record<string, UserViewModel> = {}
const allUserIds = [ const allUserIds = [
...new Set([...items.map((item) => item.userId), ...Object.values(relations).map((r) => r.userId)]), ...new Set([...items.map((item) => item.userId), ...Object.values(relations).map((r) => r.userId)]),
] ]
if (allUserIds.length > 0) { const users = await this.fetchUsersWithProfiles(allUserIds)
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 { return {
comments: commentItems, comments: commentItems,
@@ -720,4 +668,76 @@ export class CommentService {
viewerReactions: model.viewerReactions, viewerReactions: model.viewerReactions,
} }
} }
private async fetchUsersWithProfiles(userIds: string[]): Promise<Record<string, UserViewModel>> {
const db = this.dbAccessor.get()
const uniqueUserIds = [...new Set(userIds)].filter(Boolean)
const result: Record<string, UserViewModel> = {}
if (uniqueUserIds.length === 0) {
return result
}
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) {
result[user.id] = {
id: user.id,
name: user.name,
image: user.image,
website: null,
}
}
const accounts = await db
.select({
userId: authAccounts.userId,
providerId: authAccounts.providerId,
accountId: authAccounts.accountId,
})
.from(authAccounts)
.where(inArray(authAccounts.userId, uniqueUserIds))
if (accounts.length > 0) {
const conditions = accounts.map((acc) =>
and(eq(authAccounts.providerId, acc.providerId), eq(authAccounts.accountId, acc.accountId)),
)
const matchedTenants = await db
.select({
providerId: authAccounts.providerId,
accountId: authAccounts.accountId,
slug: tenants.slug,
customDomain: tenantDomains.domain,
})
.from(authAccounts)
.innerJoin(authUsers, eq(authAccounts.userId, authUsers.id))
.innerJoin(tenants, eq(authUsers.tenantId, tenants.id))
.leftJoin(tenantDomains, and(eq(tenantDomains.tenantId, tenants.id), eq(tenantDomains.status, 'verified')))
.where(and(or(...conditions), eq(authUsers.role, 'admin')))
const baseDomain = (await this.systemSettings.getSettings()).baseDomain || DEFAULT_BASE_DOMAIN
for (const acc of accounts) {
const match = matchedTenants.find((t) => t.providerId === acc.providerId && t.accountId === acc.accountId)
if (match && result[acc.userId]) {
if (match.customDomain) {
result[acc.userId].website = `https://${match.customDomain}`
} else {
result[acc.userId].website = `https://${match.slug}.${baseDomain}`
}
}
}
}
return result
}
} }

View File

@@ -1,4 +1,4 @@
import { Button } from '@afilmory/ui' import { Button, LinearBorderContainer } from '@afilmory/ui'
import { repository } from '@pkg' import { repository } from '@pkg'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -39,80 +39,68 @@ export function ErrorElement() {
} }
return ( return (
<div className="flex min-h-screen flex-col"> <div className="bg-background text-text relative flex min-h-dvh flex-1 flex-col">
{/* Header spacer */} <div className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6">
<div className="h-16" /> <LinearBorderContainer>
<div className="relative w-full max-w-[640px] overflow-hidden border border-white/5">
{/* Main content */} {/* Glassmorphic background effects */}
<div className="flex flex-1 items-center justify-center px-6"> <div className="pointer-events-none absolute inset-0 opacity-60">
<div className="w-full max-w-lg"> <div className="absolute -inset-32 bg-linear-to-br from-red-500/20 via-transparent to-transparent blur-3xl" />
{/* Error icon and status */} <div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%)]" />
<div className="mb-8 text-center">
<div className="bg-background-secondary mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full">
<svg className="text-red h-8 w-8" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
</div> </div>
<h1 className="text-text mb-2 text-3xl font-medium">{t('error.boundary.title')}</h1>
<p className="text-text-secondary text-lg">{t('error.boundary.description')}</p>
</div>
{/* Error message */} <div className="relative p-10 sm:p-12">
<div className="bg-material-medium border-fill-tertiary mb-6 rounded-lg border p-4"> <div>
<p className="text-text-secondary font-mono text-sm break-words">{message}</p> <p className="mb-3 text-xs font-semibold tracking-[0.55em] text-red-400 uppercase">Error</p>
</div> <h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">{t('error.boundary.title')}</h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">{t('error.boundary.description')}</p>
{/* Stack trace in development */} <div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
{import.meta.env.DEV && stack && ( <p className="text-text-secondary font-mono break-words">{message}</p>
<div className="mb-6"> {import.meta.env.DEV && stack && (
<div className="bg-material-medium border-fill-tertiary overflow-auto rounded-lg border p-4"> <div className="mt-4 overflow-auto">
<pre className="text-red font-mono text-xs break-words whitespace-pre-wrap"> <pre className="font-mono text-xs break-words whitespace-pre-wrap text-red-400">
{attachOpenInEditor(stack)} {attachOpenInEditor(stack)}
</pre> </pre>
</div>
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
variant="primary"
className="glassmorphic-btn flex-1"
onClick={() => (window.location.href = '/')}
>
{t('error.boundary.reload')}
</Button>
<Button variant="ghost" className="flex-1" onClick={() => window.history.back()}>
{t('error.boundary.go-back')}
</Button>
</div>
<div className="mt-8 text-center sm:text-left">
<p className="text-text-secondary mb-2 text-sm">{t('error.boundary.help')}</p>
<a
href={`${repository.url}/issues/new?title=${encodeURIComponent(
`Error: ${message}`,
)}&body=${encodeURIComponent(
`### Error\n\n${message}\n\n### Stack\n\n\`\`\`\n${stack}\n\`\`\``,
)}&label=bug`}
target="_blank"
rel="noreferrer"
className="text-text-secondary hover:text-text inline-flex items-center text-sm transition-colors"
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.374 0 0 5.373 0 12 0 17.302 3.438 21.8 8.207 23.387c.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
</svg>
{t('error.boundary.report')}
</a>
</div>
</div> </div>
</div> </div>
)}
{/* Action buttons */}
<div className="mb-8 flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => (window.location.href = '/')}
className="bg-material-opaque text-text-vibrant hover:bg-control-enabled/90 h-10 flex-1 border-0 font-medium transition-colors"
>
{t('error.boundary.reload')}
</Button>
<Button
onClick={() => window.history.back()}
className="bg-material-thin text-text border-fill-tertiary hover:bg-fill-tertiary h-10 flex-1 border font-medium transition-colors"
>
{t('error.boundary.go-back')}
</Button>
</div> </div>
</LinearBorderContainer>
{/* Help text */}
<div className="text-center">
<p className="text-text-secondary mb-3 text-sm">{t('error.boundary.help')}</p>
<a
href={`${repository.url}/issues/new?title=${encodeURIComponent(
`Error: ${message}`,
)}&body=${encodeURIComponent(
`### Error\n\n${message}\n\n### Stack\n\n\`\`\`\n${stack}\n\`\`\``,
)}&label=bug`}
target="_blank"
rel="noreferrer"
className="text-text-secondary hover:text-text inline-flex items-center text-sm transition-colors"
>
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.374 0 0 5.373 0 12 0 17.302 3.438 21.8 8.207 23.387c.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
</svg>
{t('error.boundary.report')}
</a>
</div>
</div>
</div> </div>
</div> </div>
) )