feat: add media assets library

This commit is contained in:
Codex
2026-04-30 11:31:40 +08:00
parent 3da316fed9
commit bf28e37ae5
16 changed files with 1398 additions and 2 deletions
@@ -0,0 +1,121 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { setRequestLocale } from 'next-intl/server'
import { notFound } from 'next/navigation'
import type { Locale } from '@/i18n/config'
import { getMediaAssetDetailPage } from '@/lib/media-assets'
import { buildLocaleAlternates, trimDescription } from '@/lib/seo'
import { MediaAssetGallery } from '../../media-asset-gallery'
import { MediaAssetBreadcrumbs, MediaAssetEmptyState } from '../../media-assets-shared'
export const revalidate = 300
function DownloadIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M10 3v9m0 0 4-4m-4 4L6 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4 15.5h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
export async function generateMetadata({
params,
}: {
params: Promise<{ category: string; locale: Locale; subcategory: string }>
}): Promise<Metadata> {
const { category, locale, subcategory } = await params
const pageData = await getMediaAssetDetailPage(locale, category, subcategory)
if (!pageData) {
return {
title: locale === 'zh' ? '媒体资料' : 'Media Assets',
}
}
return {
alternates: buildLocaleAlternates(`/media-assets/${category}/${subcategory}`, locale),
description: trimDescription(pageData.subcategory.description) ||
(locale === 'zh' ? '下载 Eversolo 媒体资料。' : 'Download Eversolo media assets.'),
title: pageData.subcategory.name,
}
}
export default async function MediaAssetDetailPage({
params,
}: {
params: Promise<{ category: string; locale: Locale; subcategory: string }>
}) {
const { category, locale, subcategory } = await params
setRequestLocale(locale)
const pageData = await getMediaAssetDetailPage(locale, category, subcategory)
if (!pageData) notFound()
const archiveMeta = [pageData.archiveFormat, pageData.archiveFileSize].filter(Boolean).join(' / ')
return (
<div className="mx-auto max-w-page px-6 pb-12 pt-4 md:px-12 md:pb-16 md:pt-5">
<MediaAssetBreadcrumbs
items={[
{ href: `/${locale}/media-assets`, label: locale === 'zh' ? '媒体资料' : 'Media Assets' },
{ href: pageData.category.href, label: pageData.category.name },
{ label: pageData.subcategory.name },
]}
/>
<header className="grid gap-8 bg-[linear-gradient(135deg,#f7f7f3_0%,#ebefee_100%)] px-6 py-8 shadow-[0_18px_60px_rgba(18,24,31,0.045)] ring-1 ring-black/[0.045] md:px-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="max-w-reading">
<div className="text-sm uppercase tracking-[0.18em] text-text-muted">
{locale === 'zh' ? '媒体资料包' : 'Media Asset Kit'}
</div>
<h1 className="mt-2 text-4xl font-medium tracking-tight md:text-6xl">
{pageData.subcategory.name}
</h1>
<p className="mt-4 text-lg leading-8 text-text-secondary">
{pageData.subcategory.description ||
(locale === 'zh'
? '点击缩略图预览原始资源,或通过标题悬停菜单查看格式、大小并下载。'
: 'Open thumbnails to preview the original file, or hover titles to inspect format, size, and download quickly.')}
</p>
</div>
{pageData.archiveDownloadUrl ? (
<div className="flex flex-col items-start gap-3 lg:items-end">
{archiveMeta ? (
<span className="text-xs font-medium uppercase tracking-[0.16em] text-text-muted">
{archiveMeta}
</span>
) : null}
<a
href={pageData.archiveDownloadUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-full bg-black px-5 py-3 text-sm font-medium text-white transition hover:bg-[#2c2925]"
>
<DownloadIcon className="h-4 w-4" />
{locale === 'zh' ? '下载压缩包' : 'Download All'}
</a>
</div>
) : null}
</header>
<section className="mt-8">
{pageData.assets.length > 0 ? (
<MediaAssetGallery items={pageData.assets} locale={locale} />
) : (
<MediaAssetEmptyState>
{locale === 'zh'
? '这个资料包还没有添加三级资源。请在后台为该二级分类添加图片、PDF 或视频条目。'
: 'This kit does not have downloadable assets yet. Add image, PDF, or video entries to this secondary category in the admin.'}
<div className="mt-4">
<Link href={pageData.category.href} className="font-medium text-text-primary underline underline-offset-4">
{locale === 'zh' ? '返回上级分类' : 'Back to category'}
</Link>
</div>
</MediaAssetEmptyState>
)}
</section>
</div>
)
}
@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import { setRequestLocale } from 'next-intl/server'
import { notFound } from 'next/navigation'
import type { Locale } from '@/i18n/config'
import { getMediaAssetCategoryPage } from '@/lib/media-assets'
import { buildLocaleAlternates, trimDescription } from '@/lib/seo'
import {
MediaAssetBreadcrumbs,
MediaAssetCategoryGrid,
MediaAssetEmptyState,
MediaAssetPageHeader,
} from '../media-assets-shared'
export const revalidate = 300
export async function generateMetadata({
params,
}: {
params: Promise<{ category: string; locale: Locale }>
}): Promise<Metadata> {
const { category, locale } = await params
const pageData = await getMediaAssetCategoryPage(locale, category)
if (!pageData) {
return {
title: locale === 'zh' ? '媒体资料' : 'Media Assets',
}
}
return {
alternates: buildLocaleAlternates(`/media-assets/${category}`, locale),
description: trimDescription(pageData.category.description) ||
(locale === 'zh' ? '浏览 Eversolo 媒体资料分类。' : 'Browse Eversolo media asset categories.'),
title: pageData.category.name,
}
}
export default async function MediaAssetCategoryPage({
params,
}: {
params: Promise<{ category: string; locale: Locale }>
}) {
const { category, locale } = await params
setRequestLocale(locale)
const pageData = await getMediaAssetCategoryPage(locale, category)
if (!pageData) notFound()
return (
<div className="mx-auto max-w-page px-6 pb-12 pt-4 md:px-12 md:pb-16 md:pt-5">
<MediaAssetBreadcrumbs
items={[
{ href: `/${locale}/media-assets`, label: locale === 'zh' ? '媒体资料' : 'Media Assets' },
{ label: pageData.category.name },
]}
/>
<MediaAssetPageHeader
eyebrow={locale === 'zh' ? '资料分类' : 'Asset Category'}
title={pageData.category.name}
description={
pageData.category.description ||
(locale === 'zh'
? '选择下方资料包继续查看可下载的图片、PDF 与视频素材。'
: 'Choose a kit below to view downloadable images, PDFs, and video assets.')
}
/>
<section className="mt-8">
{pageData.children.length > 0 ? (
<MediaAssetCategoryGrid categories={pageData.children} locale={locale} />
) : (
<MediaAssetEmptyState>
{locale === 'zh'
? '这个一级分类下还没有二级资料包。请在后台创建一个带上级分类的媒体资料包。'
: 'This category does not have secondary kits yet. Add a media asset kit with this parent category in the admin.'}
</MediaAssetEmptyState>
)}
</section>
</div>
)
}
@@ -0,0 +1,259 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import type { Locale } from '@/i18n/config'
import type { MediaAssetFileItem } from '@/lib/media-assets'
function DownloadGlyph({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M10 3v9m0 0 4-4m-4 4L6 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4 15.5h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
function CloseGlyph({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="m5 5 10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
function getVideoEmbedUrl(url: string) {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.replace(/^www\./, '')
if (hostname === 'youtube.com' || hostname === 'm.youtube.com') {
const id = parsed.searchParams.get('v')
return id ? `https://www.youtube.com/embed/${id}?controls=1&rel=0` : null
}
if (hostname === 'youtu.be') {
const id = parsed.pathname.split('/').filter(Boolean)[0]
return id ? `https://www.youtube.com/embed/${id}?controls=1&rel=0` : null
}
if (hostname === 'vimeo.com') {
const id = parsed.pathname.split('/').filter(Boolean)[0]
return id ? `https://player.vimeo.com/video/${id}` : null
}
if (hostname === 'player.vimeo.com' || parsed.pathname.includes('/embed/')) return url
} catch {
return null
}
return null
}
function PreviewFrame({ item }: { item: MediaAssetFileItem }) {
if (item.type === 'image') {
return (
<img
src={item.externalUrl}
alt={item.title}
className="max-h-[76vh] w-full object-contain"
/>
)
}
if (item.type === 'pdf') {
return (
<iframe
src={item.externalUrl}
title={item.title}
className="h-[76vh] w-full bg-white"
/>
)
}
const embedUrl = getVideoEmbedUrl(item.externalUrl)
if (embedUrl) {
return (
<iframe
src={embedUrl}
title={item.title}
className="aspect-video w-full bg-black"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
)
}
return (
<video
src={item.externalUrl}
className="max-h-[76vh] w-full bg-black"
controls
playsInline
/>
)
}
function typeLabel(type: MediaAssetFileItem['type'], locale: Locale) {
if (type === 'pdf') return 'PDF'
if (type === 'video') return locale === 'zh' ? '视频' : 'Video'
return locale === 'zh' ? '图片' : 'Image'
}
export function MediaAssetGallery({
items,
locale,
}: {
items: MediaAssetFileItem[]
locale: Locale
}) {
const [activeId, setActiveId] = useState<string | null>(null)
const activeItem = useMemo(
() => items.find((item) => item.id === activeId) ?? null,
[activeId, items],
)
useEffect(() => {
if (!activeItem) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setActiveId(null)
}
document.addEventListener('keydown', onKeyDown)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = ''
}
}, [activeItem])
return (
<>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<article
key={item.id}
className="group overflow-visible bg-white shadow-[0_18px_60px_rgba(18,24,31,0.06)] ring-1 ring-black/[0.05]"
>
<button
type="button"
onClick={() => setActiveId(item.id)}
className="relative block aspect-[4/3] w-full overflow-hidden bg-[#edf0ee] text-left"
>
{item.thumbnailUrl ? (
<img
src={item.thumbnailUrl}
alt={item.title}
className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.035]"
/>
) : (
<span className="flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,#e4e8e7_0%,#f8f8f5_100%)] text-sm uppercase tracking-[0.16em] text-text-muted">
{item.fileFormat}
</span>
)}
<span className="absolute right-4 top-4 bg-black/80 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white">
{typeLabel(item.type, locale)}
</span>
</button>
<div className="p-5 md:p-6">
<div className="group/title relative inline-flex max-w-full flex-col">
<div className="pointer-events-none absolute bottom-full left-0 z-10 mb-3 flex translate-y-1 items-center gap-2 bg-black px-3 py-2 text-[11px] font-medium uppercase tracking-[0.14em] text-white opacity-0 shadow-[0_16px_36px_rgba(0,0,0,0.22)] transition duration-200 group-hover/title:pointer-events-auto group-hover/title:translate-y-0 group-hover/title:opacity-100">
<span>{item.fileFormat}</span>
{item.fileSize ? <span className="text-white/65">{item.fileSize}</span> : null}
<a
href={item.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-1 inline-flex h-7 w-7 items-center justify-center rounded-full bg-white text-black transition hover:bg-[#d8cbb6]"
aria-label={locale === 'zh' ? `下载 ${item.title}` : `Download ${item.title}`}
>
<DownloadGlyph className="h-4 w-4" />
</a>
</div>
<h3 className="max-w-full text-xl font-medium leading-tight tracking-tight text-text-primary">
{item.title}
</h3>
</div>
{item.description ? (
<p className="mt-3 line-clamp-2 text-sm leading-6 text-text-secondary">
{item.description}
</p>
) : null}
</div>
</article>
))}
</div>
{activeItem ? (
<div
className="fixed inset-0 z-[120] bg-black/72 px-4 py-4 backdrop-blur-sm md:px-8 md:py-8"
role="dialog"
aria-modal="true"
aria-label={activeItem.title}
onMouseDown={(event) => {
if (event.target === event.currentTarget) setActiveId(null)
}}
>
<div className="mx-auto grid h-full max-w-[1440px] overflow-hidden bg-[#f5f5f1] shadow-[0_30px_120px_rgba(0,0,0,0.35)] lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="flex min-h-0 items-center justify-center overflow-auto bg-[#101112] p-4 md:p-8">
<PreviewFrame item={activeItem} />
</div>
<aside className="relative flex min-h-0 flex-col bg-white p-6 md:p-8">
<button
type="button"
onClick={() => setActiveId(null)}
className="absolute right-5 top-5 inline-flex h-10 w-10 items-center justify-center rounded-full border border-black/10 text-text-primary transition hover:bg-subtle"
aria-label={locale === 'zh' ? '关闭预览' : 'Close preview'}
>
<CloseGlyph className="h-5 w-5" />
</button>
<div className="pr-12">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-text-muted">
{typeLabel(activeItem.type, locale)}
</div>
<h2 className="mt-3 text-3xl font-medium leading-tight tracking-tight text-text-primary">
{activeItem.title}
</h2>
</div>
<dl className="mt-8 grid gap-4 border-y border-black/10 py-6 text-sm">
<div className="flex items-center justify-between gap-4">
<dt className="text-text-muted">{locale === 'zh' ? '格式' : 'Format'}</dt>
<dd className="font-medium text-text-primary">{activeItem.fileFormat}</dd>
</div>
{activeItem.fileSize ? (
<div className="flex items-center justify-between gap-4">
<dt className="text-text-muted">{locale === 'zh' ? '大小' : 'Size'}</dt>
<dd className="font-medium text-text-primary">{activeItem.fileSize}</dd>
</div>
) : null}
</dl>
{activeItem.description ? (
<p className="mt-6 text-sm leading-7 text-text-secondary">{activeItem.description}</p>
) : null}
<div className="mt-auto pt-8">
<a
href={activeItem.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-black px-5 py-3 text-sm font-medium text-white transition hover:bg-[#2c2925]"
>
<DownloadGlyph className="h-4 w-4" />
{locale === 'zh' ? '下载' : 'Download'}
</a>
</div>
</aside>
</div>
</div>
) : null}
</>
)
}
@@ -0,0 +1,116 @@
import Link from 'next/link'
import type { Locale } from '@/i18n/config'
import type { MediaAssetCategoryCard } from '@/lib/media-assets'
export function MediaAssetPageHeader({
description,
eyebrow,
title,
}: {
description: string
eyebrow: string
title: string
}) {
return (
<header className="max-w-reading">
<div className="text-sm uppercase tracking-[0.18em] text-text-muted">{eyebrow}</div>
<h1 className="mt-2 text-4xl font-medium tracking-tight md:text-6xl">{title}</h1>
<p className="mt-4 text-lg leading-8 text-text-secondary">{description}</p>
</header>
)
}
export function MediaAssetBreadcrumbs({
items,
}: {
items: Array<{ href?: string; label: string }>
}) {
return (
<nav aria-label="Breadcrumb" className="mb-6 flex flex-wrap items-center gap-2 text-sm text-text-muted">
{items.map((item, index) => (
<span key={`${item.label}-${index}`} className="inline-flex items-center gap-2">
{index > 0 ? <span aria-hidden="true">/</span> : null}
{item.href ? (
<Link href={item.href} className="transition hover:text-text-primary">
{item.label}
</Link>
) : (
<span className="text-text-primary">{item.label}</span>
)}
</span>
))}
</nav>
)
}
export function MediaAssetCategoryGrid({
categories,
locale,
}: {
categories: MediaAssetCategoryCard[]
locale: Locale
}) {
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{categories.map((category) => {
const countLabel =
category.childCount > 0
? locale === 'zh'
? `${category.childCount} 个分类`
: `${category.childCount} categories`
: locale === 'zh'
? `${category.assetCount} 个资源`
: `${category.assetCount} assets`
return (
<article
key={category.id}
className="group overflow-hidden bg-white shadow-[0_18px_60px_rgba(18,24,31,0.055)] ring-1 ring-black/[0.05]"
>
<Link href={category.href} className="block">
<div className="relative aspect-[16/10] overflow-hidden bg-[#ecefed]">
{category.imageUrl ? (
<img
src={category.imageUrl}
alt={category.name}
className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.035]"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,#e4e8e7_0%,#faf9f5_100%)] px-8 text-center text-sm uppercase tracking-[0.18em] text-text-muted">
{category.name}
</div>
)}
<span className="absolute bottom-4 left-4 bg-white/92 px-3 py-1 text-xs font-medium text-text-secondary shadow-[0_10px_24px_rgba(0,0,0,0.08)]">
{countLabel}
</span>
</div>
<div className="p-6">
<h2 className="text-2xl font-medium tracking-tight text-text-primary transition group-hover:opacity-70">
{category.name}
</h2>
{category.description ? (
<p className="mt-3 line-clamp-2 text-sm leading-7 text-text-secondary">
{category.description}
</p>
) : null}
</div>
</Link>
</article>
)
})}
</div>
)
}
export function MediaAssetEmptyState({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="mt-8 border border-dashed border-black/15 bg-white/70 px-6 py-10 text-sm leading-7 text-text-secondary md:px-8">
{children}
</div>
)
}
@@ -0,0 +1,66 @@
import type { Metadata } from 'next'
import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/config'
import { getMediaAssetLanding } from '@/lib/media-assets'
import { buildLocaleAlternates } from '@/lib/seo'
import {
MediaAssetCategoryGrid,
MediaAssetEmptyState,
MediaAssetPageHeader,
} from './media-assets-shared'
export const revalidate = 300
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale }>
}): Promise<Metadata> {
const { locale } = await params
return {
alternates: buildLocaleAlternates('/media-assets', locale),
description:
locale === 'zh'
? '下载 Eversolo 产品图片、媒体资料、PDF 与视频素材。'
: 'Download Eversolo product images, press materials, PDFs, and video assets.',
title: locale === 'zh' ? '媒体资料' : 'Media Assets',
}
}
export default async function MediaAssetsPage({
params,
}: {
params: Promise<{ locale: Locale }>
}) {
const { locale } = await params
setRequestLocale(locale)
const categories = await getMediaAssetLanding(locale)
return (
<div className="mx-auto max-w-page px-6 pb-12 pt-4 md:px-12 md:pb-16 md:pt-5">
<MediaAssetPageHeader
eyebrow={locale === 'zh' ? '媒体中心' : 'Media Library'}
title={locale === 'zh' ? '媒体资料' : 'Media Assets'}
description={
locale === 'zh'
? '按资料包浏览 Eversolo 官方图片、文档与视频资源,适合媒体报道、渠道页面和品牌素材下载。'
: 'Browse official Eversolo image, document, and video kits for press coverage, channel pages, and brand use.'
}
/>
<section className="mt-8">
{categories.length > 0 ? (
<MediaAssetCategoryGrid categories={categories} locale={locale} />
) : (
<MediaAssetEmptyState>
{locale === 'zh'
? '后台还没有添加媒体资料包分类。添加一级分类后,这里会自动显示入口。'
: 'No media asset categories have been added yet. Top-level categories will appear here automatically.'}
</MediaAssetEmptyState>
)}
</section>
</div>
)
}