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 (
<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>
)
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
{/* 视图设置按钮(合并排序和列数) */}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
)
}

View File

@@ -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

View File

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