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
name: string
image: string | null
website?: string | null
}
export interface CommentListResult {
@@ -62,6 +63,7 @@ interface CommentUserDto {
id: string
name: string
image: string | null
website?: string | null
}
interface CommentListResponseDto {

View File

@@ -34,6 +34,9 @@ export const CommentItem = memo(({ comment, reacted, locale }: CommentItemProps)
[atoms.usersAtom, comment.userId],
),
)
const user = useAtomValue(
useMemo(() => selectAtom(atoms.usersAtom, (users) => users[comment.userId]), [atoms.usersAtom, comment.userId]),
)
const userName = useAtomValue(
useMemo(
() => 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">
<UserAvatar image={userImage} name={userName ?? comment.userId} fallback="?" size={36} />
<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} />
<CommentActionBar reacted={reacted} reactionCount={comment.reactionCounts.like ?? 0} comment={comment} />
</div>

View File

@@ -1,14 +1,37 @@
import { useTranslation } from 'react-i18next'
import type { Comment } from '~/lib/api/comments'
import type { Comment, CommentUser } from '~/lib/api/comments'
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()
return (
<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" />
{comment.status === 'pending' && (
<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 { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import { getClientIp } from 'core/context/http-context.helper'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
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 { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { and, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm'
@@ -29,6 +31,7 @@ export interface UserViewModel {
id: string
name: string
image: string | null
website?: string | null
}
interface ViewerContext {
@@ -47,6 +50,7 @@ export class CommentService {
private readonly dbAccessor: DbAccessor,
@inject(COMMENT_MODERATION_HOOK) private readonly moderationHook: CommentModerationHook,
private readonly eventEmitter: EventEmitterService,
private readonly systemSettings: SystemSettingService,
) {}
async createComment(
@@ -132,28 +136,8 @@ export class CommentService {
}
// 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,
}
}
}
const users = await this.fetchUsersWithProfiles(userIds)
// Emit event asynchronously
this.eventEmitter
@@ -285,29 +269,11 @@ export class CommentService {
}
// 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,
}
}
}
const users = await this.fetchUsersWithProfiles(allUserIds)
return {
comments: commentItems,
@@ -424,29 +390,11 @@ export class CommentService {
}
// 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,
}
}
}
const users = await this.fetchUsersWithProfiles(allUserIds)
return {
comments: commentItems,
@@ -720,4 +668,76 @@ export class CommentService {
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 { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@@ -39,80 +39,68 @@ export function ErrorElement() {
}
return (
<div className="flex min-h-screen flex-col">
{/* Header spacer */}
<div className="h-16" />
{/* Main content */}
<div className="flex flex-1 items-center justify-center px-6">
<div className="w-full max-w-lg">
{/* Error icon and status */}
<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 className="bg-background text-text relative flex min-h-dvh flex-1 flex-col">
<div className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6">
<LinearBorderContainer>
<div className="relative w-full max-w-[640px] overflow-hidden border border-white/5">
{/* Glassmorphic background effects */}
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute -inset-32 bg-linear-to-br from-red-500/20 via-transparent to-transparent blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%)]" />
</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="bg-material-medium border-fill-tertiary mb-6 rounded-lg border p-4">
<p className="text-text-secondary font-mono text-sm break-words">{message}</p>
</div>
<div className="relative p-10 sm:p-12">
<div>
<p className="mb-3 text-xs font-semibold tracking-[0.55em] text-red-400 uppercase">Error</p>
<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 */}
{import.meta.env.DEV && stack && (
<div className="mb-6">
<div className="bg-material-medium border-fill-tertiary overflow-auto rounded-lg border p-4">
<pre className="text-red font-mono text-xs break-words whitespace-pre-wrap">
{attachOpenInEditor(stack)}
</pre>
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
<p className="text-text-secondary font-mono break-words">{message}</p>
{import.meta.env.DEV && stack && (
<div className="mt-4 overflow-auto">
<pre className="font-mono text-xs break-words whitespace-pre-wrap text-red-400">
{attachOpenInEditor(stack)}
</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>
)}
{/* 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>
{/* 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>
</LinearBorderContainer>
</div>
</div>
)