diff --git a/apps/web/package.json b/apps/web/package.json index 4be577ee..a6478c99 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "@react-hook/window-size": "3.1.1", "@t3-oss/env-core": "catalog:", "@tanstack/react-query": "5.90.11", + "@tanstack/react-virtual": "3.13.12", "@use-gesture/react": "10.3.1", "@uswriting/exiftool": "1.0.6", "blurhash": "2.0.5", diff --git a/apps/web/src/modules/gallery/ListView.tsx b/apps/web/src/modules/gallery/ListView.tsx index a74b8f9e..d979a4ec 100644 --- a/apps/web/src/modules/gallery/ListView.tsx +++ b/apps/web/src/modules/gallery/ListView.tsx @@ -1,5 +1,10 @@ +import { useScrollViewElement } from '@afilmory/ui' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useMobile } from '~/hooks/useMobile' +import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer' import type { PhotoManifest } from '~/types/photo' interface ListViewProps { @@ -7,17 +12,84 @@ interface ListViewProps { } export const ListView = ({ photos }: ListViewProps) => { + const scrollElement = useScrollViewElement() + const isMobile = useMobile() + + // 间距大小(更紧凑,即 0.5rem = 8px) + const gap = 8 + // 固定卡片高度:移动端使用动态测量(因为图片高度根据宽高比变化),桌面端 176px (h-44) + const cardHeight = isMobile ? 300 : 176 // 移动端给一个初始估算值 + const estimateSize = () => cardHeight + gap + + const virtualizer = useVirtualizer({ + count: photos.length, + getScrollElement: () => scrollElement, + estimateSize, + overscan: 5, + // 移动端需要动态测量,桌面端使用固定高度 + measureElement: isMobile + ? (element) => { + if (!element) return estimateSize() + const { height } = element.getBoundingClientRect() + return height + gap + } + : undefined, + }) + + // 计算总高度:减去最后一个项目的间距(因为最后一个项目不应该有间距) + const totalSize = virtualizer.getTotalSize() - gap + return ( -
- {photos.map((photo) => ( - - ))} +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const photo = photos[virtualItem.index] + const isLast = virtualItem.index === photos.length - 1 + + return ( +
+ +
+ ) + })} +
) } const PhotoCard = ({ photo }: { photo: PhotoManifest }) => { const { i18n } = useTranslation() + const photos = useContextPhotos() + const photoViewer = usePhotoViewer() + const imageRef = useRef(null) + + const handleClick = () => { + const photoIndex = photos.findIndex((p) => p.id === photo.id) + if (photoIndex !== -1) { + const triggerEl = + imageRef.current?.parentElement instanceof HTMLElement ? imageRef.current.parentElement : imageRef.current + + photoViewer.openViewer(photoIndex, triggerEl ?? undefined) + } + } const formatDate = (timestamp: number) => { return new Date(timestamp).toLocaleDateString(i18n.language, { @@ -27,72 +99,221 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => { }) } - // 获取相机信息 - const cameraInfo = photo.exif?.Model || photo.exif?.Make - const lensInfo = photo.exif?.LensModel + // 格式化 EXIF 数据 + const formatExifData = () => { + if (!photo.exif) { + return { + camera: null, + lens: null, + iso: null, + aperture: null, + shutterSpeed: null, + focalLength: null, + exposureCompensation: null, + } + } + + const { exif } = photo + + // 相机信息 + const camera = exif.Make && exif.Model ? `${exif.Make} ${exif.Model}` : exif.Model || exif.Make || null + + // 镜头信息 + const lens = exif.LensMake && exif.LensModel ? `${exif.LensMake} ${exif.LensModel}` : exif.LensModel || null + + // ISO + const iso = exif.ISO || null + + // 光圈 + const aperture = exif.FNumber ? `f/${exif.FNumber}` : null + + // 快门速度 + const exposureTime = exif.ExposureTime + let shutterSpeed: string | null = null + if (exposureTime) { + if (typeof exposureTime === 'number') { + if (exposureTime >= 1) { + shutterSpeed = `${exposureTime}s` + } else { + shutterSpeed = `1/${Math.round(1 / exposureTime)}s` + } + } else { + shutterSpeed = `${exposureTime}s` + } + } else if (exif.ShutterSpeedValue) { + const speed = + typeof exif.ShutterSpeedValue === 'number' + ? exif.ShutterSpeedValue + : Number.parseFloat(String(exif.ShutterSpeedValue)) + if (speed >= 1) { + shutterSpeed = `${speed}s` + } else { + shutterSpeed = `1/${Math.round(1 / speed)}s` + } + } + + // 焦距 (优先使用 35mm 等效焦距) + const focalLength = exif.FocalLengthIn35mmFormat + ? `${Number.parseInt(exif.FocalLengthIn35mmFormat)}mm` + : exif.FocalLength + ? `${Number.parseInt(exif.FocalLength)}mm` + : null + + // 曝光补偿 + const exposureCompensation = exif.ExposureCompensation ? `${exif.ExposureCompensation} EV` : null + + return { + camera, + lens, + iso, + aperture, + shutterSpeed, + focalLength, + exposureCompensation, + } + } + + const exifData = formatExifData() return ( -
- {/* 缩略图 */} -
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + }} + className="group flex flex-col gap-2 overflow-hidden border border-white/5 bg-white/5 p-2 backdrop-blur-sm transition-all duration-200 hover:border-white/10 hover:bg-white/8 lg:h-44 lg:flex-row lg:gap-3" + > + {/* 缩略图 - 移动端按宽高比,桌面端固定高度 */} +
{photo.title -
- - {/* 元数据 */} -
- {/* 标题 */} -
-

{photo.title}

- - {/* 元数据行 */} -
- {/* 位置 */} - {photo.location && ( -
- - {photo.location.locationName} -
- )} - - {/* 日期 */} -
- - {formatDate(new Date(photo.lastModified).getTime())} -
- - {/* 相机 */} - {cameraInfo && ( -
- - {cameraInfo} -
- )} - - {/* 镜头 */} - {lensInfo && ( -
- - {lensInfo} -
- )} -
-
- - {/* 标签 */} + {/* Tags 覆盖在图片上 */} {photo.tags && photo.tags.length > 0 && ( -
+
{photo.tags.map((tag) => ( - + {tag} ))}
)}
+ + {/* 元数据 - 移动端自适应,桌面端固定高度 */} +
+ {/* 标题和基本信息 */} +
+

{photo.title}

+ + {/* 元数据行 */} +
+ {/* 位置 */} + {photo.location && ( +
+ + {photo.location.locationName} +
+ )} + + {/* 日期 */} +
+ + {formatDate(new Date(photo.lastModified).getTime())} +
+ + {/* 相机 */} + {exifData.camera && ( +
+ + {exifData.camera} +
+ )} + + {/* 镜头 */} + {exifData.lens && ( +
+ + {exifData.lens} +
+ )} + + {/* 尺寸 */} +
+ + + {photo.width} x {photo.height} + +
+
+ + {/* 摄影三要素 + 焦距 - 简洁样式 */} + {(exifData.iso || exifData.aperture || exifData.shutterSpeed || exifData.focalLength) && ( +
+ {/* ISO */} + {exifData.iso && ( +
+ + ISO {exifData.iso} +
+ )} + + {/* 光圈 */} + {exifData.aperture && ( +
+ + {exifData.aperture} +
+ )} + + {/* 快门速度 */} + {exifData.shutterSpeed && ( +
+ + {exifData.shutterSpeed} +
+ )} + + {/* 焦距 */} + {exifData.focalLength && ( +
+ + {exifData.focalLength} +
+ )} + + {/* 曝光补偿 - 次要显示 */} + {exifData.exposureCompensation && ( +
+ + {exifData.exposureCompensation} +
+ )} +
+ )} +
+
) } diff --git a/apps/web/src/modules/gallery/PageHeader/PageHeaderCenter.tsx b/apps/web/src/modules/gallery/PageHeader/PageHeaderCenter.tsx index bf48ee6b..bc54ed4d 100644 --- a/apps/web/src/modules/gallery/PageHeader/PageHeaderCenter.tsx +++ b/apps/web/src/modules/gallery/PageHeader/PageHeaderCenter.tsx @@ -66,8 +66,8 @@ export const PageHeaderCenter = ({ dateRange, location, showDateRange }: PageHea transition={Spring.presets.smooth} className="absolute left-1/2 hidden -translate-x-1/2 flex-col items-center lg:flex" > - {formattedDate} - {location && {location}} + {formattedDate} + {location && {location}} )} diff --git a/apps/web/src/modules/gallery/PageHeader/PageHeaderLeft.tsx b/apps/web/src/modules/gallery/PageHeader/PageHeaderLeft.tsx index 20b3e2f2..651af5be 100644 --- a/apps/web/src/modules/gallery/PageHeader/PageHeaderLeft.tsx +++ b/apps/web/src/modules/gallery/PageHeader/PageHeaderLeft.tsx @@ -16,45 +16,39 @@ export const PageHeaderLeft = () => { siteConfig.social && siteConfig.social.twitter ? resolveSocialUrl(siteConfig.social.twitter, { baseUrl: 'https://twitter.com/', stripAt: true }) : undefined - const hasRss = siteConfig.social && siteConfig.social.rss - - const hasSocialLinks = githubUrl || twitterUrl || hasRss + const hasRss = true return ( -
-
- {siteConfig.author.avatar ? ( - - - -
- -
-
-
- ) : ( -
- -
- )} -
-
-
-

{siteConfig.name}

- {visiblePhotoCount} +
+ {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: