feat: add Gallery Showcase component and update documentation

- Introduced a new Gallery Showcase component to display featured galleries on the landing page.
- Updated localization files to include new strings for the Gallery Showcase.
- Enhanced the landing page by integrating the Gallery Showcase component alongside existing elements.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-24 21:00:19 +08:00
parent f7956c9a4e
commit 5a98b43544
16 changed files with 1751 additions and 1403 deletions

View File

@@ -224,7 +224,7 @@
"title": "Cloudflare Pages",
"meta": {
"title": "Cloudflare Pages",
"description": "Deploy your gallery to Cloudflare Pages for fast global CDN distribution.",
"description": "Guide to deploying Afilmory via Cloudflare Pages.",
"createdAt": "2025-07-20T22:35:03+08:00",
"lastModified": "2025-11-23T19:40:52+08:00",
"order": "53"

View File

@@ -282,7 +282,7 @@ export const routes: RouteConfig[] = [
title: 'Cloudflare Pages',
meta: {
title: 'Cloudflare Pages',
description: 'Deploy your gallery to Cloudflare Pages for fast global CDN distribution.',
description: 'Guide to deploying Afilmory via Cloudflare Pages.',
createdAt: '2025-07-20T22:35:03+08:00',
lastModified: '2025-11-23T19:40:52+08:00',
order: '53',

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
'use client'
import { GalleryShowcase } from '~/components/landing'
import {
CreateSpaceCTA,
NocturneHero,
@@ -11,6 +12,7 @@ export default function Home() {
<main className="relative z-10 mx-auto flex w-full max-w-6xl flex-col gap-20 px-4 py-16 sm:px-6 lg:px-0">
<NocturneHero />
<CreateSpaceCTA />
<GalleryShowcase />
</main>
</div>
)

View File

@@ -0,0 +1,227 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useLocale, useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import { API_URL } from '~/constants/env'
interface FeaturedGalleryAuthor {
name: string
avatar: string | null
}
interface FeaturedGallery {
id: string
name: string
slug: string
domain: string | null
description: string | null
author: FeaturedGalleryAuthor | null
createdAt: string
}
interface FeaturedGalleriesResponse {
galleries: FeaturedGallery[]
}
const API_BASE_URL = API_URL.replace(/\/$/, '')
const FEATURED_GALLERIES_ENDPOINT = `${API_BASE_URL || ''}/featured-galleries`
async function fetchFeaturedGalleries(): Promise<FeaturedGalleriesResponse> {
const response = await fetch(FEATURED_GALLERIES_ENDPOINT, {
method: 'GET',
headers: {
'content-type': 'application/json',
},
})
if (!response.ok) {
throw new Error('Failed to fetch featured galleries')
}
return response.json()
}
export const GalleryShowcase = () => {
const t = useTranslations('GalleryShowcase')
const locale = useLocale()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const { data, isLoading, error } = useQuery<FeaturedGalleriesResponse>({
queryKey: ['featured-galleries'],
queryFn: fetchFeaturedGalleries,
enabled: mounted,
staleTime: 5 * 60 * 1000, // 5 minutes
})
const galleries = data?.galleries ?? []
const getBaseDomain = () => {
if (typeof window === 'undefined') return 'afilmory.art'
const { hostname } = window.location
// Extract base domain from current hostname, or use default
if (
hostname.includes('.') &&
!hostname.includes('localhost') &&
!hostname.includes('127.0.0.1')
) {
return hostname.split('.').slice(-2).join('.')
}
return 'afilmory.art'
}
const buildGalleryUrl = (gallery: FeaturedGallery) => {
if (typeof window === 'undefined') return '#'
const { protocol } = window.location
// Prefer custom domain, fallback to slug subdomain
if (gallery.domain) {
return `${protocol}//${gallery.domain}`
}
const baseDomain = getBaseDomain()
return `${protocol}//${gallery.slug}.${baseDomain}`
}
const getDisplayUrl = (gallery: FeaturedGallery) => {
// Prefer custom domain, fallback to slug subdomain
if (gallery.domain) {
return gallery.domain
}
return `${gallery.slug}.${getBaseDomain()}`
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const localeMap: Record<string, string> = {
'zh-CN': 'zh-CN',
'zh-TW': 'zh-TW',
'zh-HK': 'zh-HK',
en: 'en-US',
jp: 'ja-JP',
ko: 'ko-KR',
}
const dateLocale = localeMap[locale] || 'en-US'
return date.toLocaleDateString(dateLocale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
if (!mounted) {
return null
}
return (
<section id="gallery-showcase" className="space-y-10">
<div className="text-center">
<p className="text-xs tracking-[0.6em] text-white/40 uppercase">
{t('eyebrow')}
</p>
<h2 className="mt-4 font-serif text-3xl text-white">{t('title')}</h2>
<p className="mt-3 text-base text-white/70">{t('description')}</p>
</div>
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="h-32 animate-pulse rounded-3xl border border-white/10 bg-white/5"
/>
))}
</div>
)}
{error && (
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 text-center text-white/60">
{t('error')}
</div>
)}
{!isLoading && !error && galleries.length === 0 && (
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 text-center text-white/60">
{t('empty')}
</div>
)}
{!isLoading && !error && galleries.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{galleries.map((gallery) => (
<a
key={gallery.id}
href={buildGalleryUrl(gallery)}
target="_blank"
rel="noopener noreferrer"
className="group relative overflow-hidden rounded-3xl border border-white/10 bg-linear-to-br from-white/8 to-transparent p-6 transition hover:border-white/30 hover:bg-white/10"
>
{/* Author Avatar & Info */}
{gallery.author && (
<div className="mb-4 flex items-center gap-3">
<div className="relative size-10 shrink-0 overflow-hidden rounded-full border border-white/10 bg-white/5">
{gallery.author.avatar ? (
<img
src={gallery.author.avatar}
alt={gallery.author.name}
className="h-full w-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
) : null}
{(!gallery.author.avatar ||
gallery.author.avatar === '') && (
<div className="bg-accent-20 text-accent flex h-full w-full items-center justify-center">
<span className="text-sm font-medium">
{gallery.author.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{gallery.author.name}
</p>
<p className="truncate text-xs text-white/50">
{getDisplayUrl(gallery)}
</p>
</div>
</div>
)}
{/* Site Name */}
<h3 className="group-hover:text-accent mb-2 font-serif text-xl text-white transition">
{gallery.name}
</h3>
{/* Description */}
{gallery.description && (
<p className="mb-4 line-clamp-2 text-sm leading-relaxed text-white/70">
{gallery.description}
</p>
)}
{/* Divider */}
<div className="mb-4 h-px w-full bg-linear-to-r from-transparent via-white/30 to-transparent opacity-50" />
{/* Footer */}
<div className="flex items-center justify-between">
<div className="text-xs text-white/40">
{formatDate(gallery.createdAt)}
</div>
<div className="text-white/30 transition group-hover:text-white/60">
<i className="i-lucide-external-link size-4" />
</div>
</div>
</a>
))}
</div>
)}
</section>
)
}

View File

@@ -4,6 +4,7 @@
*/
export { CreateSpaceModal } from './CreateSpaceModal'
export { GalleryShowcase } from './GalleryShowcase'
export { NocturneBackground } from './NocturneBackground'
export { NocturneButton } from './NocturneButton'
export * from './NocturneSections'

View File

@@ -0,0 +1,12 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})

View File

@@ -177,5 +177,12 @@
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"copyright": "© 2025 Afilmory.art"
},
"GalleryShowcase": {
"eyebrow": "Registered Galleries",
"title": "Explore registered visual spaces",
"description": "Discover amazing photography archives created by other photographers and curators, experience different visual storytelling styles.",
"error": "Failed to load gallery list, please try again later.",
"empty": "No registered galleries yet."
}
}

View File

@@ -177,5 +177,12 @@
"privacy": "隐私政策",
"terms": "服务条款",
"copyright": "© 2025 Afilmory.art"
},
"GalleryShowcase": {
"eyebrow": "已注册画展",
"title": "探索已注册的影像空间",
"description": "发现其他摄影师和策展人创建的精彩影像档案馆,感受不同的视觉叙事风格。",
"error": "加载画展列表时出错,请稍后重试。",
"empty": "暂无已注册的画展。"
}
}

View File

@@ -1,22 +1,20 @@
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { LazyMotion } from 'motion/react'
import { ThemeProvider } from 'next-themes'
import type { JSX, PropsWithChildren } from 'react'
import { ProviderComposer } from '../../components/common/ProviderComposer'
import { queryClient } from '../../lib/query-client'
const loadFeatures = () =>
import('./framer-lazy-feature').then((res) => res.default)
const contexts: JSX.Element[] = [
<ThemeProvider key="themeProvider" />,
<QueryClientProvider key="queryClient" client={queryClient} />,
<LazyMotion features={loadFeatures} strict key="framer" />,
]
export function Providers({ children }: PropsWithChildren) {
return (
<>
<ProviderComposer contexts={contexts}>{children}</ProviderComposer>
</>
)
return <ProviderComposer contexts={contexts}>{children}</ProviderComposer>
}