+ {siteConfig.author.avatar ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+
- {hasSocialLinks && (
-
- {githubUrl && }
- {twitterUrl && }
- {hasRss && }
-
- )}
+ )}
+
+
{siteConfig.name}
+ {visiblePhotoCount}
+ {(githubUrl || twitterUrl || hasRss) && (
+
+ {githubUrl && }
+ {twitterUrl && }
+ {hasRss && }
+
+ )}
)
}
diff --git a/apps/web/src/modules/gallery/PageHeader/PageHeaderRight.tsx b/apps/web/src/modules/gallery/PageHeader/PageHeaderRight.tsx
index 14a7f13b..8fe38059 100644
--- a/apps/web/src/modules/gallery/PageHeader/PageHeaderRight.tsx
+++ b/apps/web/src/modules/gallery/PageHeader/PageHeaderRight.tsx
@@ -1,11 +1,13 @@
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@afilmory/ui'
import { useAtom, useSetAtom } from 'jotai'
+import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
+import { Drawer } from 'vaul'
import { gallerySettingAtom, isCommandPaletteOpenAtom } from '~/atoms/app'
import { useMobile } from '~/hooks/useMobile'
-import { ResponsiveActionButton } from '../components/ActionButton'
import { ViewPanel } from '../panels/ViewPanel'
import { ActionIconButton } from './utils'
@@ -27,31 +29,117 @@ export const PageHeaderRight = () => {
(gallerySetting.selectedRatings !== null ? 1 : 0)
return (
-
+
{/* Action Buttons */}
-
setCommandPaletteOpen(true)}
- badge={filterCount}
- />
-
- {/* Desktop only: Map Link */}
- {!isMobile && (
+
navigate('/explory')}
+ icon="i-mingcute-search-line"
+ title={t('action.search.unified.title')}
+ onClick={() => setCommandPaletteOpen(true)}
+ badge={filterCount}
/>
- )}
-
-
-
+ {/* Desktop only: Map Link */}
+ {!isMobile && (
+ navigate('/explory')}
+ />
+ )}
+
+ {isMobile ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
)
}
+
+// 紧凑版本的桌面端视图按钮
+const DesktopViewButton = ({
+ icon,
+ title,
+ badge,
+ children,
+}: {
+ icon: string
+ title: string
+ badge?: number | string
+ children: React.ReactNode
+}) => {
+ return (
+
+
+
+
+ {children}
+
+ )
+}
+
+// 紧凑版本的移动端视图按钮
+const MobileViewButton = ({
+ icon,
+ title,
+ badge,
+ children,
+}: {
+ icon: string
+ title: string
+ badge?: number | string
+ children: React.ReactNode
+}) => {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+
+
+
+
+
+ {children}
+
+
+
+ >
+ )
+}
diff --git a/apps/web/src/modules/gallery/PageHeader/ViewModeSegment.tsx b/apps/web/src/modules/gallery/PageHeader/ViewModeSegment.tsx
index 047f09ae..59852061 100644
--- a/apps/web/src/modules/gallery/PageHeader/ViewModeSegment.tsx
+++ b/apps/web/src/modules/gallery/PageHeader/ViewModeSegment.tsx
@@ -15,11 +15,11 @@ export const ViewModeSegment = () => {
}
return (
-
+
)
diff --git a/apps/web/src/modules/gallery/PageHeader/index.tsx b/apps/web/src/modules/gallery/PageHeader/index.tsx
index 8e3ec196..287e3e4a 100644
--- a/apps/web/src/modules/gallery/PageHeader/index.tsx
+++ b/apps/web/src/modules/gallery/PageHeader/index.tsx
@@ -12,10 +12,10 @@ interface PageHeaderProps {
export const PageHeader = ({ dateRange, location, showDateRange }: PageHeaderProps) => {
return (
-
+
-
+
diff --git a/apps/web/src/modules/gallery/PageHeader/utils.tsx b/apps/web/src/modules/gallery/PageHeader/utils.tsx
index 5bc4922c..76ba0f3f 100644
--- a/apps/web/src/modules/gallery/PageHeader/utils.tsx
+++ b/apps/web/src/modules/gallery/PageHeader/utils.tsx
@@ -19,17 +19,17 @@ export function resolveSocialUrl(
return `${baseUrl}${normalized}`
}
-// 小型社交按钮样式(用于 PageHeaderLeft)
+// 小型社交按钮样式(用于 PageHeaderRight)
export const SocialIconButton = ({ icon, title, href }: { icon: string; title: string; href: string }) => {
return (
-
+
)
}
@@ -49,13 +49,13 @@ export const ActionIconButton = ({
href?: string
}) => {
const commonClasses =
- 'relative flex size-9 items-center justify-center rounded-full bg-white/10 text-white/60 transition-all duration-200 hover:bg-white/20 hover:text-white lg:size-10'
+ 'relative flex size-7 items-center justify-center rounded text-white/60 transition-all duration-200 hover:bg-white/10 hover:text-white lg:size-8'
const content = (
<>
-
+
{badge !== undefined && badge > 0 && (
-
+
{badge}
)}
diff --git a/apps/web/src/modules/gallery/PhotosRoot.tsx b/apps/web/src/modules/gallery/PhotosRoot.tsx
index 6e1166f6..6c95e25c 100644
--- a/apps/web/src/modules/gallery/PhotosRoot.tsx
+++ b/apps/web/src/modules/gallery/PhotosRoot.tsx
@@ -45,7 +45,7 @@ export const PhotosRoot = () => {
showDateRange={showFloatingActions && !!dateRange.formattedRange}
/>
-
+
{viewMode === 'list' ? : }
diff --git a/apps/web/src/pages/(main)/layout.tsx b/apps/web/src/pages/(main)/layout.tsx
index 86383ce2..a1653d2b 100644
--- a/apps/web/src/pages/(main)/layout.tsx
+++ b/apps/web/src/pages/(main)/layout.tsx
@@ -41,7 +41,7 @@ export const Component = () => {
) : (
-
+
)}
diff --git a/be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts b/be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts
index b5e6b960..90ca6bde 100644
--- a/be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts
+++ b/be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts
@@ -3,10 +3,10 @@ import type { Context } from 'hono'
import type { UploadAssetInput } from './photo-asset.types'
-export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 // 50 MB
-export const ABSOLUTE_MAX_REQUEST_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB
-export const MAX_UPLOAD_FILES_PER_BATCH = 32
-export const MAX_TEXT_FIELDS_PER_REQUEST = 64
+export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 1024 * 1024 * 1024 // 1 GB hard cap per file
+export const ABSOLUTE_MAX_REQUEST_SIZE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB hard cap per request
+export const MAX_UPLOAD_FILES_PER_BATCH = 128
+export const MAX_TEXT_FIELDS_PER_REQUEST = 256
const PHOTO_UPLOAD_LIMIT_CONTEXT_KEY = 'photo.upload.limits'
const PHOTO_UPLOAD_INPUT_CONTEXT_KEY = 'photo.upload.inputs'
diff --git a/docs/backend/billing-plans.md b/docs/backend/billing-plans.md
index 5f40998a..fc634fa5 100644
--- a/docs/backend/billing-plans.md
+++ b/docs/backend/billing-plans.md
@@ -8,7 +8,7 @@ This document tracks the current subscription plans, quota knobs, and the design
- Decouple plan defaults from tenant-specific overrides so superadmins can hotfix limits without redeploys.
- Keep room for future self-serve subscriptions while allowing manual override flows during private beta.
-## Plan Catalog (2024-xx-xx)
+## Plan Catalog (2025-11-30)
| Plan ID | Label | Availability | Monthly Process Limit | Library Items | Upload Size (MB) | Sync Object (MB) | Notes |
|------------|--------------------|-----------------------|-----------------------|---------------|------------------|------------------|-------|
@@ -18,6 +18,17 @@ This document tracks the current subscription plans, quota knobs, and the design
> `Unlimited` == `null` in the DB schema, meaning enforcement is skipped for that quota dimension.
+## Global Hard Caps (system guardrails)
+
+These apply to every plan (including `friend`) to protect the service from pathological requests. Plan-specific limits are enforced first, then clipped by these ceilings:
+
+- Max file size: **1 GB** (`ABSOLUTE_MAX_FILE_SIZE_BYTES`)
+- Max request payload: **5 GB** (`ABSOLUTE_MAX_REQUEST_SIZE_BYTES`)
+- Max files per batch: **128** (`MAX_UPLOAD_FILES_PER_BATCH`)
+- Max text fields per request: **256** (`MAX_TEXT_FIELDS_PER_REQUEST`)
+
+> Effectively: `resolvedFileLimit = min(plan.uploadLimit, 1 GB)` and `resolvedBatchLimit = min(resolvedFileLimit * 128, 5 GB)`.
+
## Design Notes
1. **Plan definitions** live in `billing-plan.constants.ts`. Each entry carries human-friendly metadata for the super-admin dashboard plus a `quotas` object.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9dfa1cf6..811c419b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -657,15 +657,15 @@ importers:
'@react-hook/window-size':
specifier: 3.1.1
version: 3.1.1(react@19.2.0)
- '@remixicon/react':
- specifier: 4.7.0
- version: 4.7.0(react@19.2.0)
'@t3-oss/env-core':
specifier: 'catalog:'
version: 0.13.8(typescript@5.9.3)(zod@4.1.13)
'@tanstack/react-query':
specifier: 5.90.11
version: 5.90.11(react@19.2.0)
+ '@tanstack/react-virtual':
+ specifier: 3.13.12
+ version: 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@use-gesture/react':
specifier: 10.3.1
version: 10.3.1(react@19.2.0)
@@ -1095,9 +1095,6 @@ importers:
'@react-hook/window-size':
specifier: 3.1.1
version: 3.1.1(react@19.2.0)
- '@remixicon/react':
- specifier: 4.7.0
- version: 4.7.0(react@19.2.0)
'@tanstack/react-form':
specifier: 1.26.0
version: 1.26.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1608,7 +1605,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.25.12)(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.20.6)(yaml@2.8.1))
+ version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(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.20.6)(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.20.6)(yaml@2.8.1)
@@ -4882,11 +4879,6 @@ packages:
resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==}
engines: {node: '>=14.0.0'}
- '@remixicon/react@4.7.0':
- resolution: {integrity: sha512-ODBQjdbOjnFguCqctYkpDjERXOInNaBnRPDKfZOBvbzExBAwr2BaH/6AHFTg/UAFzBDkwtylfMT8iKPAkLwPLQ==}
- peerDependencies:
- react: '>=18.2.0'
-
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
@@ -8379,7 +8371,6 @@ packages:
glob@13.0.0:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
- hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
@@ -16351,10 +16342,6 @@ snapshots:
'@remix-run/router@1.23.0':
optional: true
- '@remixicon/react@4.7.0(react@19.2.0)':
- dependencies:
- react: 19.2.0
-
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
@@ -24546,7 +24533,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.25.12)(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.20.6)(yaml@2.8.1)):
+ unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(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.20.6)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.53.3)
'@volar/typescript': 2.4.23
@@ -24560,6 +24547,7 @@ snapshots:
optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.10.1)
esbuild: 0.25.12
+ 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.20.6)(yaml@2.8.1)
transitivePeerDependencies: