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

View File

@@ -29,6 +29,7 @@ import { AuthModule } from './platform/auth/auth.module'
import { BillingModule } from './platform/billing/billing.module'
import { DashboardModule } from './platform/dashboard/dashboard.module'
import { DataManagementModule } from './platform/data-management/data-management.module'
import { FeaturedGalleriesModule } from './platform/featured-galleries/featured-galleries.module'
import { SuperAdminModule } from './platform/super-admin/super-admin.module'
import { TenantModule } from './platform/tenant/tenant.module'
@@ -62,6 +63,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
BillingModule,
DataManagementModule,
TenantModule,
FeaturedGalleriesModule,
DataSyncModule,
FeedModule,
OgModule,

View File

@@ -1,14 +1,4 @@
import {
authSessions,
authUsers,
photoAssets,
reactions,
settings,
tenantAuthAccounts,
tenantAuthSessions,
tenantAuthUsers,
tenants,
} from '@afilmory/db'
import { authSessions, authUsers, photoAssets, reactions, settings, tenants } from '@afilmory/db'
import { EventEmitterService } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
@@ -76,9 +66,7 @@ export class DataManagementService {
await tx.delete(photoAssets).where(eq(photoAssets.tenantId, tenantId))
await tx.delete(reactions).where(eq(reactions.tenantId, tenantId))
await tx.delete(settings).where(eq(settings.tenantId, tenantId))
await tx.delete(tenantAuthAccounts).where(eq(tenantAuthAccounts.tenantId, tenantId))
await tx.delete(tenantAuthSessions).where(eq(tenantAuthSessions.tenantId, tenantId))
await tx.delete(tenantAuthUsers).where(eq(tenantAuthUsers.tenantId, tenantId))
await tx.delete(authSessions).where(eq(authSessions.tenantId, tenantId))
await tx.update(authUsers).set({ tenantId: null, role: 'user' }).where(eq(authUsers.tenantId, tenantId))
await tx.delete(tenants).where(eq(tenants.id, tenantId))

View File

@@ -0,0 +1,19 @@
import { Controller, Get } from '@afilmory/framework'
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
import { FeaturedGalleriesService } from './featured-galleries.service'
@Controller('featured-galleries')
@SkipTenantGuard()
@BypassResponseTransform()
export class FeaturedGalleriesController {
constructor(private readonly featuredGalleriesService: FeaturedGalleriesService) {}
@AllowPlaceholderTenant()
@Get('/')
async listFeaturedGalleries() {
return await this.featuredGalleriesService.listFeaturedGalleries()
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from 'core/database/database.module'
import { TenantModule } from '../tenant/tenant.module'
import { FeaturedGalleriesController } from './featured-galleries.controller'
import { FeaturedGalleriesService } from './featured-galleries.service'
@Module({
imports: [DatabaseModule, TenantModule],
controllers: [FeaturedGalleriesController],
providers: [FeaturedGalleriesService],
})
export class FeaturedGalleriesModule {}

View File

@@ -0,0 +1,117 @@
import { authUsers, settings, tenantDomains } from '@afilmory/db'
import { DbAccessor } from 'core/database/database.provider'
import { normalizeDate } from 'core/helpers/normalize.helper'
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { TenantService } from '../tenant/tenant.service'
@injectable()
export class FeaturedGalleriesService {
constructor(
private readonly tenantService: TenantService,
private readonly dbAccessor: DbAccessor,
) {}
async listFeaturedGalleries() {
const aggregates = await this.tenantService.listTenants()
// Filter out banned, inactive, and suspended tenants
const validTenants = aggregates
.filter((aggregate) => {
const { tenant } = aggregate
return !tenant.banned && tenant.status === 'active' && tenant.slug !== 'root' && tenant.slug !== 'placeholder'
})
.slice(0, 20) // Limit to 20 most recent
const tenantIds = validTenants.map((aggregate) => aggregate.tenant.id)
if (tenantIds.length === 0) {
return { galleries: [] }
}
const db = this.dbAccessor.get()
// Fetch site settings for all tenants
const siteSettings = await db
.select()
.from(settings)
.where(and(inArray(settings.tenantId, tenantIds), inArray(settings.key, ['site.name', 'site.description'])))
// Fetch primary author (admin) for each tenant
const authors = await db
.select({
tenantId: authUsers.tenantId,
name: authUsers.name,
image: authUsers.image,
})
.from(authUsers)
.where(inArray(authUsers.tenantId, tenantIds))
.orderBy(
sql`case when ${authUsers.role} = 'admin' then 0 when ${authUsers.role} = 'superadmin' then 1 else 2 end`,
asc(authUsers.createdAt),
)
// Fetch verified domains for all tenants
const domains = await db
.select({
tenantId: tenantDomains.tenantId,
domain: tenantDomains.domain,
})
.from(tenantDomains)
.where(and(inArray(tenantDomains.tenantId, tenantIds), eq(tenantDomains.status, 'verified')))
// Build maps for quick lookup
const settingsMap = new Map<string, Map<string, string | null>>()
for (const setting of siteSettings) {
if (!settingsMap.has(setting.tenantId)) {
settingsMap.set(setting.tenantId, new Map())
}
settingsMap.get(setting.tenantId)!.set(setting.key, setting.value)
}
const authorMap = new Map<string, { name: string; avatar: string | null }>()
for (const author of authors) {
if (!authorMap.has(author.tenantId!)) {
authorMap.set(author.tenantId!, {
name: author.name,
avatar: author.image ?? null,
})
}
}
const domainMap = new Map<string, string>()
for (const domain of domains) {
// Use the first verified domain for each tenant
if (!domainMap.has(domain.tenantId)) {
domainMap.set(domain.tenantId, domain.domain)
}
}
// Build response
const featuredGalleries = validTenants.map((aggregate) => {
const { tenant } = aggregate
const tenantSettings = settingsMap.get(tenant.id) ?? new Map()
const author = authorMap.get(tenant.id)
const domain = domainMap.get(tenant.id)
return {
id: tenant.id,
name: tenantSettings.get('site.name') ?? tenant.name,
slug: tenant.slug,
domain: domain ?? null,
description: tenantSettings.get('site.description') ?? null,
author: author
? {
name: author.name,
avatar: author.avatar,
}
: null,
createdAt: normalizeDate(tenant.createdAt) ?? tenant.createdAt,
}
})
return {
galleries: featuredGalleries,
}
}
}

View File

@@ -168,67 +168,6 @@ export const creemSubscriptions = pgTable('creem_subscription', {
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
})
export const tenantAuthUsers = pgTable(
'tenant_auth_user',
{
id: text('id').primaryKey(),
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
email: text('email').notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
role: text('role').default('guest').notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
username: text('username'),
displayUsername: text('display_username'),
banned: boolean('banned').default(false).notNull(),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires_at', { mode: 'string' }),
},
(t) => [unique('uq_tenant_auth_user_tenant_email').on(t.tenantId, t.email)],
)
export const tenantAuthSessions = pgTable('tenant_auth_session', {
id: text('id').primaryKey(),
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { mode: 'string' }).notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => tenantAuthUsers.id, { onDelete: 'cascade' }),
})
export const tenantAuthAccounts = pgTable('tenant_auth_account', {
id: text('id').primaryKey(),
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => tenantAuthUsers.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
})
export const settings = pgTable(
'settings',
{
@@ -402,9 +341,7 @@ export const dbSchema = {
authAccounts,
authVerifications,
creemSubscriptions,
tenantAuthUsers,
tenantAuthSessions,
tenantAuthAccounts,
settings,
systemSettings,
reactions,