mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user