feat: implement email notification system for comments

- Added MailModule to handle email notifications for comment events.
- Introduced CommentCreatedEvent to encapsulate comment creation details.
- Implemented CommentNotificationListener to send notifications to relevant users when a comment is created.
- Integrated Resend service for sending emails, with templates for comment notifications.
- Updated various modules to support the new email notification feature.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-03 20:57:26 +08:00
parent 1aac293020
commit ced9e747aa
26 changed files with 455 additions and 36 deletions

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
export const autolink = (text: string): ReactNode[] => {
if (!text) return []
const urlPattern = /((?:https?:\/\/|www\.)[\x21-\x7E]+)/g
return text.split(urlPattern).map((part, i) => {
if (urlPattern.test(part)) {
let href = part
if (part.startsWith('www.')) {
href = `http://${part}`
}
return (
<a
key={i}
href={href}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
)
}
return part
})
}

View File

@@ -134,7 +134,7 @@ export const ExifPanelContent: FC<ExifPanelContentProps> = ({
)
return (
<ScrollArea rootClassName={rootClassName} viewportClassName={viewportClassName}>
<ScrollArea mask rootClassName={rootClassName} viewportClassName={viewportClassName}>
<div className={`space-y-${isMobile ? '3' : '4'}`}>
{/* 基本信息和标签 - 合并到一个 section */}
<div>

View File

@@ -64,9 +64,9 @@ export const CommentItem = memo(({ comment, reacted, locale }: CommentItemProps)
// isNew && 'animate-highlight-new',
)}
>
<div className="relative z-10 flex 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} />
<div className="flex-1 space-y-2">
<div className="flex min-w-0 flex-1 flex-col 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} />

View File

@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { Trans } from 'react-i18next'
import type { Comment } from '~/lib/api/comments'
import { autolink } from '~/lib/autolink'
import { useCommentsContext } from './context'
@@ -24,7 +25,7 @@ export const CommentContent = ({ comment, parentId, authorName }: CommentContent
return (
<>
{parent ? (
<div className="rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-xs text-white/70">
<div className="flex min-w-0 flex-col 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 text-[11px] tracking-wide text-white/40 uppercase">
<i className="i-lucide-reply mr-2" />
@@ -34,11 +35,13 @@ export const CommentContent = ({ comment, parentId, authorName }: CommentContent
values={{ user: authorName(parent) }}
/>
</div>
<p className="line-clamp-3 text-sm leading-relaxed text-white/70">{parent.content}</p>
<p className="line-clamp-3 text-sm leading-relaxed wrap-break-word text-white/70">
{autolink(parent.content)}
</p>
</div>
) : null}
<p className="text-sm leading-relaxed text-white/85">{comment.content}</p>
<p className="text-sm leading-relaxed wrap-break-word text-white/85">{autolink(comment.content)}</p>
</>
)
}

View File

@@ -60,9 +60,9 @@ export const CommentInput = () => {
)}
{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="border-accent/20 bg-accent/50 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-lucide-reply text-accent" />
<i className="i-lucide-reply opacity-50" />
<span>
<Trans
i18nKey="comments.replyingTo"
@@ -117,7 +117,6 @@ export const CommentInput = () => {
)}
</button>
</form>
<p className="mt-2 text-xs text-white/40">{t('comments.hint')}</p>
</div>
)
}

View File

@@ -33,9 +33,9 @@ const CommentsContent: FC = () => {
const sessionUser = useAtomValue(sessionUserAtom)
return (
<div className="flex min-h-0 w-full flex-1 flex-col">
<ScrollArea rootClassName="flex-1 min-h-0" viewportClassName="px-4">
<ScrollArea mask rootClassName="flex-1 min-h-0" viewportClassName="px-4">
<div className="space-y-4 pb-4">
{status.isLoading ? (
{status.isLoading && !status.isLoadingMore && !status.isError && comments.length === 0 ? (
<SkeletonList />
) : status.isError ? (
<ErrorBox />

View File

@@ -35,17 +35,20 @@
"better-auth": "1.4.2",
"busboy": "1.6.0",
"drizzle-orm": "^0.44.7",
"ejs": "3.1.10",
"hono": "4.10.7",
"linkedom": "0.18.12",
"mime-types": "3.0.2",
"pg": "^8.16.3",
"picocolors": "1.1.1",
"reflect-metadata": "0.2.2",
"resend": "6.5.2",
"satori": "0.18.3",
"tsyringe": "4.10.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/ejs": "3.1.5",
"@types/mime-types": "3.0.1",
"@types/node": "^24.10.1",
"@types/pg": "8.15.6",

View File

@@ -7,5 +7,6 @@ import { SystemSettingStore } from './system-setting.store.service'
@Module({
imports: [DatabaseModule],
providers: [SystemSettingStore, SystemSettingService],
exports: [SystemSettingService],
})
export class SystemSettingModule {}

View File

@@ -1,8 +1,10 @@
import { authUsers, commentReactions, comments, photoAssets } from '@afilmory/db'
import { HttpContext } from '@afilmory/framework'
import { EventEmitterService, HttpContext } from '@afilmory/framework'
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 { 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'
import type { Context } from 'hono'
@@ -44,6 +46,7 @@ export class CommentService {
constructor(
private readonly dbAccessor: DbAccessor,
@inject(COMMENT_MODERATION_HOOK) private readonly moderationHook: CommentModerationHook,
private readonly eventEmitter: EventEmitterService,
) {}
async createComment(
@@ -152,6 +155,24 @@ export class CommentService {
}
}
// Emit event asynchronously
this.eventEmitter
.emit(
'comment.created',
new CommentCreatedEvent(
record.id,
tenant.tenant.id,
dto.photoId,
auth.userId,
parent?.id ?? null,
dto.content.trim(),
record.createdAt,
),
)
.catch((error) => {
logger.error('Failed to emit comment.created event', error)
})
return { comments: [item], relations, users }
}

View File

@@ -0,0 +1,11 @@
export class CommentCreatedEvent {
constructor(
public readonly commentId: string,
public readonly tenantId: string,
public readonly photoId: string,
public readonly userId: string,
public readonly parentId: string | null,
public readonly content: string,
public readonly createdAt: string,
) {}
}

View File

@@ -26,6 +26,7 @@ import { CacheModule } from './infrastructure/cache/cache.module'
import { DataSyncModule } from './infrastructure/data-sync/data-sync.module'
import { HealthModule } from './infrastructure/health/health.module'
import { StaticWebModule } from './infrastructure/static-web/static-web.module'
import { MailModule } from './mail/mail.module'
import { AuthModule } from './platform/auth/auth.module'
import { BillingModule } from './platform/billing/billing.module'
import { DashboardModule } from './platform/dashboard/dashboard.module'
@@ -49,6 +50,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
inject: [RedisAccessor],
}),
RedisModule,
MailModule,
AuthModule,
CacheModule,
HealthModule,

View File

@@ -0,0 +1,123 @@
import { authUsers, comments } from '@afilmory/db'
import { createLogger, OnEvent } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { CommentCreatedEvent } from 'core/modules/content/comment/events/comment-created.event'
import { and, eq, or } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { MailService, TEMPLATES } from '../mail.service'
@injectable()
export class CommentNotificationListener {
private readonly logger = createLogger('CommentNotificationListener')
constructor(
private readonly dbAccessor: DbAccessor,
private readonly mailService: MailService,
private readonly systemSettingService: SystemSettingService,
) {
this.logger.info('CommentNotificationListener initialized')
}
@OnEvent('comment.created')
async handleCommentCreated(event: CommentCreatedEvent) {
try {
this.logger.verbose('Sending notifications for comment.created event', event)
await this.sendNotifications(event)
} catch (error) {
this.logger.error('Failed to handle comment.created event', error)
}
}
private async sendNotifications(event: CommentCreatedEvent) {
const db = this.dbAccessor.get()
const settings = await this.systemSettingService.getSettings()
const { baseDomain } = settings
// Assume https for now, or we could make protocol configurable if needed.
// But typically production runs on https.
const photoUrl = `https://${baseDomain}/photos/${event.photoId}`
const sentEmails = new Set<string>()
// Fetch commenter info
const [commenter] = await db
.select({
id: authUsers.id,
name: authUsers.name,
})
.from(authUsers)
.where(eq(authUsers.id, event.userId))
.limit(1)
if (!commenter) {
this.logger.warn(`Commenter ${event.userId} not found for notification`)
return
}
// 1. Notify parent comment author (Reply)
if (event.parentId) {
const [parent] = await db
.select({
id: comments.id,
userId: comments.userId,
})
.from(comments)
.where(eq(comments.id, event.parentId))
.limit(1)
if (parent) {
const [parentAuthor] = await db
.select({
id: authUsers.id,
name: authUsers.name,
email: authUsers.email,
})
.from(authUsers)
.where(eq(authUsers.id, parent.userId))
.limit(1)
if (parentAuthor && parentAuthor.id !== event.userId && parentAuthor.email) {
await this.mailService.sendTemplate(
parentAuthor.email,
'New reply to your comment',
TEMPLATES.commentNotification,
{
userName: commenter.name,
content: event.content,
photoUrl,
replyToUser: parentAuthor.name,
photoId: event.photoId,
},
)
sentEmails.add(parentAuthor.email)
}
}
}
// 2. Notify Tenant Admins (Owner)
const admins = await db
.select({
id: authUsers.id,
name: authUsers.name,
email: authUsers.email,
})
.from(authUsers)
.where(
and(eq(authUsers.tenantId, event.tenantId), or(eq(authUsers.role, 'admin'), eq(authUsers.role, 'superadmin'))),
)
for (const admin of admins) {
if (admin.id === event.userId) continue
if (sentEmails.has(admin.email)) continue
await this.mailService.sendTemplate(admin.email, 'New comment on your photo', TEMPLATES.commentNotification, {
userName: commenter.name,
content: event.content,
photoUrl,
replyToUser: undefined,
photoId: event.photoId,
})
sentEmails.add(admin.email)
}
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@afilmory/framework'
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
import { CommentNotificationListener } from './listeners/comment-notification.listener'
import { MailService } from './mail.service'
@Module({
imports: [SystemSettingModule],
providers: [MailService, CommentNotificationListener],
exports: [MailService],
})
export class MailModule {}

View File

@@ -0,0 +1,56 @@
import { env } from '@afilmory/env'
import { createLogger } from '@afilmory/framework'
import ejs from 'ejs'
import { Resend } from 'resend'
import { injectable } from 'tsyringe'
import baseTemplate from './templates/base.ejs?raw'
import commentNotificationTemplate from './templates/comment-notification.ejs?raw'
export const TEMPLATES = {
commentNotification: commentNotificationTemplate,
}
@injectable()
export class MailService {
private readonly logger = createLogger('MailService')
private resend: Resend | null = null
constructor() {
if (env.RESEND_API_KEY) {
this.resend = new Resend(env.RESEND_API_KEY)
} else {
this.logger.warn('RESEND_API_KEY is not set. Mail service will be disabled.')
}
}
async send(to: string, subject: string, html: string) {
if (!this.resend) {
this.logger.warn(`Attempted to send email to ${to} but Resend is not configured.`)
return
}
try {
const data = await this.resend.emails.send({
from: env.RESEND_FROM,
to,
subject,
html,
})
this.logger.info(`Email sent to ${to}, id: ${data.data?.id}`)
this.logger.verbose(data)
return data
} catch (error) {
this.logger.error(`Failed to send email to ${to}`, error)
// We don't throw here to prevent blocking the main flow, but we log it.
// Or should we throw? For notifications, maybe better to just log.
}
}
async sendTemplate(to: string, subject: string, template: string, data: Record<string, any>) {
const content = ejs.render(template, data)
const html = ejs.render(baseTemplate, { ...data, content, title: subject })
return this.send(to, subject, html)
}
}

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title><%= title %></title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f9f9f9; }
.container { max-width: 600px; margin: 40px auto; padding: 32px; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.header { margin-bottom: 24px; border-bottom: 1px solid #eee; padding-bottom: 16px; }
.footer { margin-top: 32px; font-size: 0.85em; color: #888; border-top: 1px solid #eee; padding-top: 16px; text-align: center; }
a { color: #000; text-decoration: underline; }
h1 { font-size: 20px; margin: 0; font-weight: 600; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><%= title %></h1>
</div>
<%- content %>
<div class="footer">
<p>Sent from Afilmory</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,5 @@
<p><strong><%= userName %></strong> <%= replyToUser ? 'replied to you' : 'commented on your photo' %>:</p>
<blockquote style="background: #f5f5f5; padding: 12px; border-left: 4px solid #333; margin: 16px 0; white-space: pre-wrap;"><%= content %></blockquote>
<p style="margin-top: 24px;">
<a href="<%= photoUrl %>" style="display: inline-block; background: #000; color: #fff; padding: 10px 20px; border-radius: 6px; text-decoration: none; font-weight: 500;">View on Site</a>
</p>

View File

@@ -28,6 +28,10 @@ export const env = createEnv({
CREEM_API_KEY: z.string().min(1),
CREEM_WEBHOOK_SECRET: z.string().min(1),
// Mail
RESEND_API_KEY: z.string().min(1).optional(),
RESEND_FROM: z.string().min(1).default('AFILMORY <notification@afilmory.art>'),
DEFAULT_SUPERADMIN_EMAIL: z.email().default('root@local.host'),
DEFAULT_SUPERADMIN_USERNAME: z
.string()

View File

@@ -52,7 +52,6 @@
"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",
"comments.loadMore": "Load more",
"comments.loading": "Loading comments...",
"comments.loginRequired": "Please sign in to leave a comment.",

View File

@@ -49,7 +49,6 @@
"comments.chooseProvider": "选择登录方式",
"comments.empty": "暂无评论。快来发表第一条评论吧!",
"comments.error": "评论加载失败",
"comments.hint": "按 Enter 发送,Shift+Enter 换行",
"comments.loadMore": "加载更多",
"comments.loading": "正在加载评论…",
"comments.loginRequired": "登录后才能发表评论。",

View File

@@ -1,3 +1,5 @@
import './index.css'
import { clsxm } from '@afilmory/utils'
import * as ScrollAreaBase from '@radix-ui/react-scroll-area'
import clsx from 'clsx'
@@ -72,7 +74,7 @@ Scrollbar.displayName = 'ScrollArea.Scrollbar'
const Viewport = ({
ref: forwardedRef,
className,
mask = false,
focusable = true,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Viewport> & {
@@ -89,11 +91,7 @@ const Viewport = ({
{...rest}
ref={ref}
tabIndex={focusable ? -1 : void 0}
className={clsxm(
'block size-full',
className,
)}
className={clsxm('block size-full', mask && 'mask-scroller', className)}
/>
)
}

View File

@@ -0,0 +1,44 @@
.mask-scroller {
mask:
linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat,
linear-gradient(white, white) 50% 50% / 100% 100% no-repeat,
linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat;
mask-composite: exclude;
mask-size:
100% calc((var(--scroll-progress-top) / 100) * 30px),
100% 100%,
100% calc((100 - (100 * (var(--scroll-progress-bottom) / 100))) * 1px);
}
@supports (animation-timeline: scroll()) {
.mask-scroller {
mask:
linear-gradient(white, transparent) 50% 0 / 100% 0 no-repeat,
linear-gradient(white, white) 50% 50% / 100% 100% no-repeat,
linear-gradient(transparent, white) 50% 100% / 100% 30px no-repeat;
mask-composite: exclude;
animation:
mask-up both linear,
mask-down both linear;
animation-timeline: scroll(self);
animation-range:
0 50px,
calc(100% - 50px) 100%;
}
}
@keyframes mask-up {
100% {
mask-size:
100% 30px,
100% 100%,
100% 30px;
}
}
@keyframes mask-down {
100% {
mask-size:
100% 30px,
100% 100%,
100% 0;
}
}

View File

@@ -1,7 +1,7 @@
import type { PhotoManifestItem } from '@afilmory/builder'
const GENERATOR_NAME = 'Afilmory Feed Generator'
const EXIF_NAMESPACE = 'https://afilmory.com/rss/exif'
const EXIF_NAMESPACE = 'https://afilmory.art/rss/exif'
const PROTOCOL_VERSION = '1.1'
const PROTOCOL_ID = 'afilmory-rss-exif'

106
pnpm-lock.yaml generated
View File

@@ -819,7 +819,7 @@ importers:
version: 0.5.19(tailwindcss@4.1.17)
'@tailwindcss/vite':
specifier: 4.1.17
version: 4.1.17(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
'@types/node':
specifier: 24.10.1
version: 24.10.1
@@ -831,7 +831,7 @@ importers:
version: 19.2.3(@types/react@19.2.7)
'@vitejs/plugin-react':
specifier: ^5.1.1
version: 5.1.1(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
ast-kit:
specifier: 2.2.0
version: 2.2.0
@@ -888,10 +888,10 @@ importers:
version: 0.15.4
vite-plugin-html:
specifier: 3.2.2
version: 3.2.2(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
version: 3.2.2(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
vite-plugin-pwa:
specifier: 1.1.0
version: 1.1.0(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
version: 1.1.0(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
be:
dependencies:
@@ -977,6 +977,9 @@ importers:
drizzle-orm:
specifier: ^0.44.7
version: 0.44.7(@types/pg@8.15.6)(@vercel/postgres@0.10.0)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7)
ejs:
specifier: 3.1.10
version: 3.1.10
hono:
specifier: 4.10.7
version: 4.10.7
@@ -995,6 +998,9 @@ importers:
reflect-metadata:
specifier: 0.2.2
version: 0.2.2
resend:
specifier: 6.5.2
version: 6.5.2
satori:
specifier: 0.18.3
version: 0.18.3
@@ -1005,6 +1011,9 @@ importers:
specifier: ^4.1.13
version: 4.1.13
devDependencies:
'@types/ejs':
specifier: 3.1.5
version: 3.1.5
'@types/mime-types':
specifier: 3.0.1
version: 3.0.1
@@ -1608,7 +1617,7 @@ importers:
version: 0.16.7(synckit@0.11.11)(typescript@5.9.3)
unplugin-dts:
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.27.0)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.27.0)(rolldown@1.0.0-beta.51)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
vite:
specifier: 7.2.4
version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
@@ -5659,6 +5668,9 @@ packages:
'@splinetool/runtime@0.9.526':
resolution: {integrity: sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@@ -6091,6 +6103,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/ejs@3.1.5':
resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -6149,6 +6164,9 @@ packages:
'@types/node@20.19.25':
resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==}
'@types/node@22.19.1':
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
'@types/node@24.10.1':
resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==}
@@ -7877,6 +7895,9 @@ packages:
es-toolkit@1.42.0:
resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
es6-promise@4.2.8:
resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
esast-util-from-estree@2.0.0:
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
@@ -8276,6 +8297,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fast-shallow-equal@1.0.0:
resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==}
@@ -10410,6 +10434,9 @@ packages:
resolution: {integrity: sha512-3jTGGLRzlhu/1ws2zlr4Q+GVMLCQTLFOj8CMX5x44cdZG9FQE07x2mQhaNxaKVPNmIDu0mvJ/cEwtY7Pim7hqA==}
engines: {node: '>=18'}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -11094,9 +11121,21 @@ packages:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
engines: {node: '>=0.10.5'}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resend@6.5.2:
resolution: {integrity: sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -11599,6 +11638,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svix@1.76.1:
resolution: {integrity: sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==}
swiper@12.0.3:
resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==}
engines: {node: '>= 4.7.0'}
@@ -12096,6 +12138,9 @@ packages:
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -12154,6 +12199,10 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
@@ -17356,6 +17405,8 @@ snapshots:
on-change: 4.0.2
semver-compare: 1.0.0
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.0.0': {}
'@stitches/react@1.2.8(react@19.2.0)':
@@ -17782,6 +17833,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/ejs@3.1.5': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -17838,6 +17891,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.19.1':
dependencies:
undici-types: 6.21.0
'@types/node@24.10.1':
dependencies:
undici-types: 7.16.0
@@ -19750,6 +19807,8 @@ snapshots:
es-toolkit@1.42.0: {}
es6-promise@4.2.8: {}
esast-util-from-estree@2.0.0:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -20493,6 +20552,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-sha256@1.3.0: {}
fast-shallow-equal@1.0.0: {}
fast-uri@3.1.0: {}
@@ -22965,6 +23026,8 @@ snapshots:
filter-obj: 5.1.0
split-on-first: 3.0.0
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}
quickselect@3.0.0: {}
@@ -23933,8 +23996,14 @@ snapshots:
requireindex@1.2.0: {}
requires-port@1.0.0: {}
reselect@5.1.1: {}
resend@6.5.2:
dependencies:
svix: 1.76.1
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
@@ -24529,6 +24598,15 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svix@1.76.1:
dependencies:
'@stablelib/base64': 1.0.1
'@types/node': 22.19.1
es6-promise: 4.2.8
fast-sha256: 1.3.0
url-parse: 1.5.10
uuid: 10.0.0
swiper@12.0.3: {}
swr@2.3.6(react@19.2.0):
@@ -24945,7 +25023,7 @@ snapshots:
magic-string-ast: 1.0.3
unplugin: 2.3.10
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.27.0)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)):
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.27.0)(rolldown@1.0.0-beta.51)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.53.3)
'@volar/typescript': 2.4.23
@@ -24959,6 +25037,7 @@ snapshots:
optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.10.1)
esbuild: 0.27.0
rolldown: 1.0.0-beta.51
rollup: 4.53.3
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
transitivePeerDependencies:
@@ -25038,6 +25117,11 @@ snapshots:
url-join@5.0.0: {}
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.0):
dependencies:
react: 19.2.0
@@ -25085,6 +25169,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@11.1.0: {}
v8n@1.5.1: {}
@@ -25181,7 +25267,7 @@ snapshots:
optionator: 0.9.4
typescript: 5.9.3
vite-plugin-html@3.2.2(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)):
vite-plugin-html@3.2.2(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 4.2.1
colorette: 2.0.20
@@ -25195,14 +25281,14 @@ snapshots:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
vite-plugin-pwa@1.1.0(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0):
vite-plugin-pwa@1.1.0(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
pretty-bytes: 6.1.1
tinyglobby: 0.2.15
vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
workbox-build: 7.3.0(@types/babel__core@7.20.5)
workbox-window: 7.3.0
transitivePeerDependencies:

View File

@@ -228,7 +228,7 @@
```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:exif="https://afilmory.com/rss/exif">
<rss version="2.0" xmlns:exif="https://afilmory.art/rss/exif">
<channel>
<title><![CDATA[我的风景摄影画廊]]></title>
<link>https://example.com</link>

View File

@@ -45,7 +45,7 @@ const defaultConfig: SiteConfig = {
name: 'New Afilmory',
title: 'New Afilmory',
description: 'A modern photo gallery website.',
url: 'https://afilmory.com',
url: 'https://afilmory.art',
accentColor: '#007bff',
author: {
name: 'Afilmory',