mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 14:44:48 +00:00
refactor: enhance accessibility and optimize EXIF data handling across components
- Added aria-labels to buttons and icons for improved accessibility. - Refactored EXIF data formatting to utilize a shared function, reducing code duplication. - Updated various components to use memoization for performance optimization. - Adjusted styles for consistency in hover effects and transitions. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -14,8 +14,13 @@ export const MapBackButton = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassButton className="absolute top-4 left-4 z-50" onClick={handleBack} title={t('explory.back.to.gallery')}>
|
||||
<i className="i-mingcute-arrow-left-line text-base text-white" />
|
||||
<GlassButton
|
||||
className="absolute top-4 left-4 z-50"
|
||||
onClick={handleBack}
|
||||
title={t('explory.back.to.gallery')}
|
||||
aria-label={t('explory.back.to.gallery')}
|
||||
>
|
||||
<i className="i-mingcute-arrow-left-line text-base text-white" aria-hidden="true" />
|
||||
</GlassButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import { clsxm as cn } from '@afilmory/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { thumbHashToDataURL } from 'thumbhash'
|
||||
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens,
|
||||
TablerAperture,
|
||||
} from '~/icons'
|
||||
import { formatExifData } from '~/modules/metadata'
|
||||
|
||||
const decompressUint8Array = (compressed: string) => {
|
||||
return Uint8Array.from(compressed.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)))
|
||||
@@ -24,51 +26,14 @@ export function PhotoItem({ photo, className }: PhotoItemProps) {
|
||||
|
||||
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()
|
||||
// 使用共享的 EXIF 格式化函数
|
||||
const exifData = useMemo(() => formatExifData(photo.exif ?? null), [photo.exif])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="link"
|
||||
aria-label={`View photo: ${photo.title}`}
|
||||
onClick={() => {
|
||||
const siteUrl = window.__SITE_CONFIG__?.url || ''
|
||||
if (siteUrl) {
|
||||
@@ -146,35 +111,37 @@ export function PhotoItem({ photo, className }: PhotoItemProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pb-4 text-xs @[600px]:grid-cols-4">
|
||||
{exifData.focalLength35mm && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-white/70" />
|
||||
<span className="text-white/90">{exifData.focalLength35mm}mm</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData && (
|
||||
<div className="grid grid-cols-2 gap-2 pb-4 text-xs @[600px]:grid-cols-4">
|
||||
{exifData.focalLength35mm && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-white/70" />
|
||||
<span className="text-white/90">{exifData.focalLength35mm}mm</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exifData.aperture && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<TablerAperture className="text-white/70" />
|
||||
<span className="text-white/90">{exifData.aperture}</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.aperture && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<TablerAperture className="text-white/70" />
|
||||
<span className="text-white/90">{exifData.aperture}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exifData.shutterSpeed && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<MaterialSymbolsShutterSpeed className="text-white/70" />
|
||||
<span className="text-white/90">{exifData.shutterSpeed}</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.shutterSpeed && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<MaterialSymbolsShutterSpeed className="text-white/70" />
|
||||
<span className="text-white/90">{exifData.shutterSpeed}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exifData.iso && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<CarbonIsoOutline className="text-white/70" />
|
||||
<span className="text-white/90">ISO {exifData.iso}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{exifData.iso && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
<CarbonIsoOutline className="text-white/70" />
|
||||
<span className="text-white/90">ISO {exifData.iso}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -416,11 +416,13 @@ export const CommandPalette = ({ isOpen, onClose }: CommandPaletteProps) => {
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="search"
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('action.search.placeholder')}
|
||||
className="text-text placeholder-text-tertiary flex-1 bg-transparent text-base outline-none"
|
||||
className="text-text placeholder-text-tertiary flex-1 bg-transparent text-base outline-none focus-visible:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -33,10 +33,11 @@ export const ActionGroup = () => {
|
||||
onClick={() => {
|
||||
setCommandPaletteOpen(true)
|
||||
}}
|
||||
className="relative h-10 min-w-10 rounded-full border-0 bg-gray-100 px-3 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
className="relative h-10 min-w-10 rounded-full border-0 bg-gray-100 px-3 transition-colors duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
title={t('action.search.unified.title')}
|
||||
aria-label={t('action.search.unified.title')}
|
||||
>
|
||||
<i className="i-mingcute-search-line text-base text-gray-600 dark:text-gray-300" />
|
||||
<i className="i-mingcute-search-line text-base text-gray-600 dark:text-gray-300" aria-hidden="true" />
|
||||
{filterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-xs font-medium text-white">
|
||||
{filterCount}
|
||||
@@ -49,10 +50,11 @@ export const ActionGroup = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/explory')}
|
||||
className="h-10 w-10 rounded-full border-0 bg-gray-100 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
className="h-10 w-10 rounded-full border-0 bg-gray-100 transition-colors duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
title={t('action.map.explore')}
|
||||
aria-label={t('action.map.explore')}
|
||||
>
|
||||
<i className="i-mingcute-map-pin-line text-base text-gray-600 dark:text-gray-300" />
|
||||
<i className="i-mingcute-map-pin-line text-base text-gray-600 dark:text-gray-300" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
{/* 视图设置按钮(合并排序和列数) */}
|
||||
|
||||
@@ -15,23 +15,25 @@ export const HeroActions = ({ onClearAll, onEditFilters }: HeroActionsProps) =>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onEditFilters}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-transparent px-4 py-2 text-sm font-medium text-white/90 backdrop-blur-sm transition-all duration-200 hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
aria-label={t('gallery.filter.edit')}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-transparent px-4 py-2 text-sm font-medium text-white/90 backdrop-blur-sm transition-colors duration-200 hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={Spring.presets.snappy}
|
||||
>
|
||||
<i className="i-lucide-pencil text-xs" />
|
||||
<i className="i-lucide-pencil text-xs" aria-hidden="true" />
|
||||
<span>{t('gallery.filter.edit')}</span>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-transparent px-4 py-2 text-sm font-medium text-white/90 backdrop-blur-sm transition-all duration-200 hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
aria-label={t('gallery.filter.clearAll')}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-transparent px-4 py-2 text-sm font-medium text-white/90 backdrop-blur-sm transition-colors duration-200 hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={Spring.presets.snappy}
|
||||
>
|
||||
<i className="i-lucide-x text-xs" />
|
||||
<i className="i-lucide-x text-xs" aria-hidden="true" />
|
||||
<span>{t('gallery.filter.clearAll')}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useScrollViewElement } from '@afilmory/ui'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useRef } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||
import { formatExifData } from '~/modules/metadata'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
interface ListViewProps {
|
||||
@@ -99,81 +100,15 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化 EXIF 数据
|
||||
const formatExifData = () => {
|
||||
if (!photo.exif) {
|
||||
return {
|
||||
camera: null,
|
||||
lens: null,
|
||||
iso: null,
|
||||
aperture: null,
|
||||
shutterSpeed: null,
|
||||
focalLength: null,
|
||||
exposureCompensation: null,
|
||||
}
|
||||
}
|
||||
// 使用共享的 EXIF 格式化函数
|
||||
const exifData = useMemo(() => formatExifData(photo.exif ?? null), [photo.exif])
|
||||
|
||||
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()
|
||||
// 从完整的 exifData 中获取焦距显示格式
|
||||
const focalLengthDisplay = exifData?.focalLength35mm
|
||||
? `${exifData.focalLength35mm}mm`
|
||||
: exifData?.focalLength
|
||||
? `${exifData.focalLength}mm`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -245,7 +180,7 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
|
||||
</div>
|
||||
|
||||
{/* 相机 */}
|
||||
{exifData.camera && (
|
||||
{exifData?.camera && (
|
||||
<div className="flex items-center gap-1">
|
||||
<i className="i-lucide-camera text-[10px]" />
|
||||
<span className="truncate">{exifData.camera}</span>
|
||||
@@ -253,7 +188,7 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
|
||||
)}
|
||||
|
||||
{/* 镜头 */}
|
||||
{exifData.lens && (
|
||||
{exifData?.lens && (
|
||||
<div className="flex items-center gap-1">
|
||||
<i className="i-lucide-aperture text-[10px]" />
|
||||
<span className="truncate">{exifData.lens}</span>
|
||||
@@ -270,10 +205,10 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
|
||||
</div>
|
||||
|
||||
{/* 摄影三要素 + 焦距 - 简洁样式 */}
|
||||
{(exifData.iso || exifData.aperture || exifData.shutterSpeed || exifData.focalLength) && (
|
||||
{(exifData?.iso || exifData?.aperture || exifData?.shutterSpeed || focalLengthDisplay) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/10 pt-2">
|
||||
{/* ISO */}
|
||||
{exifData.iso && (
|
||||
{exifData?.iso && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
|
||||
<i className="i-lucide-gauge text-[10px] text-white/70" />
|
||||
<span className="text-[11px] text-white/90">ISO {exifData.iso}</span>
|
||||
@@ -281,7 +216,7 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
|
||||
)}
|
||||
|
||||
{/* 光圈 */}
|
||||
{exifData.aperture && (
|
||||
{exifData?.aperture && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
|
||||
<i className="i-lucide-circle-dot text-[10px] text-white/70" />
|
||||
<span className="text-[11px] text-white/90">{exifData.aperture}</span>
|
||||
@@ -289,7 +224,7 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
|
||||
)}
|
||||
|
||||
{/* 快门速度 */}
|
||||
{exifData.shutterSpeed && (
|
||||
{exifData?.shutterSpeed && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
|
||||
<i className="i-lucide-timer text-[10px] text-white/70" />
|
||||
<span className="text-[11px] text-white/90">{exifData.shutterSpeed}</span>
|
||||
@@ -297,18 +232,18 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
|
||||
)}
|
||||
|
||||
{/* 焦距 */}
|
||||
{exifData.focalLength && (
|
||||
{focalLengthDisplay && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
|
||||
<i className="i-lucide-maximize-2 text-[10px] text-white/70" />
|
||||
<span className="text-[11px] text-white/90">{exifData.focalLength}</span>
|
||||
<span className="text-[11px] text-white/90">{focalLengthDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 曝光补偿 - 次要显示 */}
|
||||
{exifData.exposureCompensation && (
|
||||
{exifData?.exposureBias && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-white/5 px-1.5 py-0.5">
|
||||
<i className="i-lucide-sliders-horizontal text-[10px] text-white/60" />
|
||||
<span className="text-[11px] text-white/70">{exifData.exposureCompensation}</span>
|
||||
<span className="text-[11px] text-white/70">{exifData.exposureBias}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Thumbhash } from '@afilmory/ui'
|
||||
import clsx from 'clsx'
|
||||
import { m } from 'motion/react'
|
||||
import { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '~/icons'
|
||||
import { isMobileDevice } from '~/lib/device-viewport'
|
||||
import { ImageLoaderManager } from '~/lib/image-loader-manager'
|
||||
import { formatExifData } from '~/modules/metadata'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; width: number }) => {
|
||||
@@ -54,46 +55,8 @@ export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; wi
|
||||
// 计算基于宽度的高度
|
||||
const calculatedHeight = width / data.aspectRatio
|
||||
|
||||
// 格式化 EXIF 数据
|
||||
const formatExifData = () => {
|
||||
const { exif } = data
|
||||
|
||||
// 安全处理:如果 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()
|
||||
// 使用共享的 EXIF 格式化函数
|
||||
const exifData = useMemo(() => formatExifData(data.exif ?? null), [data.exif])
|
||||
|
||||
// 检查是否有视频内容(Live Photo 或 Motion Photo)
|
||||
const hasVideo = data.video !== undefined
|
||||
@@ -333,7 +296,7 @@ export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; wi
|
||||
</div>
|
||||
|
||||
{/* EXIF 信息网格 */}
|
||||
{calculatedHeight >= 200 && (
|
||||
{calculatedHeight >= 200 && exifData && (
|
||||
<div className="grid grid-cols-2 gap-2 pb-4 text-xs">
|
||||
{exifData.focalLength35mm && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
|
||||
|
||||
@@ -110,9 +110,10 @@ const MoreActionMenu = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-all duration-200 hover:bg-white/10 hover:text-white lg:hidden"
|
||||
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-colors duration-200 hover:bg-white/10 hover:text-white lg:hidden"
|
||||
aria-label="More options"
|
||||
>
|
||||
<i className="i-mingcute-more-2-line text-lg" />
|
||||
<i className="i-mingcute-more-2-line text-lg" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
@@ -186,10 +187,11 @@ const DesktopViewButton = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-7 items-center justify-center rounded-full text-white/60 transition-all duration-200 hover:bg-white/10 hover:text-white lg:size-8"
|
||||
className="relative flex size-7 items-center justify-center rounded-full text-white/60 transition-colors duration-200 hover:bg-white/10 hover:text-white lg:size-8"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<i className={`${icon} text-sm lg:text-base`} />
|
||||
<i className={`${icon} text-sm lg:text-base`} aria-hidden="true" />
|
||||
{badge && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex size-2 items-center justify-center rounded-full bg-blue-500 lg:size-2.5">
|
||||
<span className="sr-only">{badge}</span>
|
||||
@@ -219,11 +221,12 @@ const MobileViewButton = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-colors duration-200 hover:bg-white/10 hover:text-white lg:size-8"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<i className={`${icon} text-sm lg:text-base`} />
|
||||
<i className={`${icon} text-sm lg:text-base`} aria-hidden="true" />
|
||||
{badge && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex size-2 items-center justify-center rounded-full bg-blue-500 lg:size-2.5">
|
||||
<span className="sr-only">{badge}</span>
|
||||
@@ -267,10 +270,11 @@ const LoginButton = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-colors duration-200 hover:bg-white/10 hover:text-white lg:size-8"
|
||||
title={t('action.login')}
|
||||
aria-label={t('action.login')}
|
||||
>
|
||||
<i className="i-lucide-log-in text-sm lg:text-base" />
|
||||
<i className="i-lucide-log-in text-sm lg:text-base" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
@@ -355,8 +359,9 @@ const UserMenuButton = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-colors duration-200 hover:bg-white/10 hover:text-white lg:size-8"
|
||||
title={t('action.dashboard')}
|
||||
aria-label={t('action.dashboard')}
|
||||
onClick={() => (window.location.href = '/platform')}
|
||||
>
|
||||
<UserAvatar image={user.image} name={user.name || user.id} fallback="?" size={28} className="lg:size-8" />
|
||||
@@ -370,8 +375,9 @@ const UserMenuButton = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="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"
|
||||
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-colors duration-200 hover:bg-white/10 hover:text-white lg:size-8"
|
||||
title={user.name || user.id}
|
||||
aria-label={`User menu: ${user.name || user.id}`}
|
||||
>
|
||||
<UserAvatar image={user.image} name={user.name || user.id} fallback="?" size={28} className="lg:size-8" />
|
||||
</button>
|
||||
|
||||
@@ -26,13 +26,14 @@ export const ActionButton = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-10 w-10 rounded-full border-0 bg-white/10 text-white/60 transition-all duration-200 hover:bg-white/20 hover:text-white"
|
||||
className="relative h-10 w-10 rounded-full border-0 bg-white/10 text-white/60 transition-colors duration-200 hover:bg-white/20 hover:text-white"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<i className={clsxm(icon, 'text-lg')} />
|
||||
<i className={clsxm(icon, 'text-lg')} aria-hidden="true" />
|
||||
{badge && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-xs font-medium text-white shadow-sm">
|
||||
{badge}
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
|
||||
interface SortOptionProps {
|
||||
icon: string
|
||||
label: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const SortOption = ({ icon, label, isActive, onClick }: SortOptionProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={clsxm(
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors duration-200 lg:py-1',
|
||||
'hover:bg-[linear-gradient(to_right,color-mix(in_srgb,var(--color-accent)_8%,transparent),color-mix(in_srgb,var(--color-accent)_5%,transparent))]',
|
||||
'hover:text-accent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-1',
|
||||
)}
|
||||
>
|
||||
<i className={icon} />
|
||||
<span>{label}</span>
|
||||
{isActive && <i className="i-mingcute-check-line ml-auto" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
@@ -13,52 +40,21 @@ export const SortPanel = () => {
|
||||
sortOrder: order,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-mx-2 flex flex-col p-0 text-sm lg:p-0">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 rounded-lg bg-transparent px-2 py-2 transition-all duration-200 lg:py-1"
|
||||
style={{
|
||||
// @ts-ignore - CSS variable for hover state
|
||||
'--highlight-bg':
|
||||
'linear-gradient(to right, color-mix(in srgb, var(--color-accent) 8%, transparent), color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(to right, color-mix(in srgb, var(--color-accent) 8%, transparent), color-mix(in srgb, var(--color-accent) 5%, transparent))'
|
||||
e.currentTarget.style.color = 'var(--color-accent)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = ''
|
||||
}}
|
||||
<SortOption
|
||||
icon="i-mingcute-sort-descending-line"
|
||||
label={t('action.sort.newest.first')}
|
||||
isActive={gallerySetting.sortOrder === 'desc'}
|
||||
onClick={() => setSortOrder('desc')}
|
||||
>
|
||||
<i className="i-mingcute-sort-descending-line" />
|
||||
<span>{t('action.sort.newest.first')}</span>
|
||||
{gallerySetting.sortOrder === 'desc' && <i className="i-mingcute-check-line ml-auto" />}
|
||||
</div>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 rounded-lg bg-transparent px-2 py-2 transition-all duration-200 lg:py-1"
|
||||
style={{
|
||||
// @ts-ignore - CSS variable for hover state
|
||||
'--highlight-bg':
|
||||
'linear-gradient(to right, color-mix(in srgb, var(--color-accent) 8%, transparent), color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(to right, color-mix(in srgb, var(--color-accent) 8%, transparent), color-mix(in srgb, var(--color-accent) 5%, transparent))'
|
||||
e.currentTarget.style.color = 'var(--color-accent)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = ''
|
||||
}}
|
||||
/>
|
||||
<SortOption
|
||||
icon="i-mingcute-sort-ascending-line"
|
||||
label={t('action.sort.oldest.first')}
|
||||
isActive={gallerySetting.sortOrder === 'asc'}
|
||||
onClick={() => setSortOrder('asc')}
|
||||
>
|
||||
<i className="i-mingcute-sort-ascending-line" />
|
||||
<span>{t('action.sort.oldest.first')}</span>
|
||||
{gallerySetting.sortOrder === 'asc' && <i className="i-mingcute-check-line ml-auto" />}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,9 +180,30 @@ export const formatExifData = (exif: PickedExif | null) => {
|
||||
// ISO
|
||||
const iso = exif.ISO
|
||||
|
||||
// 快门速度
|
||||
const exposureTime = exif.ExposureTime
|
||||
const shutterSpeed = exposureTime ? `${exposureTime}s` : exif.ShutterSpeedValue ? `${exif.ShutterSpeedValue}s` : null
|
||||
// 快门速度 - 使用分数格式更友好
|
||||
const shutterSpeed = (() => {
|
||||
const exposureTime = exif.ExposureTime
|
||||
if (exposureTime) {
|
||||
if (typeof exposureTime === 'number') {
|
||||
if (exposureTime >= 1) {
|
||||
return `${exposureTime}s`
|
||||
}
|
||||
return `1/${Math.round(1 / exposureTime)}s`
|
||||
}
|
||||
return `${exposureTime}s`
|
||||
}
|
||||
if (exif.ShutterSpeedValue) {
|
||||
const speed =
|
||||
typeof exif.ShutterSpeedValue === 'number'
|
||||
? exif.ShutterSpeedValue
|
||||
: Number.parseFloat(String(exif.ShutterSpeedValue))
|
||||
if (speed >= 1) {
|
||||
return `${speed}s`
|
||||
}
|
||||
return `1/${Math.round(1 / speed)}s`
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
// 光圈
|
||||
const aperture = exif.FNumber ? `f/${exif.FNumber}` : null
|
||||
|
||||
@@ -82,6 +82,8 @@ export const CommentInput = () => {
|
||||
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
name="comment"
|
||||
autoComplete="off"
|
||||
value={newComment}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('comments.placeholder')}
|
||||
|
||||
Reference in New Issue
Block a user