mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
227
apps/landing/src/components/landing/GalleryShowcase.tsx
Normal file
227
apps/landing/src/components/landing/GalleryShowcase.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
export { CreateSpaceModal } from './CreateSpaceModal'
|
||||
export { GalleryShowcase } from './GalleryShowcase'
|
||||
export { NocturneBackground } from './NocturneBackground'
|
||||
export { NocturneButton } from './NocturneButton'
|
||||
export * from './NocturneSections'
|
||||
|
||||
12
apps/landing/src/lib/query-client.ts
Normal file
12
apps/landing/src/lib/query-client.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,5 +177,12 @@
|
||||
"privacy": "隐私政策",
|
||||
"terms": "服务条款",
|
||||
"copyright": "© 2025 Afilmory.art"
|
||||
},
|
||||
"GalleryShowcase": {
|
||||
"eyebrow": "已注册画展",
|
||||
"title": "探索已注册的影像空间",
|
||||
"description": "发现其他摄影师和策展人创建的精彩影像档案馆,感受不同的视觉叙事风格。",
|
||||
"error": "加载画展列表时出错,请稍后重试。",
|
||||
"empty": "暂无已注册的画展。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user