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:
Innei
2026-01-18 15:46:02 +08:00
parent e1d1d73364
commit 76b7d47acd
12 changed files with 163 additions and 261 deletions

View File

@@ -14,8 +14,13 @@ export const MapBackButton = () => {
} }
return ( return (
<GlassButton className="absolute top-4 left-4 z-50" onClick={handleBack} title={t('explory.back.to.gallery')}> <GlassButton
<i className="i-mingcute-arrow-left-line text-base text-white" /> 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> </GlassButton>
) )
} }

View File

@@ -1,5 +1,6 @@
import type { PhotoManifestItem } from '@afilmory/builder' import type { PhotoManifestItem } from '@afilmory/builder'
import { clsxm as cn } from '@afilmory/utils' import { clsxm as cn } from '@afilmory/utils'
import { useMemo } from 'react'
import { thumbHashToDataURL } from 'thumbhash' import { thumbHashToDataURL } from 'thumbhash'
import { import {
@@ -8,6 +9,7 @@ import {
StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens, StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens,
TablerAperture, TablerAperture,
} from '~/icons' } from '~/icons'
import { formatExifData } from '~/modules/metadata'
const decompressUint8Array = (compressed: string) => { const decompressUint8Array = (compressed: string) => {
return Uint8Array.from(compressed.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16))) 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 const ratio = photo.aspectRatio
// 格式化 EXIF 数 // 使用共享的 EXIF 格式化函
const formatExifData = () => { const exifData = useMemo(() => formatExifData(photo.exif ?? null), [photo.exif])
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 ( return (
<button <button
type="button" type="button"
role="link" role="link"
aria-label={`View photo: ${photo.title}`}
onClick={() => { onClick={() => {
const siteUrl = window.__SITE_CONFIG__?.url || '' const siteUrl = window.__SITE_CONFIG__?.url || ''
if (siteUrl) { if (siteUrl) {
@@ -146,35 +111,37 @@ export function PhotoItem({ photo, className }: PhotoItemProps) {
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-2 pb-4 text-xs @[600px]:grid-cols-4"> {exifData && (
{exifData.focalLength35mm && ( <div className="grid grid-cols-2 gap-2 pb-4 text-xs @[600px]:grid-cols-4">
<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"> {exifData.focalLength35mm && (
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-white/70" /> <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">
<span className="text-white/90">{exifData.focalLength35mm}mm</span> <StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-white/70" />
</div> <span className="text-white/90">{exifData.focalLength35mm}mm</span>
)} </div>
)}
{exifData.aperture && ( {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"> <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" /> <TablerAperture className="text-white/70" />
<span className="text-white/90">{exifData.aperture}</span> <span className="text-white/90">{exifData.aperture}</span>
</div> </div>
)} )}
{exifData.shutterSpeed && ( {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"> <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" /> <MaterialSymbolsShutterSpeed className="text-white/70" />
<span className="text-white/90">{exifData.shutterSpeed}</span> <span className="text-white/90">{exifData.shutterSpeed}</span>
</div> </div>
)} )}
{exifData.iso && ( {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"> <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" /> <CarbonIsoOutline className="text-white/70" />
<span className="text-white/90">ISO {exifData.iso}</span> <span className="text-white/90">ISO {exifData.iso}</span>
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
</button> </button>

View File

@@ -416,11 +416,13 @@ export const CommandPalette = ({ isOpen, onClose }: CommandPaletteProps) => {
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
name="search"
autoComplete="off"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t('action.search.placeholder')} 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 <button
type="button" type="button"

View File

@@ -33,10 +33,11 @@ export const ActionGroup = () => {
onClick={() => { onClick={() => {
setCommandPaletteOpen(true) 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')} 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 && ( {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"> <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} {filterCount}
@@ -49,10 +50,11 @@ export const ActionGroup = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => navigate('/explory')} 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')} 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> </Button>
{/* 视图设置按钮(合并排序和列数) */} {/* 视图设置按钮(合并排序和列数) */}

View File

@@ -15,23 +15,25 @@ export const HeroActions = ({ onClearAll, onEditFilters }: HeroActionsProps) =>
<motion.button <motion.button
type="button" type="button"
onClick={onEditFilters} 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 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
transition={Spring.presets.snappy} 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> <span>{t('gallery.filter.edit')}</span>
</motion.button> </motion.button>
<motion.button <motion.button
type="button" type="button"
onClick={onClearAll} 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 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
transition={Spring.presets.snappy} 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> <span>{t('gallery.filter.clearAll')}</span>
</motion.button> </motion.button>
</div> </div>

View File

@@ -1,10 +1,11 @@
import { useScrollViewElement } from '@afilmory/ui' import { useScrollViewElement } from '@afilmory/ui'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react' import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMobile } from '~/hooks/useMobile' import { useMobile } from '~/hooks/useMobile'
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer' import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import { formatExifData } from '~/modules/metadata'
import type { PhotoManifest } from '~/types/photo' import type { PhotoManifest } from '~/types/photo'
interface ListViewProps { interface ListViewProps {
@@ -99,81 +100,15 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
}) })
} }
// 格式化 EXIF 数 // 使用共享的 EXIF 格式化函
const formatExifData = () => { const exifData = useMemo(() => formatExifData(photo.exif ?? null), [photo.exif])
if (!photo.exif) {
return {
camera: null,
lens: null,
iso: null,
aperture: null,
shutterSpeed: null,
focalLength: null,
exposureCompensation: null,
}
}
const { exif } = photo // 从完整的 exifData 中获取焦距显示格式
const focalLengthDisplay = exifData?.focalLength35mm
// 相机信息 ? `${exifData.focalLength35mm}mm`
const camera = exif.Make && exif.Model ? `${exif.Make} ${exif.Model}` : exif.Model || exif.Make || null : exifData?.focalLength
? `${exifData.focalLength}mm`
// 镜头信息 : 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 ( return (
<div <div
@@ -245,7 +180,7 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
</div> </div>
{/* 相机 */} {/* 相机 */}
{exifData.camera && ( {exifData?.camera && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<i className="i-lucide-camera text-[10px]" /> <i className="i-lucide-camera text-[10px]" />
<span className="truncate">{exifData.camera}</span> <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"> <div className="flex items-center gap-1">
<i className="i-lucide-aperture text-[10px]" /> <i className="i-lucide-aperture text-[10px]" />
<span className="truncate">{exifData.lens}</span> <span className="truncate">{exifData.lens}</span>
@@ -270,10 +205,10 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
</div> </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"> <div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/10 pt-2">
{/* ISO */} {/* ISO */}
{exifData.iso && ( {exifData?.iso && (
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md"> <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" /> <i className="i-lucide-gauge text-[10px] text-white/70" />
<span className="text-[11px] text-white/90">ISO {exifData.iso}</span> <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"> <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" /> <i className="i-lucide-circle-dot text-[10px] text-white/70" />
<span className="text-[11px] text-white/90">{exifData.aperture}</span> <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"> <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" /> <i className="i-lucide-timer text-[10px] text-white/70" />
<span className="text-[11px] text-white/90">{exifData.shutterSpeed}</span> <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"> <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" /> <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> </div>
)} )}
{/* 曝光补偿 - 次要显示 */} {/* 曝光补偿 - 次要显示 */}
{exifData.exposureCompensation && ( {exifData?.exposureBias && (
<div className="flex items-center gap-1 rounded-md bg-white/5 px-1.5 py-0.5"> <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" /> <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>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { Thumbhash } from '@afilmory/ui' import { Thumbhash } from '@afilmory/ui'
import clsx from 'clsx' import clsx from 'clsx'
import { m } from 'motion/react' 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 { useTranslation } from 'react-i18next'
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer' import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
@@ -13,6 +13,7 @@ import {
} from '~/icons' } from '~/icons'
import { isMobileDevice } from '~/lib/device-viewport' import { isMobileDevice } from '~/lib/device-viewport'
import { ImageLoaderManager } from '~/lib/image-loader-manager' import { ImageLoaderManager } from '~/lib/image-loader-manager'
import { formatExifData } from '~/modules/metadata'
import type { PhotoManifest } from '~/types/photo' import type { PhotoManifest } from '~/types/photo'
export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; width: number }) => { 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 const calculatedHeight = width / data.aspectRatio
// 格式化 EXIF 数 // 使用共享的 EXIF 格式化函
const formatExifData = () => { const exifData = useMemo(() => formatExifData(data.exif ?? null), [data.exif])
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()
// 检查是否有视频内容Live Photo 或 Motion Photo // 检查是否有视频内容Live Photo 或 Motion Photo
const hasVideo = data.video !== undefined const hasVideo = data.video !== undefined
@@ -333,7 +296,7 @@ export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; wi
</div> </div>
{/* EXIF 信息网格 */} {/* EXIF 信息网格 */}
{calculatedHeight >= 200 && ( {calculatedHeight >= 200 && exifData && (
<div className="grid grid-cols-2 gap-2 pb-4 text-xs"> <div className="grid grid-cols-2 gap-2 pb-4 text-xs">
{exifData.focalLength35mm && ( {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"> <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">

View File

@@ -110,9 +110,10 @@ const MoreActionMenu = () => {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="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> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]"> <DropdownMenuContent align="end" className="min-w-[180px]">
@@ -186,10 +187,11 @@ const DesktopViewButton = ({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="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} 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 && ( {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="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> <span className="sr-only">{badge}</span>
@@ -219,11 +221,12 @@ const MobileViewButton = ({
<> <>
<button <button
type="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} title={title}
aria-label={title}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
<i className={`${icon} text-sm lg:text-base`} /> <i className={`${icon} text-sm lg:text-base`} aria-hidden="true" />
{badge && ( {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="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> <span className="sr-only">{badge}</span>
@@ -267,10 +270,11 @@ const LoginButton = () => {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="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')} 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> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[200px]"> <DropdownMenuContent align="end" className="min-w-[200px]">
@@ -355,8 +359,9 @@ const UserMenuButton = ({
return ( return (
<button <button
type="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')} title={t('action.dashboard')}
aria-label={t('action.dashboard')}
onClick={() => (window.location.href = '/platform')} onClick={() => (window.location.href = '/platform')}
> >
<UserAvatar image={user.image} name={user.name || user.id} fallback="?" size={28} className="lg:size-8" /> <UserAvatar image={user.image} name={user.name || user.id} fallback="?" size={28} className="lg:size-8" />
@@ -370,8 +375,9 @@ const UserMenuButton = ({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="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} 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" /> <UserAvatar image={user.image} name={user.name || user.id} fallback="?" size={28} className="lg:size-8" />
</button> </button>

View File

@@ -26,13 +26,14 @@ export const ActionButton = ({
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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} title={title}
aria-label={title}
onClick={onClick} onClick={onClick}
ref={ref} ref={ref}
{...props} {...props}
> >
<i className={clsxm(icon, 'text-lg')} /> <i className={clsxm(icon, 'text-lg')} aria-hidden="true" />
{badge && ( {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"> <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} {badge}

View File

@@ -1,8 +1,35 @@
import { clsxm } from '@afilmory/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { gallerySettingAtom } from '~/atoms/app' 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 = () => { export const SortPanel = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom) const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
@@ -13,52 +40,21 @@ export const SortPanel = () => {
sortOrder: order, sortOrder: order,
}) })
} }
return ( return (
<div className="-mx-2 flex flex-col p-0 text-sm lg:p-0"> <div className="-mx-2 flex flex-col p-0 text-sm lg:p-0">
<div <SortOption
className="group flex cursor-pointer items-center gap-2 rounded-lg bg-transparent px-2 py-2 transition-all duration-200 lg:py-1" icon="i-mingcute-sort-descending-line"
style={{ label={t('action.sort.newest.first')}
// @ts-ignore - CSS variable for hover state isActive={gallerySetting.sortOrder === 'desc'}
'--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 = ''
}}
onClick={() => setSortOrder('desc')} onClick={() => setSortOrder('desc')}
> />
<i className="i-mingcute-sort-descending-line" /> <SortOption
<span>{t('action.sort.newest.first')}</span> icon="i-mingcute-sort-ascending-line"
{gallerySetting.sortOrder === 'desc' && <i className="i-mingcute-check-line ml-auto" />} label={t('action.sort.oldest.first')}
</div> isActive={gallerySetting.sortOrder === 'asc'}
<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 = ''
}}
onClick={() => setSortOrder('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> </div>
) )
} }

View File

@@ -180,9 +180,30 @@ export const formatExifData = (exif: PickedExif | null) => {
// ISO // ISO
const iso = exif.ISO const iso = exif.ISO
// 快门速度 // 快门速度 - 使用分数格式更友好
const exposureTime = exif.ExposureTime const shutterSpeed = (() => {
const shutterSpeed = exposureTime ? `${exposureTime}s` : exif.ShutterSpeedValue ? `${exif.ShutterSpeedValue}s` : null 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 const aperture = exif.FNumber ? `f/${exif.FNumber}` : null

View File

@@ -82,6 +82,8 @@ export const CommentInput = () => {
<div className="flex-1"> <div className="flex-1">
<textarea <textarea
name="comment"
autoComplete="off"
value={newComment} value={newComment}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={t('comments.placeholder')} placeholder={t('comments.placeholder')}