mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: implement active filters feature in gallery module
- Added `useHasActiveFilters` hook to determine if any filters are active based on selected tags, cameras, lenses, and ratings. - Introduced `ActiveFiltersHero` component to display active filters and related actions, including options to edit or clear filters. - Created `FilterChip`, `FilterChips`, and `FilterStats` components for managing and displaying individual filter selections. - Updated `PhotosRoot` to integrate the new active filters UI, enhancing user experience by providing immediate feedback on applied filters. - Added localization strings for active filters in multiple languages. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
14
apps/web/src/hooks/useHasActiveFilters.ts
Normal file
14
apps/web/src/hooks/useHasActiveFilters.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useAtomValue } from 'jotai'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
|
||||
export const useHasActiveFilters = () => {
|
||||
const gallerySetting = useAtomValue(gallerySettingAtom)
|
||||
|
||||
return (
|
||||
gallerySetting.selectedTags.length > 0 ||
|
||||
gallerySetting.selectedCameras.length > 0 ||
|
||||
gallerySetting.selectedLenses.length > 0 ||
|
||||
gallerySetting.selectedRatings !== null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m as motion } from 'motion/react'
|
||||
|
||||
interface FilterChipProps {
|
||||
type: 'tag' | 'camera' | 'lens' | 'rating'
|
||||
label: string
|
||||
onRemove: () => void
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export const FilterChip = ({ type, label, onRemove, icon }: FilterChipProps) => {
|
||||
const getIcon = () => {
|
||||
if (icon) return icon
|
||||
switch (type) {
|
||||
case 'tag': {
|
||||
return 'i-lucide-tag'
|
||||
}
|
||||
case 'camera': {
|
||||
return 'i-lucide-camera'
|
||||
}
|
||||
case 'lens': {
|
||||
return 'i-lucide-aperture'
|
||||
}
|
||||
case 'rating': {
|
||||
return 'i-lucide-star'
|
||||
}
|
||||
default: {
|
||||
return 'i-lucide-filter'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="group flex max-w-[280px] min-w-0 items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-3 py-1.5 text-sm backdrop-blur-md transition-all duration-200 hover:border-white/30 hover:bg-white/15 sm:max-w-[320px]"
|
||||
>
|
||||
<i className={`${getIcon()} shrink-0 text-xs text-white/70`} />
|
||||
<span className="min-w-0 truncate text-white/90">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-0.5 flex shrink-0 items-center justify-center rounded-full p-0.5 text-white/60 transition-colors hover:bg-white/10 hover:text-white/90"
|
||||
aria-label="Remove filter"
|
||||
>
|
||||
<i className="i-lucide-x text-xs" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
|
||||
import { FilterChip } from './FilterChip'
|
||||
|
||||
interface FilterChipsProps {
|
||||
tags: string[]
|
||||
cameras: string[]
|
||||
lenses: string[]
|
||||
rating: number | null
|
||||
onRemoveTag: (tag: string) => void
|
||||
onRemoveCamera: (camera: string) => void
|
||||
onRemoveLens: (lens: string) => void
|
||||
onRemoveRating: () => void
|
||||
}
|
||||
|
||||
export const FilterChips = ({
|
||||
tags,
|
||||
cameras,
|
||||
lenses,
|
||||
rating,
|
||||
onRemoveTag,
|
||||
onRemoveCamera,
|
||||
onRemoveLens,
|
||||
onRemoveRating,
|
||||
}: FilterChipsProps) => {
|
||||
const hasFilters = tags.length > 0 || cameras.length > 0 || lenses.length > 0 || rating !== null
|
||||
|
||||
if (!hasFilters) return null
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{tags.map((tag) => (
|
||||
<FilterChip key={`tag-${tag}`} type="tag" label={tag} onRemove={() => onRemoveTag(tag)} />
|
||||
))}
|
||||
{cameras.map((camera) => (
|
||||
<FilterChip key={`camera-${camera}`} type="camera" label={camera} onRemove={() => onRemoveCamera(camera)} />
|
||||
))}
|
||||
{lenses.map((lens) => (
|
||||
<FilterChip key={`lens-${lens}`} type="lens" label={lens} onRemove={() => onRemoveLens(lens)} />
|
||||
))}
|
||||
{rating !== null && (
|
||||
<FilterChip key="rating" type="rating" label={`${rating}+`} onRemove={onRemoveRating} icon="i-lucide-star" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface FilterStatsProps {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const FilterStats = ({ count }: FilterStatsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="i-mingcute-filter-line text-base text-white/70" />
|
||||
<span className="text-sm font-medium text-white/90">{t('gallery.filter.results', { count })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m as motion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface HeroActionsProps {
|
||||
onClearAll: () => void
|
||||
onEditFilters: () => void
|
||||
}
|
||||
|
||||
export const HeroActions = ({ onClearAll, onEditFilters }: HeroActionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={Spring.presets.snappy}
|
||||
>
|
||||
<i className="i-lucide-pencil text-xs" />
|
||||
<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"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={Spring.presets.snappy}
|
||||
>
|
||||
<i className="i-lucide-x text-xs" />
|
||||
<span>{t('gallery.filter.clearAll')}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
apps/web/src/modules/gallery/ActiveFiltersHero/index.tsx
Normal file
246
apps/web/src/modules/gallery/ActiveFiltersHero/index.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { m as motion } from 'motion/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { gallerySettingAtom, isCommandPaletteOpenAtom } from '~/atoms/app'
|
||||
import { useContextPhotos } from '~/hooks/usePhotoViewer'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import { FilterChips } from './FilterChips'
|
||||
import { HeroActions } from './HeroActions'
|
||||
|
||||
// 从照片中随机选择一些作为背景拼贴
|
||||
const getRandomPhotos = (photos: PhotoManifest[], count = 12): PhotoManifest[] => {
|
||||
if (photos.length === 0) return []
|
||||
const shuffled = [...photos].sort(() => Math.random() - 0.5)
|
||||
return shuffled.slice(0, Math.min(count, photos.length))
|
||||
}
|
||||
|
||||
export const ActiveFiltersHero = () => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
const setCommandPaletteOpen = useSetAtom(isCommandPaletteOpenAtom)
|
||||
const photos = useContextPhotos()
|
||||
|
||||
// 获取背景照片(使用筛选后的照片)
|
||||
const backgroundPhotos = useMemo(() => getRandomPhotos(photos, 12), [photos])
|
||||
|
||||
const summarizeValues = (items: string[], limit = 3) => {
|
||||
if (items.length === 0) return ''
|
||||
const summary = items.slice(0, limit).join(' · ')
|
||||
const remaining = items.length - limit
|
||||
return remaining > 0 ? `${summary} +${remaining}` : summary
|
||||
}
|
||||
|
||||
const headline = useMemo(() => {
|
||||
const fragments: string[] = []
|
||||
if (gallerySetting.selectedTags.length > 0) {
|
||||
fragments.push(summarizeValues(gallerySetting.selectedTags, 4))
|
||||
}
|
||||
if (gallerySetting.selectedCameras.length > 0) {
|
||||
fragments.push(summarizeValues(gallerySetting.selectedCameras, 2))
|
||||
}
|
||||
if (gallerySetting.selectedLenses.length > 0) {
|
||||
fragments.push(summarizeValues(gallerySetting.selectedLenses, 2))
|
||||
}
|
||||
if (gallerySetting.selectedRatings !== null) {
|
||||
fragments.push(`${gallerySetting.selectedRatings}+ ★`)
|
||||
}
|
||||
if (fragments.length === 0) return t('gallery.filter.active')
|
||||
return fragments.slice(0, 2).join(' · ')
|
||||
}, [
|
||||
gallerySetting.selectedCameras,
|
||||
gallerySetting.selectedLenses,
|
||||
gallerySetting.selectedRatings,
|
||||
gallerySetting.selectedTags,
|
||||
t,
|
||||
])
|
||||
|
||||
const infoItems = useMemo(() => {
|
||||
const items: Array<{ key: string; icon: string; label: string; value: string }> = []
|
||||
if (gallerySetting.selectedTags.length > 0) {
|
||||
items.push({
|
||||
key: 'tags',
|
||||
icon: 'i-lucide-tag',
|
||||
label: t('exif.tags'),
|
||||
value: gallerySetting.selectedTags.join(' · '),
|
||||
})
|
||||
}
|
||||
if (gallerySetting.selectedCameras.length > 0) {
|
||||
items.push({
|
||||
key: 'cameras',
|
||||
icon: 'i-lucide-camera',
|
||||
label: t('exif.camera'),
|
||||
value: gallerySetting.selectedCameras.join(' · '),
|
||||
})
|
||||
}
|
||||
if (gallerySetting.selectedLenses.length > 0) {
|
||||
items.push({
|
||||
key: 'lenses',
|
||||
icon: 'i-lucide-aperture',
|
||||
label: t('exif.lens'),
|
||||
value: gallerySetting.selectedLenses.join(' · '),
|
||||
})
|
||||
}
|
||||
if (gallerySetting.selectedRatings !== null) {
|
||||
items.push({
|
||||
key: 'rating',
|
||||
icon: 'i-lucide-star',
|
||||
label: t('exif.rating'),
|
||||
value: `${gallerySetting.selectedRatings}+`,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}, [
|
||||
gallerySetting.selectedCameras,
|
||||
gallerySetting.selectedLenses,
|
||||
gallerySetting.selectedRatings,
|
||||
gallerySetting.selectedTags,
|
||||
t,
|
||||
])
|
||||
|
||||
const tagModeLabel =
|
||||
gallerySetting.selectedTags.length > 1
|
||||
? gallerySetting.tagFilterMode === 'intersection'
|
||||
? t('action.tag.mode.and')
|
||||
: t('action.tag.mode.or')
|
||||
: null
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedTags: prev.selectedTags.filter((t) => t !== tag),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveCamera = (camera: string) => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedCameras: prev.selectedCameras.filter((c) => c !== camera),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveLens = (lens: string) => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedLenses: prev.selectedLenses.filter((l) => l !== lens),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveRating = () => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedRatings: null,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedTags: [],
|
||||
selectedCameras: [],
|
||||
selectedLenses: [],
|
||||
selectedRatings: null,
|
||||
tagFilterMode: 'union',
|
||||
}))
|
||||
}
|
||||
|
||||
const handleEditFilters = () => {
|
||||
setCommandPaletteOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border border-white/5 bg-black shadow-[0_25px_60px_rgba(0,0,0,0.55)]"
|
||||
>
|
||||
{/* Background Photo Grid */}
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="grid h-full w-full grid-cols-4 grid-rows-3 gap-[2px] opacity-90">
|
||||
{Array.from({ length: 12 }).map((_, index) => {
|
||||
const photo = backgroundPhotos[index]
|
||||
if (photo) {
|
||||
return (
|
||||
<div key={photo.id} className="relative overflow-hidden">
|
||||
<img
|
||||
src={photo.thumbnailUrl || photo.originalUrl}
|
||||
alt=""
|
||||
className="h-full w-full scale-105 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-br from-black/80 via-black/50 to-black/80" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div key={`empty-${index}`} className="bg-linear-to-br from-zinc-900 via-black to-zinc-900" />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dark Overlay Gradient */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-black/80 via-black/90 to-black/95" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.15),rgba(0,0,0,0)_60%)] opacity-70" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex min-h-[320px] flex-col">
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 pt-20 pb-12 text-center lg:px-12 lg:pt-28 lg:pb-16">
|
||||
<p className="text-xs font-medium tracking-[0.5em] text-white/60 uppercase">
|
||||
{t('gallery.filter.results', { count: photos.length })}
|
||||
</p>
|
||||
<h2 className="mt-4 text-4xl font-semibold text-white drop-shadow-[0_15px_30px_rgba(0,0,0,0.65)] lg:text-5xl">
|
||||
{headline}
|
||||
</h2>
|
||||
|
||||
{infoItems.length > 0 && (
|
||||
<div className="mt-8 grid w-full max-w-4xl grid-cols-1 gap-3 text-left sm:grid-cols-2 lg:grid-cols-4">
|
||||
{infoItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex h-full flex-col gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white/90 backdrop-blur"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold tracking-[0.3em] text-white/50 uppercase">
|
||||
<i className={`${item.icon} text-sm text-white/70`} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-base font-semibold text-white">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tagModeLabel && (
|
||||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-[0.3em] text-white/65 uppercase backdrop-blur">
|
||||
<i className="i-lucide-git-branch text-sm text-white/70" />
|
||||
<span>
|
||||
{t('action.tag.filter')} · {tagModeLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-4xl flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
|
||||
<div className="flex min-w-0 flex-1 justify-center lg:justify-start">
|
||||
<FilterChips
|
||||
tags={gallerySetting.selectedTags}
|
||||
cameras={gallerySetting.selectedCameras}
|
||||
lenses={gallerySetting.selectedLenses}
|
||||
rating={gallerySetting.selectedRatings}
|
||||
onRemoveTag={handleRemoveTag}
|
||||
onRemoveCamera={handleRemoveCamera}
|
||||
onRemoveLens={handleRemoveLens}
|
||||
onRemoveRating={handleRemoveRating}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-center lg:justify-end">
|
||||
<HeroActions onClearAll={handleClearAll} onEditFilters={handleEditFilters} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; wi
|
||||
src={data.thumbnailUrl}
|
||||
alt={data.title}
|
||||
loading="lazy"
|
||||
className={clsx('absolute inset-0 h-full w-full object-cover duration-300 group-hover:scale-105')}
|
||||
className={'absolute inset-0 h-full w-full object-cover duration-300 group-hover:scale-105'}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useScrollViewElement } from '@afilmory/ui'
|
||||
import clsx from 'clsx'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
import { useHasActiveFilters } from '~/hooks/useHasActiveFilters'
|
||||
import { useContextPhotos } from '~/hooks/usePhotoViewer'
|
||||
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
|
||||
|
||||
import type { PanelType } from './ActionPanel'
|
||||
import { ActionPanel } from './ActionPanel'
|
||||
import { ActiveFiltersHero } from './ActiveFiltersHero'
|
||||
import { ListView } from './ListView'
|
||||
import { MasonryView } from './MasonryView'
|
||||
import { PageHeader } from './PageHeader'
|
||||
@@ -37,6 +40,8 @@ export const PhotosRoot = () => {
|
||||
}
|
||||
}, [scrollElement])
|
||||
|
||||
const hasActiveFilters = useHasActiveFilters()
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -45,7 +50,9 @@ export const PhotosRoot = () => {
|
||||
showDateRange={showFloatingActions && !!dateRange.formattedRange}
|
||||
/>
|
||||
|
||||
<div className="mt-12 p-1 **:select-none! lg:px-0 lg:pb-0">
|
||||
{hasActiveFilters && <ActiveFiltersHero />}
|
||||
|
||||
<div className={clsx('p-1 **:select-none! lg:px-0 lg:pb-0', !hasActiveFilters && 'mt-12')}>
|
||||
{viewMode === 'list' ? <ListView photos={photos} /> : <MasonryView photos={photos} onRender={handleRender} />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -337,6 +337,10 @@
|
||||
"explory.range.separator": "to",
|
||||
"explory.shooting.range": "Shooting Range:",
|
||||
"gallery.built.at": "Built at ",
|
||||
"gallery.filter.active": "Active Filters",
|
||||
"gallery.filter.clearAll": "Clear All",
|
||||
"gallery.filter.edit": "Edit Filters",
|
||||
"gallery.filter.results": "Showing {{count}} photos",
|
||||
"gallery.photos_one": "{{count}} photo",
|
||||
"gallery.photos_other": "{{count}} photos",
|
||||
"gallery.search": "Search",
|
||||
|
||||
@@ -308,6 +308,10 @@
|
||||
"explory.range.separator": "から",
|
||||
"explory.shooting.range": "撮影範囲:",
|
||||
"gallery.built.at": "ビルド日時 ",
|
||||
"gallery.filter.active": "アクティブなフィルター",
|
||||
"gallery.filter.clearAll": "すべてクリア",
|
||||
"gallery.filter.edit": "フィルターを編集",
|
||||
"gallery.filter.results": "{{count}} 枚の写真を表示",
|
||||
"gallery.photos_one": "写真{{count}}枚",
|
||||
"gallery.photos_other": "写真{{count}}枚",
|
||||
"gallery.search": "検索",
|
||||
|
||||
@@ -308,6 +308,10 @@
|
||||
"explory.range.separator": "~",
|
||||
"explory.shooting.range": "촬영 범위:",
|
||||
"gallery.built.at": "빌드 날짜 ",
|
||||
"gallery.filter.active": "활성 필터",
|
||||
"gallery.filter.clearAll": "모두 지우기",
|
||||
"gallery.filter.edit": "필터 편집",
|
||||
"gallery.filter.results": "{{count}}장의 사진 표시",
|
||||
"gallery.photos_one": "사진 {{count}}장",
|
||||
"gallery.photos_other": "사진 {{count}}장",
|
||||
"gallery.search": "검색",
|
||||
|
||||
@@ -334,6 +334,10 @@
|
||||
"explory.range.separator": "至",
|
||||
"explory.shooting.range": "拍摄范围:",
|
||||
"gallery.built.at": "构建于 ",
|
||||
"gallery.filter.active": "活跃筛选",
|
||||
"gallery.filter.clearAll": "清除全部",
|
||||
"gallery.filter.edit": "编辑筛选",
|
||||
"gallery.filter.results": "显示 {{count}} 张照片",
|
||||
"gallery.photos_one": "{{count}} 张照片",
|
||||
"gallery.photos_other": "{{count}} 张照片",
|
||||
"gallery.search": "搜索",
|
||||
|
||||
@@ -308,6 +308,10 @@
|
||||
"explory.range.separator": "至",
|
||||
"explory.shooting.range": "拍攝範圍:",
|
||||
"gallery.built.at": "建置於 ",
|
||||
"gallery.filter.active": "活躍篩選",
|
||||
"gallery.filter.clearAll": "清除全部",
|
||||
"gallery.filter.edit": "編輯篩選",
|
||||
"gallery.filter.results": "顯示 {{count}} 張照片",
|
||||
"gallery.photos_one": "{{count}} 張照片",
|
||||
"gallery.photos_other": "{{count}} 張照片",
|
||||
"gallery.search": "搜尋",
|
||||
|
||||
@@ -307,6 +307,10 @@
|
||||
"explory.range.separator": "至",
|
||||
"explory.shooting.range": "拍攝範圍:",
|
||||
"gallery.built.at": "建置於 ",
|
||||
"gallery.filter.active": "活躍篩選",
|
||||
"gallery.filter.clearAll": "清除全部",
|
||||
"gallery.filter.edit": "編輯篩選",
|
||||
"gallery.filter.results": "顯示 {{count}} 張照片",
|
||||
"gallery.photos_one": "{{count}} 張照片",
|
||||
"gallery.photos_other": "{{count}} 張照片",
|
||||
"gallery.search": "搜尋",
|
||||
|
||||
Reference in New Issue
Block a user