feat(config): update site configuration and enhance author handling

- Changed default site name and URL to reflect new branding.
- Updated author information in the site configuration, including name, URL, and avatar.
- Removed author-related settings from the configuration schema to streamline the setup.
- Enhanced the SiteSettingService to resolve author details dynamically based on tenant context.
- Added a new endpoint to retrieve the status of photo synchronization, improving user feedback on sync operations.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-15 01:08:31 +08:00
parent 0621909e49
commit dafc621033
42 changed files with 2359 additions and 285 deletions

View File

@@ -34,7 +34,7 @@ export const MasonryHeaderMasonryItem = ({ style, className }: { style?: React.C
)}
<div
className={clsxm(
'from-accent to-accent/80 inline-flex items-center justify-center rounded-2xl bg-gradient-to-br shadow-lg',
'from-accent to-accent/80 inline-flex items-center justify-center rounded-2xl bg-linear-to-br shadow-lg',
siteConfig.author.avatar ? 'size-8 rounded absolute bottom-0 -right-3' : 'size-16 mb-4',
)}
>

View File

@@ -8,7 +8,7 @@ import { createConfiguredApp } from './app.factory'
import { runCliPipeline } from './cli'
import { logger } from './helpers/logger.helper'
process.title = 'Hono HTTP Server'
process.title = 'afilmory core'
async function bootstrap() {
const app = await createConfiguredApp({

View File

@@ -8,26 +8,6 @@ import type { SettingDefinition, SettingMetadata } from './setting.type'
const HEX_COLOR_REGEX = /^#(?:[0-9a-f]{3}){1,2}$/i
function createOptionalUrlSchema(errorMessage: string) {
return z
.string()
.trim()
.superRefine((value, ctx) => {
if (value.length === 0) {
return
}
try {
new URL(value)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: errorMessage,
})
}
})
}
function createJsonStringArraySchema(options: {
allowEmpty?: boolean
validator?: (value: unknown) => boolean
@@ -138,18 +118,6 @@ export const DEFAULT_SETTING_DEFINITIONS = {
}
}),
},
'site.author.name': {
isSensitive: false,
schema: z.string().trim().min(1, 'Author name cannot be empty'),
},
'site.author.url': {
isSensitive: false,
schema: z.url('Author URL must be a valid URL'),
},
'site.author.avatar': {
isSensitive: false,
schema: createOptionalUrlSchema('Author avatar must be a valid URL'),
},
'site.social.twitter': {
isSensitive: false,
schema: z.string().trim(),

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Get, Post } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
import { UpdateSiteSettingsDto } from './site-setting.dto'
import { UpdateSiteAuthorDto, UpdateSiteSettingsDto } from './site-setting.dto'
import { SiteSettingService } from './site-setting.service'
@Controller('site/settings')
@@ -21,4 +21,14 @@ export class SiteSettingController {
await this.siteSettingService.setMany(entries)
return { updated: entries }
}
@Get('/author')
async getAuthorProfile() {
return await this.siteSettingService.getAuthorProfile()
}
@Post('/author')
async updateAuthorProfile(@Body() payload: UpdateSiteAuthorDto) {
return await this.siteSettingService.updateAuthorProfile(payload)
}
}

View File

@@ -21,3 +21,12 @@ export class UpdateSiteSettingsDto extends createZodDto(
entries: z.array(entrySchema).min(1),
}),
) {}
const updateAuthorSchema = z.object({
name: z.string().trim().min(1, '作者名称不能为空'),
displayUsername: z.string().optional().nullable(),
username: z.string().optional().nullable(),
avatar: z.string().optional().nullable(),
})
export class UpdateSiteAuthorDto extends createZodDto(updateAuthorSchema) {}

View File

@@ -1,4 +1,5 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from 'core/database/database.module'
import { SettingModule } from '../setting/setting.module'
import { SiteSettingController } from './site-setting.controller'
@@ -6,7 +7,7 @@ import { SiteSettingPublicController } from './site-setting.public.controller'
import { SiteSettingService } from './site-setting.service'
@Module({
imports: [SettingModule],
imports: [SettingModule, DatabaseModule],
controllers: [SiteSettingController, SiteSettingPublicController],
providers: [SiteSettingService],
})

View File

@@ -1,3 +1,8 @@
import { authUsers } from '@afilmory/db'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { asc, eq, sql } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { UiNode } from '../../ui/ui-schema/ui-schema.type'
@@ -9,7 +14,10 @@ import { SITE_SETTING_UI_SCHEMA, SITE_SETTING_UI_SCHEMA_KEYS } from './site-sett
@injectable()
export class SiteSettingService {
constructor(private readonly settingService: SettingService) {}
constructor(
private readonly settingService: SettingService,
private readonly dbAccessor: DbAccessor,
) {}
async getUiSchema(): Promise<SiteSettingUiSchemaResponse> {
const values = await this.settingService.getMany(SITE_SETTING_UI_SCHEMA_KEYS, {})
@@ -66,11 +74,12 @@ export class SiteSettingService {
assignString(values['site.url'], (value) => (config.url = value))
assignString(values['site.accentColor'], (value) => (config.accentColor = value))
assignString(values['site.author.name'], (value) => (config.author.name = value))
assignString(values['site.author.url'], (value) => (config.author.url = value))
assignString(values['site.author.avatar'], (value) => {
config.author.avatar = value
})
const resolvedAuthor = await this.resolveAuthorFromTenant(config.name)
if (resolvedAuthor) {
config.author = resolvedAuthor
} else if (!config.author.name) {
config.author.name = config.name
}
const social = buildSocialConfig(values)
if (social) {
@@ -97,6 +106,131 @@ export class SiteSettingService {
return config
}
async getAuthorProfile(): Promise<SiteAuthorProfile> {
const user = await this.findPrimaryAuthorUser()
if (!user) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前租户缺少可更新的作者账号,请先创建管理员账号。',
})
}
return toSiteAuthorProfile(user)
}
async updateAuthorProfile(input: UpdateSiteAuthorInput): Promise<SiteAuthorProfile> {
const user = await this.findPrimaryAuthorUser()
if (!user) {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '当前租户缺少可更新的作者账号,请先创建管理员账号。',
})
}
const name = normalizeString(input.name)
if (!name) {
throw new BizException(ErrorCode.COMMON_VALIDATION, { message: '作者名称不能为空' })
}
const displayUsername = normalizeOptionalString(input.displayUsername)
const username = normalizeOptionalString(input.username)
const avatar = this.normalizeAvatarInput(input.avatar)
const now = new Date().toISOString()
const db = this.dbAccessor.get()
await db
.update(authUsers)
.set({
name,
displayUsername,
username,
image: avatar,
updatedAt: now,
})
.where(eq(authUsers.id, user.id))
return toSiteAuthorProfile({
...user,
name,
displayUsername,
username,
image: avatar,
updatedAt: now,
})
}
private async resolveAuthorFromTenant(siteName: string): Promise<SiteConfigAuthor | null> {
const user = await this.findPrimaryAuthorUser()
if (!user) {
return null
}
const fallbackName = normalizeString(siteName) ?? siteName
const normalizedName =
normalizeString(user.displayUsername) ??
normalizeString(user.username) ??
normalizeString(user.name) ??
fallbackName
const author: SiteConfigAuthor = {
name: normalizedName,
}
const avatar = normalizeString(user.image)
if (avatar) {
author.avatar = avatar
}
return author
}
private async findPrimaryAuthorUser(): Promise<AuthorUserRecord | null> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const [user] = await db
.select({
id: authUsers.id,
name: authUsers.name,
email: authUsers.email,
displayUsername: authUsers.displayUsername,
username: authUsers.username,
image: authUsers.image,
role: authUsers.role,
createdAt: authUsers.createdAt,
updatedAt: authUsers.updatedAt,
})
.from(authUsers)
.where(eq(authUsers.tenantId, tenant.tenant.id))
.orderBy(
sql`case when ${authUsers.role} = 'admin' then 0 when ${authUsers.role} = 'superadmin' then 1 else 2 end`,
asc(authUsers.createdAt),
)
.limit(1)
return user ?? null
}
private normalizeAvatarInput(value: string | null | undefined): string | null {
const normalized = normalizeString(value)
if (!normalized) {
return null
}
if (normalized.startsWith('//')) {
return normalized
}
try {
const url = new URL(normalized)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('Invalid protocol')
}
return url.toString()
} catch {
throw new BizException(ErrorCode.COMMON_VALIDATION, {
message: '头像链接必须是以 http(s) 或 // 开头的有效 URL',
})
}
}
private filterSchema(
schema: SiteSettingUiSchemaResponse['schema'],
allowed: Set<SiteSettingKey>,
@@ -130,9 +264,39 @@ export class SiteSettingService {
}
}
interface SiteAuthorProfile {
id: string
email: string
name: string
username: string | null
displayUsername: string | null
avatar: string | null
createdAt: string
updatedAt: string
}
interface UpdateSiteAuthorInput {
name: string
displayUsername?: string | null
username?: string | null
avatar?: string | null
}
type AuthorUserRecord = {
id: string
name: string
email: string
username: string | null
displayUsername: string | null
image: string | null
role: string
createdAt: string
updatedAt: string
}
interface SiteConfigAuthor {
name: string
url: string
url?: string
avatar?: string
}
@@ -177,7 +341,6 @@ const DEFAULT_SITE_CONFIG: SiteConfig = {
accentColor: '#007bff',
author: {
name: '',
url: '',
},
}
@@ -199,6 +362,24 @@ function normalizeString(value: string | null | undefined): string | undefined {
return trimmed.length > 0 ? trimmed : undefined
}
function normalizeOptionalString(value: string | null | undefined): string | null {
const normalized = normalizeString(value)
return normalized ?? null
}
function toSiteAuthorProfile(user: AuthorUserRecord): SiteAuthorProfile {
return {
id: user.id,
email: user.email,
name: user.name,
username: user.username ?? null,
displayUsername: user.displayUsername ?? null,
avatar: user.image ?? null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
}
function parseJsonStringArray(value: string | null | undefined): string[] | undefined {
const normalized = normalizeString(value)
if (!normalized) {

View File

@@ -8,9 +8,6 @@ export const SITE_SETTING_KEYS = [
'site.description',
'site.url',
'site.accentColor',
'site.author.name',
'site.author.url',
'site.author.avatar',
'site.social.twitter',
'site.social.github',
'site.social.rss',

View File

@@ -89,60 +89,11 @@ export const SITE_SETTING_UI_SCHEMA: UiSchema<SiteSettingKey> = {
},
{
type: 'section',
id: 'site-author-social',
title: '作者与社交',
description: '展示在站点关于信息与页脚的联系人和社交账号。',
icon: 'user-round',
id: 'site-social',
title: '社交与订阅',
description: '配置展示在站点页脚与关于区域的社交账号与订阅入口。',
icon: 'share-2',
children: [
{
type: 'group',
id: 'site-author-group',
title: '作者信息',
icon: 'id-card',
children: [
{
type: 'field',
id: 'site-author-name',
title: '作者名称',
key: 'site.author.name',
required: true,
component: {
type: 'text',
placeholder: '请输入作者名称',
autoComplete: 'name',
},
icon: 'user-circle',
},
{
type: 'field',
id: 'site-author-url',
title: '作者主页链接',
key: 'site.author.url',
required: true,
component: {
type: 'text',
inputType: 'url',
placeholder: 'https://innei.in/',
autoComplete: 'url',
},
icon: 'link',
},
{
type: 'field',
id: 'site-author-avatar',
title: '头像地址',
description: '可选,展示在站点顶部与分享卡片中。',
helperText: '留空则不显示头像。',
key: 'site.author.avatar',
component: {
type: 'text',
inputType: 'url',
placeholder: 'https://cdn.example.com/avatar.png',
},
icon: 'image',
},
],
},
{
type: 'group',
id: 'site-social-group',
@@ -194,7 +145,7 @@ export const SITE_SETTING_UI_SCHEMA: UiSchema<SiteSettingKey> = {
type: 'section',
id: 'site-feed',
title: 'Feed 设置',
description: '配置第三方 Feed 数据源,用于聚合内容或挑战进度。',
description: '配置第三方 Feed 数据源,用于聚合内容',
icon: 'radio',
children: [
{

View File

@@ -13,6 +13,7 @@ import type {
DataSyncConflict,
DataSyncProgressEmitter,
DataSyncProgressEvent,
DataSyncStatus,
} from './data-sync.types'
@Controller('data-sync')
@@ -52,6 +53,11 @@ export class DataSyncController {
})
}
@Get('status')
async status(): Promise<DataSyncStatus> {
return await this.dataSyncService.getStatus()
}
@Get('conflicts')
async listConflicts(): Promise<DataSyncConflict[]> {
return await this.dataSyncService.listConflicts()

View File

@@ -1,13 +1,13 @@
import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageManager, StorageObject } from '@afilmory/builder'
import type { PhotoAssetConflictPayload, PhotoAssetConflictSnapshot, PhotoAssetManifest } from '@afilmory/db'
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db'
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets, photoSyncRuns } from '@afilmory/db'
import { createLogger, EventEmitterService } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service'
import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { and, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type {
@@ -20,7 +20,9 @@ import type {
DataSyncProgressStage,
DataSyncResult,
DataSyncResultSummary,
DataSyncRunRecord,
DataSyncStageTotals,
DataSyncStatus,
ResolveConflictOptions,
SyncObjectSnapshot,
} from './data-sync.types'
@@ -45,6 +47,8 @@ type StatusReconciliationEntry = {
storageSnapshot: SyncObjectSnapshot
}
type PhotoSyncRunRow = typeof photoSyncRuns.$inferSelect
interface SyncPreparation {
tenantId: string
builder: ReturnType<PhotoBuilderService['createBuilder']>
@@ -78,6 +82,7 @@ export class DataSyncService {
async runSync(options: DataSyncOptions, onProgress?: DataSyncProgressEmitter): Promise<DataSyncResult> {
const tenant = requireTenantContext()
const runStartedAt = new Date()
const { builderConfig, storageConfig } = await this.resolveBuilderConfigForTenant(tenant.tenant.id, options)
const context = await this.prepareSyncContext(tenant.tenant.id, builderConfig, storageConfig)
const summary = this.createSummary(context)
@@ -170,9 +175,33 @@ export class DataSyncService {
}
}
await this.recordSyncRun(tenant.tenant.id, {
dryRun: options.dryRun,
summary: { ...summary },
actionsCount: actions.length,
startedAt: runStartedAt,
completedAt: new Date(),
})
return result
}
async getStatus(): Promise<DataSyncStatus> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const [record] = await db
.select()
.from(photoSyncRuns)
.where(eq(photoSyncRuns.tenantId, tenant.tenant.id))
.orderBy(desc(photoSyncRuns.completedAt))
.limit(1)
return {
lastRun: record ? this.mapSyncRunRecord(record) : null,
}
}
async listConflicts(): Promise<DataSyncConflict[]> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
@@ -225,6 +254,40 @@ export class DataSyncService {
return action
}
private async recordSyncRun(
tenantId: string,
payload: {
dryRun: boolean
summary: DataSyncResultSummary
actionsCount: number
startedAt: Date
completedAt: Date
},
): Promise<void> {
const db = this.dbAccessor.get()
const record: typeof photoSyncRuns.$inferInsert = {
tenantId,
dryRun: payload.dryRun,
summary: payload.summary,
actionsCount: payload.actionsCount,
startedAt: payload.startedAt.toISOString(),
completedAt: payload.completedAt.toISOString(),
}
await db.insert(photoSyncRuns).values(record)
}
private mapSyncRunRecord(record: PhotoSyncRunRow): DataSyncRunRecord {
return {
id: record.id,
dryRun: record.dryRun,
summary: record.summary,
actionsCount: record.actionsCount,
startedAt: record.startedAt,
completedAt: record.completedAt,
}
}
private async prepareSyncContext(
tenantId: string,
builderConfig: BuilderConfig,

View File

@@ -55,6 +55,19 @@ export interface DataSyncResult {
actions: DataSyncAction[]
}
export interface DataSyncRunRecord {
id: string
dryRun: boolean
summary: DataSyncResultSummary
actionsCount: number
startedAt: string
completedAt: string
}
export interface DataSyncStatus {
lastRun: DataSyncRunRecord | null
}
export interface DataSyncOptions {
builderConfig?: BuilderConfig
storageConfig?: StorageConfig

View File

@@ -17,20 +17,20 @@ export function Header() {
return (
<header className="bg-background relative shrink-0 border-b border-fill-tertiary/50">
<div className="flex h-14 items-center px-6">
<div className="flex h-14 items-center px-3 sm:px-6">
{/* Logo/Brand */}
<a href="/" className="text-text mr-8 text-base font-semibold tracking-tight">
<a href="/" className="text-text mr-2 sm:mr-8 text-sm sm:text-base font-semibold tracking-tight">
Afilmory
</a>
{/* Navigation Tabs */}
<nav className="flex flex-1 items-center gap-1">
<nav className="flex flex-1 items-center gap-0.5 sm:gap-1 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{navigationTabs.map((tab) => (
<NavLink key={tab.path} to={tab.path} end={tab.path === '/'}>
{({ isActive }) => (
<div
className={clsxm(
'relative rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200',
'relative rounded-lg px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium transition-all duration-200 whitespace-nowrap',
'hover:bg-fill/30',
isActive ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:text-text',
)}
@@ -44,7 +44,7 @@ export function Header() {
{/* Right side - User Menu */}
{user && (
<div className="border-fill-tertiary/50 ml-auto border-l pl-4">
<div className="border-fill-tertiary/50 ml-2 sm:ml-auto border-l pl-2 sm:pl-4">
<UserMenu user={user} />
</div>
)}

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuTrigger,
} from '@afilmory/ui'
import { clsxm } from '@afilmory/utils'
import { ChevronDown, LogOut, Settings, User as UserIcon } from 'lucide-react'
import { LogOut, Settings, User as UserIcon } from 'lucide-react'
import { useState } from 'react'
import { Link } from 'react-router'
@@ -64,11 +64,6 @@ export function UserMenu({ user }: UserMenuProps) {
<div className="text-text text-sm leading-tight font-medium">{user.name || user.email}</div>
<div className="text-text-tertiary text-[10px] leading-tight capitalize">{user.role}</div>
</div>
{/* Chevron Icon */}
<ChevronDown
className={clsxm('text-text-tertiary size-3.5 transition-transform duration-200', isOpen && 'rotate-180')}
/>
</button>
</DropdownMenuTrigger>

View File

@@ -72,19 +72,19 @@ function MainPageLayoutBase({ title, description, actions, footer, children }: M
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="mt-8 space-y-6"
className="mt-4 sm:mt-8 space-y-4 sm:space-y-6"
>
{/* Header - Sharp edges with gradient borders */}
<header className="relative flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<header className="relative flex flex-col gap-3 sm:gap-4 md:flex-row md:items-start md:justify-between">
{/* Linear gradient borders */}
<div className="relative space-y-1.5">
<h1 className="text-text text-2xl font-semibold">{title}</h1>
{description ? <p className="text-text-secondary text-sm">{description}</p> : null}
<div className="relative space-y-1 sm:space-y-1.5">
<h1 className="text-text text-xl sm:text-2xl font-semibold">{title}</h1>
{description ? <p className="text-text-secondary text-xs sm:text-sm">{description}</p> : null}
</div>
{showHeaderActions ? (
<div className="relative flex flex-wrap items-center gap-2 md:justify-end">
<div className="flex flex-wrap items-center gap-2 md:justify-end absolute md:translate-y-0 -translate-y-1 right-0 md:relative">
{actions}
<div ref={assignActionsContainer} className="flex flex-wrap items-center gap-2" />
</div>

View File

@@ -107,8 +107,8 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
const maxUploads = data.reduce((max, point) => Math.max(max, point.uploads), 0)
return (
<div className="mt-6 overflow-x-auto pb-2">
<div className="flex h-44 min-w-[480px] items-end gap-3">
<div className="mt-4 sm:mt-6 overflow-x-auto pb-2">
<div className="flex h-36 sm:h-44 min-w-[360px] sm:min-w-[480px] items-end gap-2 sm:gap-3">
{data.map((point, index) => {
const basePercent = maxUploads === 0 ? 0 : (point.uploads / maxUploads) * 100
const heightPercent = Math.max(basePercent, point.uploads > 0 ? 8 : 0)
@@ -121,10 +121,10 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
className="group flex min-w-[32px] flex-1 flex-col items-center gap-1"
className="group flex min-w-[24px] sm:min-w-[32px] flex-1 flex-col items-center gap-0.5 sm:gap-1"
title={`${fullLabel} · ${plainNumberFormatter.format(point.uploads)}`}
>
<div className="relative flex h-40 w-full items-end">
<div className="relative flex h-32 sm:h-40 w-full items-end">
<div
className="bg-accent/70 group-hover:bg-accent absolute right-0 bottom-0 shape-squircle left-0 mb-2 transition-colors duration-200"
style={{ height: `${heightPercent}%` }}
@@ -144,11 +144,11 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUsage[]; totalBytes: number }) {
if (providers.length === 0) {
return <div className="text-text-tertiary mt-5 text-sm">使</div>
return <div className="text-text-tertiary mt-4 sm:mt-5 text-xs sm:text-sm">使</div>
}
return (
<div className="mt-5 space-y-3">
<div className="mt-4 sm:mt-5 space-y-2.5 sm:space-y-3">
{providers.map((provider, index) => {
const ratio = totalBytes > 0 ? provider.bytes / totalBytes : 0
const percent = Math.round(ratio * 100)
@@ -158,13 +158,13 @@ function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUs
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
className="space-y-1.5"
className="space-y-1 sm:space-y-1.5"
>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center justify-between text-xs sm:text-sm">
<span className="text-text capitalize">{provider.provider}</span>
<span className="text-text-secondary">
<span className="text-text-secondary text-right">
{formatBytes(provider.bytes)}
<span className="text-text-tertiary ml-2">
<span className="text-text-tertiary ml-1 sm:ml-2 text-[11px] sm:text-xs">
{percent}% · {provider.photoCount}
</span>
</span>
@@ -184,13 +184,13 @@ function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUs
function RankedList({ items, emptyText }: { items: Array<{ label: string; value: number }>; emptyText: string }) {
if (items.length === 0) {
return <div className="text-text-tertiary mt-4 text-sm">{emptyText}</div>
return <div className="text-text-tertiary mt-3 sm:mt-4 text-xs sm:text-sm">{emptyText}</div>
}
const maxValue = items.reduce((max, item) => Math.max(max, item.value), 0)
return (
<div className="mt-4 space-y-2.5">
<div className="mt-3 sm:mt-4 space-y-2 sm:space-y-2.5">
{items.map((item, index) => {
const ratio = maxValue > 0 ? item.value / maxValue : 0
return (
@@ -199,13 +199,15 @@ function RankedList({ items, emptyText }: { items: Array<{ label: string; value:
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
className="flex items-center gap-3 text-sm"
className="flex items-center gap-2 sm:gap-3 text-xs sm:text-sm"
>
<div className="text-text-tertiary w-6 text-right text-[11px]">#{index + 1}</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div className="text-text-tertiary w-5 sm:w-6 text-right text-[10px] sm:text-[11px]">#{index + 1}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-text truncate">{item.label}</span>
<span className="text-text-secondary text-[13px]">{plainNumberFormatter.format(item.value)}</span>
<span className="text-text-secondary text-[11px] sm:text-[13px] shrink-0">
{plainNumberFormatter.format(item.value)}
</span>
</div>
<div className="bg-fill/15 mt-1.5 h-2 rounded-full">
<div
@@ -233,10 +235,10 @@ function SectionPanel({
children: ReactNode
}) {
return (
<LinearBorderPanel className={clsxm('bg-background-tertiary p-5', className)}>
<div className="space-y-2">
<h2 className="text-text text-sm font-semibold">{title}</h2>
<p className="text-text-tertiary text-[13px] leading-relaxed">{description}</p>
<LinearBorderPanel className={clsxm('bg-background-tertiary p-4 sm:p-5', className)}>
<div className="space-y-1.5 sm:space-y-2">
<h2 className="text-text text-xs sm:text-sm font-semibold">{title}</h2>
<p className="text-text-tertiary text-[12px] sm:text-[13px] leading-relaxed">{description}</p>
</div>
{children}
</LinearBorderPanel>
@@ -286,7 +288,7 @@ export function DashboardAnalytics() {
return (
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-3 sm:gap-4 grid-cols-1 md:grid-cols-2">
<SectionPanel title="Upload Trends" description="近 12 个月的上传趋势">
{isLoading ? (
<TrendSkeleton />
@@ -295,7 +297,7 @@ export function DashboardAnalytics() {
) : data?.uploadTrends?.length ? (
<>
{uploadTrendStats ? (
<div className="mt-5 grid gap-3 text-sm">
<div className="mt-4 sm:mt-5 grid gap-2 sm:gap-3 text-xs sm:text-sm">
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
@@ -304,9 +306,9 @@ export function DashboardAnalytics() {
</div>
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
{formatFullMonth(uploadTrendStats.bestMonth.month)}
<span className="text-text-tertiary ml-2 text-[13px]">
<span className="text-text font-semibold text-right">
<span className="block sm:inline">{formatFullMonth(uploadTrendStats.bestMonth.month)}</span>
<span className="text-text-tertiary ml-0 sm:ml-2 text-[11px] sm:text-[13px]">
{plainNumberFormatter.format(uploadTrendStats.bestMonth.uploads)}
</span>
</span>
@@ -318,7 +320,7 @@ export function DashboardAnalytics() {
{uploadTrendStats.growth !== null ? (
<span
className={clsxm(
'ml-2 text-[13px]',
'ml-1 sm:ml-2 text-[11px] sm:text-[13px]',
uploadTrendStats.growth >= 0 ? 'text-emerald-300' : 'text-red-300',
)}
>
@@ -327,7 +329,7 @@ export function DashboardAnalytics() {
: `${uploadTrendStats.growth >= 0 ? '+' : ''}${percentFormatter.format(uploadTrendStats.growth)}`}
</span>
) : uploadTrendStats.previousMonth ? (
<span className="text-text-tertiary ml-2 text-[13px]">
<span className="text-text-tertiary ml-1 sm:ml-2 text-[11px] sm:text-[13px]">
{uploadTrendStats.delta > 0 ? '首次出现上传记录' : '与上月持平'}
</span>
) : null}
@@ -339,7 +341,7 @@ export function DashboardAnalytics() {
<UploadTrendsChart data={data.uploadTrends} />
</>
) : (
<div className="text-text-tertiary mt-6 text-sm"></div>
<div className="text-text-tertiary mt-4 sm:mt-6 text-xs sm:text-sm"></div>
)}
</SectionPanel>
@@ -364,10 +366,10 @@ export function DashboardAnalytics() {
return (
<>
<div className="mt-5 grid gap-3 text-sm">
<div className="mt-4 sm:mt-5 grid gap-2 sm:gap-3 text-xs sm:text-sm">
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">{formatBytes(storageUsage.totalBytes)}</span>
<span className="text-text font-semibold text-right">{formatBytes(storageUsage.totalBytes)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
@@ -377,9 +379,11 @@ export function DashboardAnalytics() {
</div>
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
{formatBytes(storageUsage.currentMonthBytes)}
<span className="text-text-tertiary ml-2 text-[13px]">{monthDeltaDescription}</span>
<span className="text-text font-semibold text-right">
<span className="block sm:inline">{formatBytes(storageUsage.currentMonthBytes)}</span>
<span className="text-text-tertiary ml-0 sm:ml-2 text-[11px] sm:text-[13px]">
{monthDeltaDescription}
</span>
</span>
</div>
</div>

View File

@@ -88,12 +88,12 @@ export function SocialConnectionSettings() {
if (isLoading) {
return (
<LinearBorderPanel className="p-6">
<div className="space-y-6">
<SkeletonBlock className="h-5 w-2/5" />
<div className="space-y-4">
<LinearBorderPanel className="p-4 sm:p-6">
<div className="space-y-4 sm:space-y-6">
<SkeletonBlock className="h-4 sm:h-5 w-2/5" />
<div className="space-y-3 sm:space-y-4">
{[1, 2, 3].map((key) => (
<SkeletonBlock key={key} className="h-20" />
<SkeletonBlock key={key} className="h-16 sm:h-20" />
))}
</div>
</div>
@@ -103,9 +103,9 @@ export function SocialConnectionSettings() {
if (hasError && errorMessage) {
return (
<LinearBorderPanel className="p-6">
<div className="text-red flex items-center gap-3 text-sm">
<i className="i-mingcute-close-circle-fill text-lg" />
<LinearBorderPanel className="p-4 sm:p-6">
<div className="text-red flex items-center gap-2 sm:gap-3 text-xs sm:text-sm">
<i className="i-mingcute-close-circle-fill text-base sm:text-lg" />
<span>{errorMessage}</span>
</div>
</LinearBorderPanel>
@@ -114,10 +114,10 @@ export function SocialConnectionSettings() {
if (providers.length === 0) {
return (
<LinearBorderPanel className="p-6">
<div className="flex flex-col gap-3">
<p className="text-base font-semibold"> OAuth Provider</p>
<p className="text-text-tertiary text-sm">
<LinearBorderPanel className="p-4 sm:p-6">
<div className="flex flex-col gap-2 sm:gap-3">
<p className="text-sm sm:text-base font-semibold"> OAuth Provider</p>
<p className="text-text-tertiary text-xs sm:text-sm">
OAuth
</p>
</div>
@@ -126,17 +126,17 @@ export function SocialConnectionSettings() {
}
return (
<LinearBorderPanel className="p-6">
<div className="space-y-6">
<LinearBorderPanel className="p-4 sm:p-6">
<div className="space-y-4 sm:space-y-6">
<div>
<p className="text-text-tertiary text-sm font-semibold tracking-wide uppercase"></p>
<h2 className="mt-1 text-2xl font-semibold">OAuth </h2>
<p className="text-text-tertiary mt-2 text-sm">
<p className="text-text-tertiary text-xs sm:text-sm font-semibold tracking-wide uppercase"></p>
<h2 className="mt-1 text-xl sm:text-2xl font-semibold">OAuth </h2>
<p className="text-text-tertiary mt-1.5 sm:mt-2 text-xs sm:text-sm">
使
</p>
</div>
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{providers.map((provider) => {
const linkedAccount = accountsByProvider.get(provider.id)
const isLinking = linkMutation.isPending && linkingProvider === provider.id
@@ -146,24 +146,26 @@ export function SocialConnectionSettings() {
return (
<div
key={provider.id}
className="flex bg-background-secondary rounded-md flex-col gap-4 p-4 transition-colors md:flex-row md:items-center md:justify-between"
className="flex bg-background-secondary rounded-md flex-col gap-3 sm:gap-4 p-3 sm:p-4 transition-colors md:flex-row md:items-center md:justify-between"
>
<div className="flex flex-1 items-center gap-4">
<div className="bg-fill-secondary/60 text-text flex size-12 items-center justify-center rounded-full">
<i className={cx('text-2xl', provider.icon)} aria-hidden />
<div className="flex flex-1 items-center gap-3 sm:gap-4">
<div className="bg-fill-secondary/60 text-text flex size-10 sm:size-12 items-center justify-center rounded-full shrink-0">
<i className={cx('text-xl sm:text-2xl', provider.icon)} aria-hidden />
</div>
<div className="min-w-0">
<p className="text-base leading-tight font-semibold">{provider.name}</p>
<div className="min-w-0 flex-1">
<p className="text-sm sm:text-base leading-tight font-semibold">{provider.name}</p>
{linkedAccount ? (
<p className="text-text-tertiary mt-1 text-xs">
<p className="text-text-tertiary mt-0.5 sm:mt-1 text-[11px] sm:text-xs">
· {formatTimestamp(linkedAccount.createdAt)}
</p>
) : (
<p className="text-text-tertiary mt-1 text-xs"></p>
<p className="text-text-tertiary mt-0.5 sm:mt-1 text-[11px] sm:text-xs">
</p>
)}
</div>
</div>
<div className="flex flex-col items-start gap-1 md:items-end">
<div className="flex flex-col items-stretch sm:items-start gap-1.5 sm:gap-1 md:items-end w-full sm:w-auto">
{linkedAccount ? (
<>
<Button
@@ -174,11 +176,12 @@ export function SocialConnectionSettings() {
isLoading={isUnlinking}
loadingText="解绑中…"
onClick={() => handleDisconnect(provider.id, provider.name, linkedAccount.accountId)}
className="w-full sm:w-auto"
>
</Button>
{isLastLinkedProvider ? (
<p className="text-text-tertiary text-xs"></p>
<p className="text-text-tertiary text-[11px] sm:text-xs"></p>
) : null}
</>
) : (
@@ -190,6 +193,7 @@ export function SocialConnectionSettings() {
isLoading={isLinking}
loadingText="跳转中…"
onClick={() => handleConnect(provider.id, provider.name)}
className="w-full sm:w-auto"
>
{provider.name}
</Button>

View File

@@ -172,9 +172,9 @@ function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
className="group px-3.5 py-3 transition-colors duration-200"
>
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<div className="bg-fill/10 relative h-11 w-11 shrink-0 overflow-hidden rounded-lg">
<div className="flex flex-col gap-2 sm:gap-2.5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2 sm:gap-3">
<div className="bg-fill/10 relative h-10 w-10 sm:h-11 sm:w-11 shrink-0 overflow-hidden rounded-lg">
{item.previewUrl ? (
<img src={item.previewUrl} alt={item.title} className="size-full object-cover" loading="lazy" />
) : (
@@ -183,9 +183,9 @@ function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="text-text truncate text-sm font-semibold">{item.title}</div>
<div className="text-text-tertiary text-xs leading-relaxed">
<div className="min-w-0 flex-1 space-y-1 sm:space-y-1.5">
<div className="text-text truncate text-xs sm:text-sm font-semibold">{item.title}</div>
<div className="text-text-tertiary text-[11px] sm:text-xs leading-relaxed">
<span> {formatRelativeTime(item.createdAt)}</span>
{takenAtText ? (
<>
@@ -284,33 +284,36 @@ export function DashboardOverview() {
return (
<MainPageLayout title="Dashboard" description="掌握图库运行状态与最近同步活动">
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-4 sm:space-y-5">
<div className="grid gap-3 sm:gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{isLoading
? Array.from({ length: 4 }, (_, i) => `skeleton-${i}`).map((key) => <StatSkeleton key={key} />)
: statCards.map((card, index) => (
<LinearBorderPanel key={card.key} className="bg-background-tertiary/60 relative overflow-hidden p-5">
<LinearBorderPanel
key={card.key}
className="bg-background-tertiary/60 relative overflow-hidden p-4 sm:p-5"
>
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
className="space-y-2.5"
className="space-y-2 sm:space-y-2.5"
>
<span className="text-text-secondary text-xs font-medium tracking-wide uppercase">
<span className="text-text-secondary text-[10px] sm:text-xs font-medium tracking-wide uppercase">
{card.label}
</span>
<div className="text-text text-2xl font-semibold">{card.value}</div>
<div className="text-text-tertiary text-xs leading-relaxed">{card.helper}</div>
<div className="text-text text-xl sm:text-2xl font-semibold">{card.value}</div>
<div className="text-text-tertiary text-[11px] sm:text-xs leading-relaxed">{card.helper}</div>
</m.div>
</LinearBorderPanel>
))}
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
<div className="space-y-1.5">
<h2 className="text-text text-base font-semibold"></h2>
<p className="text-text-tertiary text-sm leading-relaxed">
<div className="grid gap-4 sm:gap-5 grid-cols-1 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-4 sm:px-5 py-4 sm:py-5">
<div className="space-y-1 sm:space-y-1.5">
<h2 className="text-text text-sm sm:text-base font-semibold"></h2>
<p className="text-text-tertiary text-xs sm:text-sm leading-relaxed">
{data?.recentActivity?.length
? `展示最近 ${data.recentActivity.length} 次上传和同步记录`
: '还没有任何上传,快来添加第一张照片吧~'}
@@ -330,10 +333,12 @@ export function DashboardOverview() {
)}
</LinearBorderPanel>
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
<div className="space-y-1.5">
<h2 className="text-text text-base font-semibold"></h2>
<p className="text-text-tertiary text-sm leading-relaxed"></p>
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-4 sm:px-5 py-4 sm:py-5">
<div className="space-y-1 sm:space-y-1.5">
<h2 className="text-text text-sm sm:text-base font-semibold"></h2>
<p className="text-text-tertiary text-xs sm:text-sm leading-relaxed">
</p>
</div>
<div className="mt-5 space-y-4">

View File

@@ -95,34 +95,40 @@ export function DataManagementPanel() {
}
return (
<div className="space-y-6">
<LinearBorderPanel className="bg-background-secondary/40 p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<span className="shape-squircle inline-flex items-center gap-2 bg-accent/10 px-3 py-1 text-xs font-medium text-accent">
<DynamicIcon name="database" className="h-4 w-4" />
<div className="space-y-4 sm:space-y-6">
<LinearBorderPanel className="bg-background-secondary/40 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3 sm:space-y-4">
<span className="shape-squircle inline-flex items-center gap-2 bg-accent/10 px-2.5 sm:px-3 py-1 text-[11px] sm:text-xs font-medium text-accent">
<DynamicIcon name="database" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
</span>
<div className="space-y-2">
<h3 className="text-text text-xl font-semibold"></h3>
<p className="text-text-secondary text-sm"></p>
<div className="space-y-1.5 sm:space-y-2">
<h3 className="text-text text-lg sm:text-xl font-semibold"></h3>
<p className="text-text-secondary text-xs sm:text-sm">
</p>
</div>
{summaryQuery.isError ? <p className="text-red text-sm"></p> : null}
{summaryQuery.isError ? (
<p className="text-red text-xs sm:text-sm"></p>
) : null}
</div>
<div className="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid w-full gap-3 sm:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-4">
{SUMMARY_STATS.map((stat) => (
<LinearBorderPanel
key={stat.id}
className={clsxm(
'bg-background-tertiary/60 px-4 py-3 shadow-sm backdrop-blur',
'bg-background-tertiary/60 px-3 sm:px-4 py-2.5 sm:py-3 shadow-sm backdrop-blur',
summaryQuery.isLoading && 'animate-pulse',
)}
>
<div className="flex items-center justify-between text-[11px] text-text-tertiary">
<div className="flex items-center justify-between text-[10px] sm:text-[11px] text-text-tertiary">
<span>{stat.label}</span>
<span className="shape-squircle bg-white/5 px-2 py-0.5 font-medium text-white/80">{stat.chip}</span>
<span className="shape-squircle bg-white/5 px-1.5 sm:px-2 py-0.5 font-medium text-white/80 text-[9px] sm:text-[10px]">
{stat.chip}
</span>
</div>
<div className={clsxm('mt-2 text-2xl font-semibold', stat.accent)}>
<div className={clsxm('mt-1.5 sm:mt-2 text-xl sm:text-2xl font-semibold', stat.accent)}>
{summaryQuery.isLoading ? '—' : numberFormatter.format(summary[stat.id])}
</div>
</LinearBorderPanel>
@@ -131,16 +137,16 @@ export function DataManagementPanel() {
</div>
</LinearBorderPanel>
<LinearBorderPanel className="bg-background-secondary/40 p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2 text-red">
<DynamicIcon name="triangle-alert" className="h-4 w-4" />
<span className="text-sm font-semibold"></span>
<LinearBorderPanel className="bg-background-secondary/40 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1.5 sm:space-y-2">
<div className="flex items-center gap-1.5 sm:gap-2 text-red">
<DynamicIcon name="triangle-alert" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
<span className="text-xs sm:text-sm font-semibold"></span>
</div>
<div>
<h4 className="text-text text-lg font-semibold"></h4>
<p className="text-text-secondary text-sm">
<h4 className="text-text text-base sm:text-lg font-semibold"></h4>
<p className="text-text-secondary text-xs sm:text-sm">
</p>
</div>
@@ -152,25 +158,26 @@ export function DataManagementPanel() {
isLoading={truncateMutation.isPending}
loadingText="清理中…"
onClick={handleTruncate}
className="w-full sm:w-auto"
>
</Button>
</div>
<p className="text-text-tertiary mt-4 text-xs">
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">
便使 manifest
</p>
</LinearBorderPanel>
<LinearBorderPanel className="bg-red-500/5 p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2 text-red">
<DynamicIcon name="radiation" className="h-4 w-4" />
<span className="text-sm font-semibold"></span>
<LinearBorderPanel className="bg-red-500/5 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1.5 sm:space-y-2">
<div className="flex items-center gap-1.5 sm:gap-2 text-red">
<DynamicIcon name="radiation" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
<span className="text-xs sm:text-sm font-semibold"></span>
</div>
<div className="space-y-1">
<h4 className="text-text text-lg font-semibold"></h4>
<p className="text-text-secondary text-sm">
<h4 className="text-text text-base sm:text-lg font-semibold"></h4>
<p className="text-text-secondary text-xs sm:text-sm">
</p>
@@ -183,11 +190,12 @@ export function DataManagementPanel() {
onClick={handleDeleteAccount}
loadingText="正在销毁…"
isLoading={deleteTenantMutation.isPending}
className="w-full sm:w-auto"
>
</Button>
</div>
<p className="text-text-tertiary mt-4 text-xs">
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">
使
</p>
</LinearBorderPanel>

View File

@@ -9,6 +9,7 @@ import type {
PhotoSyncProgressEvent,
PhotoSyncResolution,
PhotoSyncResult,
PhotoSyncStatus,
RunPhotoSyncPayload,
} from './types'
@@ -207,3 +208,8 @@ export async function getPhotoStorageUrl(storageKey: string): Promise<string> {
return data.url
}
export async function getPhotoSyncStatus(): Promise<PhotoSyncStatus> {
const status = await coreApi<PhotoSyncStatus>('/data-sync/status')
return camelCaseKeys<PhotoSyncStatus>(status)
}

View File

@@ -15,6 +15,7 @@ import {
usePhotoAssetListQuery,
usePhotoAssetSummaryQuery,
usePhotoSyncConflictsQuery,
usePhotoSyncStatusQuery,
useResolvePhotoSyncConflictMutation,
useUploadPhotoAssetsMutation,
} from '../hooks'
@@ -86,6 +87,14 @@ export function PhotoPage() {
}, [activeTab, selectedIds.length])
const summaryQuery = usePhotoAssetSummaryQuery()
const {
data: syncStatus,
isLoading: isSyncStatusLoading,
isFetching: isSyncStatusFetching,
refetch: refetchSyncStatus,
} = usePhotoSyncStatusQuery({
enabled: activeTab === 'sync',
})
const listQuery = usePhotoAssetListQuery({ enabled: activeTab === 'library' })
const deleteMutation = useDeletePhotoAssetsMutation()
const uploadMutation = useUploadPhotoAssetsMutation()
@@ -233,7 +242,7 @@ export function PhotoPage() {
})
}
},
[setResult, setLastWasDryRun],
[setLastWasDryRun],
)
const handleSyncError = useCallback((error: Error) => {
@@ -291,8 +300,9 @@ export function PhotoPage() {
void summaryQuery.refetch()
void listQuery.refetch()
void refetchSyncStatus()
},
[listQuery, summaryQuery],
[listQuery, summaryQuery, refetchSyncStatus],
)
const handleDeleteSelected = useCallback(() => {
@@ -468,6 +478,8 @@ export function PhotoPage() {
lastWasDryRun={lastWasDryRun}
baselineSummary={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
lastSyncRun={syncStatus?.lastRun ?? null}
isSyncStatusLoading={isSyncStatusLoading || isSyncStatusFetching}
onRequestStorageUrl={getPhotoStorageUrl}
/>
</div>
@@ -511,7 +523,7 @@ export function PhotoPage() {
onSyncError={handleSyncError}
/>
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6">
<PageTabs
activeId={activeTab}
items={[

View File

@@ -55,8 +55,8 @@ export function PhotoLibraryActionBar({
}
return (
<div className="flex w-full relative flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex w-full relative flex-col gap-2 sm:gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<input
ref={fileInputRef}
type="file"
@@ -71,14 +71,15 @@ export function PhotoLibraryActionBar({
size="sm"
disabled={isUploading}
onClick={handleUploadClick}
className="flex items-center gap-1"
className="flex items-center gap-1 text-xs sm:text-sm"
>
<DynamicIcon name="upload" className="h-3.5 w-3.5" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
</div>
<div className="flex min-h-10 absolute right-0 translate-y-20 items-center justify-end gap-2">
<div className="flex min-h-10 absolute right-0 translate-y-25 lg:translate-y-16 items-center justify-end gap-1.5 sm:gap-2 lg:flex-wrap w-full flex-nowrap">
<div
className={clsxm(
'flex items-center gap-2 transition-opacity duration-200',

View File

@@ -1,7 +1,9 @@
import { Button, Modal, Prompt, Thumbhash } from '@afilmory/ui'
import { clsxm } from '@afilmory/utils'
import { useAtomValue } from 'jotai'
import { DynamicIcon } from 'lucide-react/dynamic'
import { viewportAtom } from '~/atoms/viewport'
import { stopPropagation } from '~/lib/dom'
import type { PhotoAssetListItem } from '../../types'
@@ -188,18 +190,13 @@ export function PhotoLibraryGrid({
onDeleteAsset,
isDeleting,
}: PhotoLibraryGridProps) {
const viewport = useAtomValue(viewportAtom)
const columnWidth = viewport.sm ? 320 : 160
if (isLoading) {
const skeletonKeys = [
'photo-skeleton-1',
'photo-skeleton-2',
'photo-skeleton-3',
'photo-skeleton-4',
'photo-skeleton-5',
'photo-skeleton-6',
]
return (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{skeletonKeys.map((key) => (
{Array.from({ length: 6 }, (_, i) => `photo-skeleton-${i + 1}`).map((key) => (
<div key={key} className="mb-4 break-inside-avoid">
<div className="bg-fill/30 h-48 w-full animate-pulse rounded-xl" />
</div>
@@ -210,19 +207,19 @@ export function PhotoLibraryGrid({
if (!assets || assets.length === 0) {
return (
<div className="bg-background-tertiary relative overflow-hidden rounded-xl p-8 text-center">
<p className="text-text text-base font-semibold"></p>
<p className="text-text-tertiary mt-2 text-sm">使</p>
<div className="bg-background-tertiary relative overflow-hidden rounded-xl p-4 sm:p-8 text-center">
<p className="text-text text-sm sm:text-base font-semibold"></p>
<p className="text-text-tertiary mt-2 text-xs sm:text-sm">使"上传图片"</p>
</div>
)
}
return (
<div className="mx-[calc(calc((3rem+100vw)-(var(--container-7xl)))*-1/2)] p-2">
<div className="lg:mx-[calc(calc((3rem+100vw)-(var(--container-7xl)))*-1/2)] -mx-2 lg:mt-0 mt-12 p-1">
<Masonry
items={assets}
columnGutter={8}
columnWidth={320}
columnWidth={columnWidth}
itemKey={(asset) => asset.id}
render={({ data }) => (
<PhotoGridItem

View File

@@ -4,7 +4,13 @@ import { m } from 'motion/react'
import { useMemo, useState } from 'react'
import { getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
import type { PhotoAssetSummary, PhotoSyncAction, PhotoSyncResult, PhotoSyncSnapshot } from '../../types'
import type {
PhotoAssetSummary,
PhotoSyncAction,
PhotoSyncResult,
PhotoSyncRunRecord,
PhotoSyncSnapshot,
} from '../../types'
export function BorderOverlay() {
return (
@@ -42,11 +48,44 @@ function SummaryCard({ label, value, tone }: SummaryCardProps) {
)
}
const DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
timeStyle: 'short',
})
function formatDateTimeLabel(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return DATE_FORMATTER.format(date)
}
function formatDurationLabel(start: string, end: string): string {
const startedAt = new Date(start)
const completedAt = new Date(end)
const duration = completedAt.getTime() - startedAt.getTime()
if (!Number.isFinite(duration) || duration <= 0) {
return '不足 1 秒'
}
const totalSeconds = Math.max(Math.round(duration / 1000), 1)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
const parts: string[] = []
if (minutes > 0) {
parts.push(`${minutes}`)
}
parts.push(`${seconds}`)
return parts.join(' ')
}
type PhotoSyncResultPanelProps = {
result: PhotoSyncResult | null
lastWasDryRun: boolean | null
baselineSummary?: PhotoAssetSummary | null
isSummaryLoading?: boolean
lastSyncRun?: PhotoSyncRunRecord | null
isSyncStatusLoading?: boolean
onRequestStorageUrl?: (storageKey: string) => Promise<string>
}
@@ -59,8 +98,11 @@ export function PhotoSyncResultPanel({
lastWasDryRun,
baselineSummary,
isSummaryLoading,
lastSyncRun,
isSyncStatusLoading,
onRequestStorageUrl,
}: PhotoSyncResultPanelProps) {
const isAwaitingStatus = isSyncStatusLoading && !lastSyncRun
const summaryItems = useMemo(() => {
if (result) {
return [
@@ -91,6 +133,35 @@ export function PhotoSyncResultPanel({
]
}
if (lastSyncRun) {
return [
{ label: '存储对象', value: lastSyncRun.summary.storageObjects },
{ label: '数据库记录', value: lastSyncRun.summary.databaseRecords },
{
label: '新增照片',
value: lastSyncRun.summary.inserted,
tone: lastSyncRun.summary.inserted > 0 ? ('accent' as const) : undefined,
},
{ label: '更新记录', value: lastSyncRun.summary.updated },
{ label: '删除记录', value: lastSyncRun.summary.deleted },
{
label: '冲突条目',
value: lastSyncRun.summary.conflicts,
tone: lastSyncRun.summary.conflicts > 0 ? ('warning' as const) : ('muted' as const),
},
{
label: '错误条目',
value: lastSyncRun.summary.errors,
tone: lastSyncRun.summary.errors > 0 ? ('warning' as const) : ('muted' as const),
},
{
label: '跳过条目',
value: lastSyncRun.summary.skipped,
tone: 'muted' as const,
},
]
}
if (baselineSummary) {
return [
{ label: '数据库记录', value: baselineSummary.total },
@@ -109,7 +180,18 @@ export function PhotoSyncResultPanel({
}
return []
}, [result, baselineSummary])
}, [result, lastSyncRun, baselineSummary])
const lastSyncRunMeta = useMemo(() => {
if (!lastSyncRun) {
return null
}
return {
completedLabel: formatDateTimeLabel(lastSyncRun.completedAt),
durationLabel: formatDurationLabel(lastSyncRun.startedAt, lastSyncRun.completedAt),
}
}, [lastSyncRun])
const [selectedActionType, setSelectedActionType] = useState<'all' | PhotoSyncAction['type']>('all')
const [expandedActionKey, setExpandedActionKey] = useState<string | null>(null)
@@ -266,17 +348,53 @@ export function PhotoSyncResultPanel({
}
if (!result) {
if (lastSyncRun && lastSyncRunMeta) {
return (
<div className="relative overflow-hidden p-6 bg-background-secondary">
<BorderOverlay />
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-text text-base font-semibold"></h2>
<p className="text-text-tertiary text-sm">
<span> {lastSyncRunMeta.completedLabel}</span>
<span className="mx-1">·</span>
<span> {lastSyncRunMeta.durationLabel}</span>
<span className="mx-1">·</span>
<span>{lastSyncRun.dryRun ? '预览模式 · 未写入数据库' : '实时模式 · 已写入数据库'}</span>
</p>
<p className="text-text-tertiary text-xs">
<span> {lastSyncRun.actionsCount} </span>
</p>
</div>
{summaryItems.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => (
<SummaryCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
))}
</div>
) : null}
</div>
</div>
)
}
const showSkeleton = isSummaryLoading || isAwaitingStatus
return (
<div className="relative overflow-hidden p-6 bg-background-secondary">
<BorderOverlay />
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-base font-semibold"></h2>
<h2 className="text-text text-base font-semibold">
{isAwaitingStatus ? '正在加载同步状态' : '尚未执行同步'}
</h2>
<p className="text-text-tertiary text-sm">
使
{isAwaitingStatus
? '正在查询最近一次同步记录,请稍候…'
: '请在系统设置中配置并激活存储提供商,然后使用右上角的按钮执行同步操作。预览模式不会写入数据,可用于安全检查。'}
</p>
</div>
{isSummaryLoading ? (
{showSkeleton ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{SUMMARY_SKELETON_KEYS.map((key) => (
<div key={key} className="bg-fill/30 h-24 animate-pulse rounded-lg" />

View File

@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
deletePhotoAssets,
getPhotoAssetSummary,
getPhotoSyncStatus,
listPhotoAssets,
listPhotoSyncConflicts,
resolvePhotoSyncConflict,
@@ -13,6 +14,7 @@ import type { PhotoAssetListItem, PhotoSyncResolution } from './types'
export const PHOTO_ASSET_SUMMARY_QUERY_KEY = ['photo-assets', 'summary'] as const
export const PHOTO_ASSET_LIST_QUERY_KEY = ['photo-assets', 'list'] as const
export const PHOTO_SYNC_CONFLICTS_QUERY_KEY = ['photo-sync', 'conflicts'] as const
export const PHOTO_SYNC_STATUS_QUERY_KEY = ['photo-sync', 'status'] as const
export function usePhotoAssetSummaryQuery() {
return useQuery({
@@ -21,6 +23,14 @@ export function usePhotoAssetSummaryQuery() {
})
}
export function usePhotoSyncStatusQuery(options?: { enabled?: boolean }) {
return useQuery({
queryKey: PHOTO_SYNC_STATUS_QUERY_KEY,
queryFn: getPhotoSyncStatus,
enabled: options?.enabled ?? true,
})
}
export function usePhotoAssetListQuery(options?: { enabled?: boolean }) {
return useQuery({
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
@@ -47,7 +57,7 @@ export function useDeletePhotoAssetsMutation() {
})
},
onSuccess: (_, variables) => {
const {ids} = variables
const { ids } = variables
void queryClient.invalidateQueries({
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
})

View File

@@ -44,6 +44,19 @@ export interface PhotoSyncResult {
actions: PhotoSyncAction[]
}
export interface PhotoSyncRunRecord {
id: string
dryRun: boolean
summary: PhotoSyncResultSummary
actionsCount: number
startedAt: string
completedAt: string
}
export interface PhotoSyncStatus {
lastRun: PhotoSyncRunRecord | null
}
export type PhotoSyncConflictType = 'missing-in-storage' | 'metadata-mismatch' | 'photo-id-conflict'
export interface PhotoSyncConflictPayload {

View File

@@ -220,10 +220,7 @@ function renderField<Key extends string>(
const helper = helperText ? <FormHelperText>{helperText}</FormHelperText> : null
return (
<div
key={field.id}
className="border-fill-tertiary/50 bg-background rounded-lg border p-4 transition-all duration-200"
>
<div key={field.id}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<Label className="text-text text-sm font-medium">{field.title}</Label>

View File

@@ -7,6 +7,12 @@ const SETTINGS_TABS = [
path: '/settings/site',
end: true,
},
{
id: 'user',
label: '用户信息',
path: '/settings/user',
end: true,
},
{
id: 'account',

View File

@@ -1,6 +1,12 @@
import { coreApi } from '~/lib/api-client'
import { camelCaseKeys } from '~/lib/case'
import type { SiteSettingEntryInput, SiteSettingUiSchemaResponse } from './types'
import type {
SiteAuthorProfile,
SiteSettingEntryInput,
SiteSettingUiSchemaResponse,
UpdateSiteAuthorPayload,
} from './types'
const SITE_SETTINGS_ENDPOINT = '/site/settings'
@@ -14,3 +20,14 @@ export async function updateSiteSettings(entries: readonly SiteSettingEntryInput
body: { entries },
})
}
export async function getSiteAuthorProfile() {
return camelCaseKeys<SiteAuthorProfile>(await coreApi<SiteAuthorProfile>(`${SITE_SETTINGS_ENDPOINT}/author`))
}
export async function updateSiteAuthorProfile(payload: UpdateSiteAuthorPayload) {
return await coreApi<SiteAuthorProfile>(`${SITE_SETTINGS_ENDPOINT}/author`, {
method: 'POST',
body: payload,
})
}

View File

@@ -0,0 +1,280 @@
import { Button, FormHelperText, Input, Label } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { startTransition, useEffect, useId, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
import { useBlock } from '~/hooks/useBlock'
import { getRequestErrorMessage } from '~/lib/errors'
import { useSiteAuthorProfileQuery, useUpdateSiteAuthorProfileMutation } from '../hooks'
import type { SiteAuthorProfile, UpdateSiteAuthorPayload } from '../types'
type UserFormState = {
name: string
displayUsername: string
username: string
avatar: string
}
const emptyState: UserFormState = {
name: '',
displayUsername: '',
username: '',
avatar: '',
}
function toFormState(profile: SiteAuthorProfile): UserFormState {
return {
name: profile.name ?? '',
displayUsername: profile.displayUsername ?? '',
username: profile.username ?? '',
avatar: profile.avatar ?? '',
}
}
function buildPayload(state: UserFormState): UpdateSiteAuthorPayload {
const normalize = (value: string) => {
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
return {
name: state.name.trim(),
displayUsername: normalize(state.displayUsername),
username: normalize(state.username),
avatar: normalize(state.avatar),
}
}
function formatTimestamp(iso: string | undefined) {
if (!iso) return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString()
}
export function SiteUserProfileForm() {
const { data, isLoading, isError, error } = useSiteAuthorProfileQuery()
const updateMutation = useUpdateSiteAuthorProfileMutation()
const { setHeaderActionState } = useMainPageLayout()
const formId = useId()
const [formState, setFormState] = useState<UserFormState>(emptyState)
const [initialState, setInitialState] = useState<UserFormState>(emptyState)
useEffect(() => {
if (!data) {
return
}
const next = toFormState(data)
startTransition(() => {
setFormState(next)
setInitialState(next)
})
}, [data])
const isDirty = useMemo(() => {
return (
formState.name !== initialState.name ||
formState.displayUsername !== initialState.displayUsername ||
formState.username !== initialState.username ||
formState.avatar !== initialState.avatar
)
}, [formState, initialState])
const canSubmit = Boolean(data) && !isLoading && isDirty
useEffect(() => {
setHeaderActionState({
disabled: !canSubmit,
loading: updateMutation.isPending,
})
return () => {
setHeaderActionState({ disabled: false, loading: false })
}
}, [canSubmit, setHeaderActionState, updateMutation.isPending])
const handleChange = (field: keyof UserFormState) => (value: string) => {
setFormState((prev) => ({
...prev,
[field]: value,
}))
}
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault()
if (!data || !isDirty || updateMutation.isPending) {
return
}
try {
const payload = buildPayload(formState)
await updateMutation.mutateAsync(payload)
setInitialState(formState)
toast.success('用户信息已更新')
} catch (mutationError) {
toast.error('保存用户信息失败', {
description: getRequestErrorMessage(mutationError, '请检查输入内容后重试。'),
})
}
}
const headerActionPortal = (
<MainPageLayout.Actions>
<Button
type="submit"
form={formId}
variant="primary"
size="sm"
disabled={!canSubmit}
isLoading={updateMutation.isPending}
loadingText="保存中…"
>
</Button>
</MainPageLayout.Actions>
)
useBlock({
when: isDirty,
title: '离开前请保存设置',
description: '当前用户信息尚未保存,离开页面会丢失这些更改,确定要继续吗?',
confirmText: '继续离开',
cancelText: '留在此页',
})
if (isLoading && !data) {
return (
<>
{headerActionPortal}
<LinearBorderPanel className="p-6">
<div className="space-y-4">
<div className="bg-fill/40 h-6 w-2/5 animate-pulse rounded-lg" />
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={`skeleton-field-${index}`} className="space-y-3">
<div className="bg-fill/30 h-4 w-1/3 animate-pulse rounded" />
<div className="bg-fill/20 h-10 animate-pulse rounded-md" />
</div>
))}
</div>
</div>
</LinearBorderPanel>
</>
)
}
if (isError && !data) {
return (
<>
{headerActionPortal}
<LinearBorderPanel className="p-6">
<div className="text-red flex items-center gap-3 text-sm">
<i className="i-mingcute-close-circle-fill text-lg" />
<span>{getRequestErrorMessage(error, '无法加载用户信息')}</span>
</div>
</LinearBorderPanel>
</>
)
}
const profile = data
const avatarPreview = formState.avatar?.trim() ? formState.avatar.trim() : null
const previewInitial =
(formState.displayUsername || formState.name || profile?.email || 'A').charAt(0)?.toUpperCase() ?? 'A'
return (
<>
{headerActionPortal}
<LinearBorderPanel className="bg-background-secondary">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-6">
<div>
<p className="text-text-tertiary text-xs font-semibold uppercase tracking-wider"></p>
<h2 className="text-text mt-1 text-xl font-semibold"></h2>
<p className="text-text-tertiary mt-1 text-sm">
RSS Feed
</p>
</div>
<div className="flex items-center gap-4">
<div className="relative size-16 sm:size-20 overflow-hidden rounded-full border border-white/5 shadow-inner">
{avatarPreview ? (
<img src={avatarPreview} alt="用户头像预览" className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="bg-accent/15 text-accent flex h-full w-full items-center justify-center text-2xl font-semibold">
{previewInitial}
</div>
)}
</div>
<div className="space-y-1 text-sm">
<p className="text-text font-semibold">{formState.displayUsername || formState.name || '作者'}</p>
<p className="text-text-tertiary text-xs">{profile?.email}</p>
<p className="text-text-tertiary text-xs">
{formatTimestamp(profile?.updatedAt) || '尚未更新'}
</p>
</div>
</div>
</div>
<m.form
id={formId}
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6 p-6"
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="user-name"></Label>
<Input
id="user-name"
value={formState.name}
onInput={(event) => handleChange('name')(event.currentTarget.value)}
placeholder="例如Innei"
required
/>
<FormHelperText> RSS /</FormHelperText>
</div>
<div className="space-y-2">
<Label htmlFor="user-display"></Label>
<Input
id="user-display"
value={formState.displayUsername}
onInput={(event) => handleChange('displayUsername')(event.currentTarget.value)}
placeholder="可选例如innei.photo"
/>
<FormHelperText>使</FormHelperText>
</div>
<div className="space-y-2">
<Label htmlFor="user-username"></Label>
<Input
id="user-username"
value={formState.username}
onInput={(event) => handleChange('username')(event.currentTarget.value)}
placeholder="例如innei"
/>
<FormHelperText></FormHelperText>
</div>
<div className="space-y-2">
<Label htmlFor="user-avatar"></Label>
<Input
id="user-avatar"
type="url"
value={formState.avatar}
onInput={(event) => handleChange('avatar')(event.currentTarget.value)}
placeholder="https://cdn.example.com/avatar.png"
/>
<FormHelperText> http(s) // 开头的链接,留空则使用首字母。</FormHelperText>
</div>
</div>
</m.form>
</LinearBorderPanel>
</>
)
}

View File

@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getSiteSettingUiSchema, updateSiteSettings } from './api'
import type { SiteSettingEntryInput } from './types'
import { getSiteAuthorProfile, getSiteSettingUiSchema, updateSiteAuthorProfile, updateSiteSettings } from './api'
import type { SiteSettingEntryInput, UpdateSiteAuthorPayload } from './types'
export const SITE_SETTING_UI_SCHEMA_QUERY_KEY = ['site-settings', 'ui-schema'] as const
export const SITE_AUTHOR_PROFILE_QUERY_KEY = ['site-settings', 'author-profile'] as const
export function useSiteSettingUiSchemaQuery() {
return useQuery({
@@ -26,3 +27,23 @@ export function useUpdateSiteSettingsMutation() {
},
})
}
export function useSiteAuthorProfileQuery() {
return useQuery({
queryKey: SITE_AUTHOR_PROFILE_QUERY_KEY,
queryFn: getSiteAuthorProfile,
})
}
export function useUpdateSiteAuthorProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: UpdateSiteAuthorPayload) => {
return await updateSiteAuthorProfile(payload)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: SITE_AUTHOR_PROFILE_QUERY_KEY })
},
})
}

View File

@@ -1,4 +1,5 @@
export * from './api'
export * from './components/SiteSettingsForm'
export * from './components/SiteUserProfileForm'
export * from './hooks'
export * from './types'

View File

@@ -11,3 +11,21 @@ export type SiteSettingEntryInput<Key extends string = string> = {
readonly key: Key
readonly value: string
}
export interface SiteAuthorProfile {
id: string
name: string
email: string
username: string | null
displayUsername: string | null
avatar: string | null
createdAt: string
updatedAt: string
}
export type UpdateSiteAuthorPayload = {
name: string
displayUsername?: string | null
username?: string | null
avatar?: string | null
}

View File

@@ -1,8 +1,10 @@
import { Button, Modal } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import { m } from 'motion/react'
import { startTransition, useEffect, useState } from 'react'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
import { useBlock } from '~/hooks/useBlock'
@@ -178,6 +180,7 @@ export function StorageProvidersManager() {
return (
<>
{headerActionPortal}
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
@@ -246,6 +249,37 @@ export function StorageProvidersManager() {
</p>
</m.div>
)}
{/* Security Notice */}
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="mb-6"
>
<LinearBorderPanel className="bg-background-secondary/40 p-4 sm:p-6">
<div className="flex items-start gap-3 sm:gap-4">
<div className="shrink-0">
<div className="bg-accent/10 inline-flex h-8 w-8 items-center justify-center rounded-lg sm:h-10 sm:w-10">
<DynamicIcon name="shield-check" className="h-4 w-4 text-accent sm:h-5 sm:w-5" />
</div>
</div>
<div className="flex-1 space-y-1.5 sm:space-y-2">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="text-text text-sm font-semibold sm:text-base"></span>
</div>
<p className="text-text-secondary text-xs sm:text-sm leading-relaxed">
访使{' '}
<span className="font-mono font-semibold text-accent">AES-256-GCM</span>{' '}
</p>
<p className="text-text-tertiary text-[11px] sm:text-xs">
AES-256-GCM
</p>
</div>
</div>
</LinearBorderPanel>
</m.div>
</>
)
}

View File

@@ -12,7 +12,7 @@ export function Component() {
{/* Main Content Area */}
<main className="bg-background flex-1 overflow-hidden">
<ScrollArea rootClassName="h-full" viewportClassName="h-full">
<div className="mx-auto max-w-7xl px-6 py-6">
<div className="mx-auto max-w-7xl px-3 sm:px-6 py-4 sm:py-6">
<Outlet />
</div>
</ScrollArea>

View File

@@ -0,0 +1,14 @@
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { SettingsNavigation } from '~/modules/settings'
import { SiteUserProfileForm } from '~/modules/site-settings'
export function Component() {
return (
<MainPageLayout title="用户信息" description="维护展示在前台的作者资料、头像与别名。">
<div className="space-y-6">
<SettingsNavigation active="user" />
<SiteUserProfileForm />
</div>
</MainPageLayout>
)
}

View File

@@ -0,0 +1,14 @@
CREATE TABLE "photo_sync_run" (
"id" text PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"dry_run" boolean DEFAULT false NOT NULL,
"summary" jsonb NOT NULL,
"actions_count" integer DEFAULT 0 NOT NULL,
"started_at" timestamp DEFAULT now() NOT NULL,
"completed_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "photo_sync_run" ADD CONSTRAINT "photo_sync_run_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_photo_sync_run_tenant" ON "photo_sync_run" USING btree ("tenant_id");

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1762852227998,
"tag": "0000_broken_maria_hill",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1763138147750,
"tag": "0001_open_sentinel",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,5 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { bigint, boolean, index, jsonb, pgEnum, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core'
import { bigint, boolean, index, integer, jsonb, pgEnum, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core'
import { generateId } from './snowflake'
@@ -43,6 +43,17 @@ export interface PhotoAssetManifest {
data: PhotoManifestItem
}
export interface PhotoSyncRunSummary {
storageObjects: number
databaseRecords: number
inserted: number
updated: number
deleted: number
conflicts: number
skipped: number
errors: number
}
export const tenants = pgTable(
'tenant',
{
@@ -254,6 +265,24 @@ export const photoAssets = pgTable(
],
)
export const photoSyncRuns = pgTable(
'photo_sync_run',
{
id: snowflakeId,
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
dryRun: boolean('dry_run').notNull().default(false),
summary: jsonb('summary').$type<PhotoSyncRunSummary>().notNull(),
actionsCount: integer('actions_count').notNull().default(0),
startedAt: timestamp('started_at', { mode: 'string' }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
},
(t) => [index('idx_photo_sync_run_tenant').on(t.tenantId)],
)
export const dbSchema = {
tenants,
authUsers,
@@ -266,6 +295,7 @@ export const dbSchema = {
systemSettings,
reactions,
photoAssets,
photoSyncRuns,
}
export type DBSchema = typeof dbSchema

View File

@@ -22,9 +22,11 @@ const closeExiftool = () => {
exiftool.end().catch(noop)
}
process.once('beforeExit', closeExiftool)
process.once('SIGINT', closeExiftool)
process.once('SIGTERM', closeExiftool)
if (process.env.NODE_ENV !== 'development') {
process.once('beforeExit', closeExiftool)
process.once('SIGINT', closeExiftool)
process.once('SIGTERM', closeExiftool)
}
// 提取 EXIF 数据
export async function extractExifData(imageBuffer: Buffer, originalBuffer?: Buffer): Promise<PickedExif | null> {

View File

@@ -43,16 +43,15 @@ interface Social {
}
const defaultConfig: SiteConfig = {
name: "Innei's Afilmory",
title: "Innei's Afilmory",
description:
'Capturing beautiful moments in life, documenting daily warmth and emotions through my lens.',
url: 'https://afilmory.innei.in',
name: 'New Afilmory',
title: 'New Afilmory',
description: 'A modern photo gallery website.',
url: 'https://afilmory.com',
accentColor: '#007bff',
author: {
name: 'Innei',
url: 'https://innei.in/',
avatar: 'https://cdn.jsdelivr.net/gh/Innei/static@master/avatar.png',
name: 'Afilmory',
url: 'https://afilmory.art/',
avatar: 'https://cdn.jsdelivr.net/gh/Afilmory/Afilmory@main/logo.jpg',
},
}
export const siteConfig: SiteConfig = merge(defaultConfig, userConfig) as any