From 96400b177580897a18ac6097075aa9c3b13f2947 Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 29 Jun 2025 23:09:57 +0800 Subject: [PATCH] feat: support share to external via iframe - Added Tailwind CSS plugins and configurations to improve styling across the application. - Introduced new global styles and layout components for better structure and design consistency. - Implemented a MasonryGallery component for responsive photo display. - Enhanced photo item rendering with EXIF data and improved loading states. - Updated package dependencies to include new Tailwind CSS utilities and components. Signed-off-by: Innei --- apps/ssr/global.d.ts | 42 +++ apps/ssr/package.json | 23 +- apps/ssr/postcss.config.mjs | 6 + apps/ssr/src/app/(pages)/globals.css | 7 + apps/ssr/src/app/(pages)/layout.tsx | 13 + .../(pages)/share/iframe/MasonryGallery.tsx | 31 ++ .../app/(pages)/share/iframe/PhotoItem.tsx | 201 ++++++++++ .../src/app/(pages)/share/iframe/layout.tsx | 23 ++ .../ssr/src/app/(pages)/share/iframe/page.tsx | 44 +++ apps/ssr/src/lib/cn.ts | 5 + apps/ssr/src/lib/injectable.ts | 1 + apps/ssr/src/lib/photo-loader.ts | 5 +- apps/ssr/src/providers/index.tsx | 3 + apps/ssr/tsconfig.json | 1 + apps/web/package.json | 22 +- .../components/ui/photo-viewer/ExifPanel.tsx | 101 ++--- .../components/ui/photo-viewer/SharePanel.tsx | 132 +++++-- .../ui/photo-viewer/formatExifData.tsx | 6 +- apps/web/src/config/index.ts | 1 + apps/web/src/framer-lazy-feature.ts | 1 - apps/web/src/icons/index.tsx | 2 +- ...toMasonryItem.tsx => MasonryPhotoItem.tsx} | 4 +- apps/web/src/modules/gallery/MasonryRoot.tsx | 4 +- apps/web/src/pages/(debug)/iframe.tsx | 33 ++ apps/web/vite.config.ts | 17 + locales/app/en.json | 3 + locales/app/jp.json | 3 + locales/app/ko.json | 3 + locales/app/zh-CN.json | 3 + locales/app/zh-HK.json | 3 + locales/app/zh-TW.json | 3 + packages/components/package.json | 11 + packages/components/src/icons/index.tsx | 107 ++++++ pnpm-lock.yaml | 348 ++++++++++++++++-- pnpm-workspace.yaml | 10 + 35 files changed, 1097 insertions(+), 125 deletions(-) create mode 100644 apps/ssr/global.d.ts create mode 100644 apps/ssr/postcss.config.mjs create mode 100644 apps/ssr/src/app/(pages)/globals.css create mode 100644 apps/ssr/src/app/(pages)/layout.tsx create mode 100644 apps/ssr/src/app/(pages)/share/iframe/MasonryGallery.tsx create mode 100644 apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx create mode 100644 apps/ssr/src/app/(pages)/share/iframe/layout.tsx create mode 100644 apps/ssr/src/app/(pages)/share/iframe/page.tsx create mode 100644 apps/ssr/src/lib/cn.ts create mode 100644 apps/ssr/src/providers/index.tsx delete mode 100644 apps/web/src/framer-lazy-feature.ts rename apps/web/src/modules/gallery/{PhotoMasonryItem.tsx => MasonryPhotoItem.tsx} (99%) create mode 100644 apps/web/src/pages/(debug)/iframe.tsx create mode 100644 packages/components/package.json create mode 100644 packages/components/src/icons/index.tsx diff --git a/apps/ssr/global.d.ts b/apps/ssr/global.d.ts new file mode 100644 index 00000000..117ebbb8 --- /dev/null +++ b/apps/ssr/global.d.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +import type { FC, PropsWithChildren } from 'react' + +declare global { + export type NextErrorProps = { + reset: () => void + error: Error + } + export type NextPageParams

= PropsWithChildren< + { + params: Promise

+ searchParams: Promise> + } & Props + > + + export type NextPageExtractedParams< + P extends {}, + Props = {}, + > = PropsWithChildren< + { + params: P + searchParams: Promise> + } & Props + > + + export type Component

= FC + + export type ComponentType

= { + className?: string + } & PropsWithChildren & + P +} + +declare module 'react' { + export interface AriaAttributes { + 'data-hide-print'?: boolean + 'data-event'?: string + 'data-testid'?: string + } +} + +export {} diff --git a/apps/ssr/package.json b/apps/ssr/package.json index cabb4d52..e2530005 100644 --- a/apps/ssr/package.json +++ b/apps/ssr/package.json @@ -19,17 +19,32 @@ "start": "next start" }, "dependencies": { + "@afilmory/components": "workspace:*", "@afilmory/data": "workspace:*", "@t3-oss/env-nextjs": "0.13.8", + "clsx": "2.1.1", "drizzle-orm": "0.44.2", + "es-toolkit": "1.39.5", "linkedom": "0.18.11", "pg": "8.16.2", "postgres": "3.4.7", "react": "19.1.0", "react-dom": "19.1.0", + "react-masonry": "1.0.7", + "react-photo-view": "1.2.7", + "react-responsive-masonry": "2.7.1", + "react-use": "17.6.0", + "tailwind-merge": "3.3.1", + "tailwind-variants": "catalog:", + "thumbhash": "0.1.1", + "usehooks-ts": "3.1.1", "zod": "catalog:" }, "devDependencies": { + "@egoist/tailwindcss-icons": "catalog:", + "@iconify-json/mingcute": "catalog:", + "@tailwindcss/postcss": "catalog:", + "@tailwindcss/typography": "catalog:", "@types/node": "24.0.4", "@types/pg": "8.15.4", "@types/react": "19.1.8", @@ -38,6 +53,12 @@ "cross-env": "7.0.3", "dotenv-expand": "catalog:", "drizzle-kit": "0.31.2", - "next": "15.3.4" + "next": "15.3.4", + "postcss": "8.5.6", + "tailwind-scrollbar": "catalog:", + "tailwindcss": "catalog:", + "tailwindcss-animate": "catalog:", + "tailwindcss-safe-area": "catalog:", + "tailwindcss-uikit-colors": "catalog:" } } \ No newline at end of file diff --git a/apps/ssr/postcss.config.mjs b/apps/ssr/postcss.config.mjs new file mode 100644 index 00000000..317d71cc --- /dev/null +++ b/apps/ssr/postcss.config.mjs @@ -0,0 +1,6 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +} +export default config diff --git a/apps/ssr/src/app/(pages)/globals.css b/apps/ssr/src/app/(pages)/globals.css new file mode 100644 index 00000000..cad037f7 --- /dev/null +++ b/apps/ssr/src/app/(pages)/globals.css @@ -0,0 +1,7 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; +@plugin 'tailwind-scrollbar'; +@plugin 'tailwindcss-animate'; +@plugin 'tailwindcss-safe-area'; +@import 'tailwindcss-uikit-colors/v4/macos.css'; +@plugin '@egoist/tailwindcss-icons'; diff --git a/apps/ssr/src/app/(pages)/layout.tsx b/apps/ssr/src/app/(pages)/layout.tsx new file mode 100644 index 00000000..f7a7fd4f --- /dev/null +++ b/apps/ssr/src/app/(pages)/layout.tsx @@ -0,0 +1,13 @@ +import './globals.css' + +import { RootProviders } from '~/providers' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/apps/ssr/src/app/(pages)/share/iframe/MasonryGallery.tsx b/apps/ssr/src/app/(pages)/share/iframe/MasonryGallery.tsx new file mode 100644 index 00000000..7082f091 --- /dev/null +++ b/apps/ssr/src/app/(pages)/share/iframe/MasonryGallery.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { PhotoManifestItem } from '@afilmory/builder' +import { useMemo } from 'react' +import Masonry from 'react-responsive-masonry' +import { useWindowSize } from 'usehooks-ts' + +import { PhotoItem } from './PhotoItem' + +interface MasonryGalleryProps { + photos: PhotoManifestItem[] +} + +export function MasonryGallery({ photos }: MasonryGalleryProps) { + const { width } = useWindowSize() + + const columnsCount = useMemo(() => { + if (width < 600) return 1 + if (width < 800) return 2 + return 3 + }, [width]) + return ( +

+ + {photos.map((photo) => ( + + ))} + +
+ ) +} diff --git a/apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx b/apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx new file mode 100644 index 00000000..bad3c549 --- /dev/null +++ b/apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx @@ -0,0 +1,201 @@ +'use client' + +import type { PhotoManifestItem } from '@afilmory/builder' +import { + CarbonIsoOutline, + MaterialSymbolsShutterSpeed, + StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens, + TablerAperture, +} from '@afilmory/components/icons/index.tsx' +import { thumbHashToDataURL } from 'thumbhash' + +import { cn } from '~/lib/cn' + +import { url } from '../../../../../../../config.json' + +const decompressUint8Array = (compressed: string) => { + return Uint8Array.from( + compressed.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)), + ) +} + +interface PhotoItemProps { + photo: PhotoManifestItem + className?: string +} + +export function PhotoItem({ photo, className }: PhotoItemProps) { + // 生成 thumbhash 预览 + const thumbHashDataURL = photo.thumbHash + ? thumbHashToDataURL(decompressUint8Array(photo.thumbHash)) + : null + + const ratio = photo.aspectRatio + + // 格式化 EXIF 数据 + const formatExifData = () => { + const { exif } = photo + + // 安全处理:如果 exif 不存在或为空,则返回空对象 + if (!exif) { + return { + focalLength35mm: null, + iso: null, + shutterSpeed: null, + aperture: null, + } + } + + // 等效焦距 (35mm) + const focalLength35mm = exif.FocalLengthIn35mmFormat + ? Number.parseInt(exif.FocalLengthIn35mmFormat) + : exif.FocalLength + ? Number.parseInt(exif.FocalLength) + : null + + // ISO + const iso = exif.ISO + + // 快门速度 + const exposureTime = exif.ExposureTime + const shutterSpeed = exposureTime ? `${exposureTime}s` : null + + // 光圈 + const aperture = exif.FNumber ? `f/${exif.FNumber}` : null + + return { + focalLength35mm, + iso, + shutterSpeed, + aperture, + } + } + + const exifData = formatExifData() + + return ( + + ) +} diff --git a/apps/ssr/src/app/(pages)/share/iframe/layout.tsx b/apps/ssr/src/app/(pages)/share/iframe/layout.tsx new file mode 100644 index 00000000..f5351014 --- /dev/null +++ b/apps/ssr/src/app/(pages)/share/iframe/layout.tsx @@ -0,0 +1,23 @@ +'use client' +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ) +} diff --git a/apps/ssr/src/app/(pages)/share/iframe/page.tsx b/apps/ssr/src/app/(pages)/share/iframe/page.tsx new file mode 100644 index 00000000..887174d6 --- /dev/null +++ b/apps/ssr/src/app/(pages)/share/iframe/page.tsx @@ -0,0 +1,44 @@ +import type { PhotoManifestItem } from '@afilmory/builder' +import { notFound } from 'next/navigation' + +import { photoLoader } from '~/lib/photo-loader' + +import { MasonryGallery } from './MasonryGallery' +import { PhotoItem } from './PhotoItem' + +export default async function Page({ + searchParams, +}: NextPageExtractedParams) { + const { id } = await searchParams + + let photos: PhotoManifestItem[] = [] + + if (!id) return notFound() + if (typeof id === 'string') { + const photo = await photoLoader.getPhoto(id) + if (!photo) { + notFound() + } + photos = [photo] + } else { + photos = await photoLoader.getPhotos(id) + if (photos.length === 0) { + notFound() + } + } + + if (photos.length === 1) { + return ( + + ) + } + + return ( +
+ +
+ ) +} diff --git a/apps/ssr/src/lib/cn.ts b/apps/ssr/src/lib/cn.ts new file mode 100644 index 00000000..7be5e447 --- /dev/null +++ b/apps/ssr/src/lib/cn.ts @@ -0,0 +1,5 @@ +import type { ClassValue } from 'clsx' +import clsx from 'clsx' +import { twMerge } from 'tailwind-merge' + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)) diff --git a/apps/ssr/src/lib/injectable.ts b/apps/ssr/src/lib/injectable.ts index e2010e87..0b6a160e 100644 --- a/apps/ssr/src/lib/injectable.ts +++ b/apps/ssr/src/lib/injectable.ts @@ -12,6 +12,7 @@ export const injectConfigToDocument = (document: OnlyHTMLDocument) => { const $config = document.head.querySelector('#config') const injectConfigBase = { useApi: DbManager.shared.isEnabled(), + useNext: true, } if ($config) { $config.innerHTML = `window.__CONFIG__ = ${JSON.stringify(injectConfigBase)}` diff --git a/apps/ssr/src/lib/photo-loader.ts b/apps/ssr/src/lib/photo-loader.ts index 89c1b811..f4f48bcc 100644 --- a/apps/ssr/src/lib/photo-loader.ts +++ b/apps/ssr/src/lib/photo-loader.ts @@ -17,7 +17,10 @@ class PhotoLoader { }) } - getPhotos() { + getPhotos(ids?: string[]) { + if (ids) { + return this.photos.filter((photo) => ids.includes(photo.id)) + } return this.photos } diff --git a/apps/ssr/src/providers/index.tsx b/apps/ssr/src/providers/index.tsx new file mode 100644 index 00000000..d96966a4 --- /dev/null +++ b/apps/ssr/src/providers/index.tsx @@ -0,0 +1,3 @@ +export const RootProviders = ({ children }: { children: React.ReactNode }) => { + return <>{children} +} diff --git a/apps/ssr/tsconfig.json b/apps/ssr/tsconfig.json index 77125afa..5b155513 100644 --- a/apps/ssr/tsconfig.json +++ b/apps/ssr/tsconfig.json @@ -16,6 +16,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "jsx": "preserve", "plugins": [ { diff --git a/apps/web/package.json b/apps/web/package.json index 9ac5f8a3..63380d39 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -69,6 +69,7 @@ "swiper": "11.2.8", "swr": "2.3.3", "tailwind-merge": "3.3.1", + "tailwind-variants": "1.0.0", "thumbhash": "0.1.1", "tiff": "^7.0.0", "usehooks-ts": "3.1.1", @@ -77,11 +78,10 @@ "zustand": "5.0.5" }, "devDependencies": { - "@egoist/tailwindcss-icons": "1.9.0", - "@iconify-json/mingcute": "1.2.3", - "@tailwindcss/container-queries": "0.1.1", - "@tailwindcss/postcss": "4.1.11", - "@tailwindcss/typography": "0.5.16", + "@egoist/tailwindcss-icons": "catalog:", + "@iconify-json/mingcute": "catalog:", + "@tailwindcss/postcss": "catalog:", + "@tailwindcss/typography": "catalog:", "@tailwindcss/vite": "4.1.11", "@types/node": "24.0.4", "@types/react": "19.1.8", @@ -92,17 +92,17 @@ "code-inspector-plugin": "0.20.12", "daisyui": "5.0.43", "execa": "9.6.0", + "kolorist": "1.8.0", "postcss": "8.5.6", "postcss-import": "16.1.1", "postcss-js": "4.0.1", "react-compiler-runtime": "19.1.0-rc.2", "simple-git-hooks": "2.13.0", - "tailwind-scrollbar": "4.0.2", - "tailwind-variants": "1.0.0", - "tailwindcss": "4.1.11", - "tailwindcss-animate": "1.0.7", - "tailwindcss-safe-area": "0.6.0", - "tailwindcss-uikit-colors": "1.0.0-alpha.1", + "tailwind-scrollbar": "catalog:", + "tailwindcss": "catalog:", + "tailwindcss-animate": "catalog:", + "tailwindcss-safe-area": "catalog:", + "tailwindcss-uikit-colors": "catalog:", "unplugin-ast": "0.15.0", "vite-plugin-html": "3.2.2" } diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx index 42baff08..63b3198b 100644 --- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx @@ -156,59 +156,64 @@ export const ExifPanel: FC<{ )} - {formattedExifData && ( -
-

- {t('exif.capture.parameters')} -

-
- {formattedExifData.focalLength35mm && ( -
- - - {formattedExifData.focalLength35mm}mm - -
- )} + {formattedExifData && + (formattedExifData.shutterSpeed || + formattedExifData.iso || + formattedExifData.aperture || + formattedExifData.exposureBias || + formattedExifData.focalLength35mm) && ( +
+

+ {t('exif.capture.parameters')} +

+
+ {formattedExifData.focalLength35mm && ( +
+ + + {formattedExifData.focalLength35mm}mm + +
+ )} - {formattedExifData.aperture && ( -
- - - {formattedExifData.aperture} - -
- )} + {formattedExifData.aperture && ( +
+ + + {formattedExifData.aperture} + +
+ )} - {formattedExifData.shutterSpeed && ( -
- - - {formattedExifData.shutterSpeed} - -
- )} + {formattedExifData.shutterSpeed && ( +
+ + + {formattedExifData.shutterSpeed} + +
+ )} - {formattedExifData.iso && ( -
- - - ISO {formattedExifData.iso} - -
- )} + {formattedExifData.iso && ( +
+ + + ISO {formattedExifData.iso} + +
+ )} - {formattedExifData.exposureBias && ( -
- - - {formattedExifData.exposureBias} - -
- )} + {formattedExifData.exposureBias && ( +
+ + + {formattedExifData.exposureBias} + +
+ )} +
-
- )} + )} {/* 标签信息 - 移到基本信息 section 内 */} {currentPhoto.tags && currentPhoto.tags.length > 0 && ( diff --git a/apps/web/src/components/ui/photo-viewer/SharePanel.tsx b/apps/web/src/components/ui/photo-viewer/SharePanel.tsx index 0d0d55e7..27e8de64 100644 --- a/apps/web/src/components/ui/photo-viewer/SharePanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/SharePanel.tsx @@ -1,9 +1,11 @@ +import { siteConfig } from '@config' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { AnimatePresence, m } from 'motion/react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { injectConfig } from '~/config' import { clsxm } from '~/lib/cn' import { Spring } from '~/lib/spring' import type { PhotoManifest } from '~/types/photo' @@ -121,6 +123,23 @@ export const SharePanel = ({ photo, trigger, blobSrc }: SharePanelProps) => { } }, [t]) + const handleCopyEmbedCode = useCallback(async () => { + try { + const embedCode = `