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>
)
}
@@ -14,6 +14,8 @@ const CACHE_PATHS = [
'/zh/where-to-buy',
'/en/downloads',
'/zh/downloads',
'/en/media-assets',
'/zh/media-assets',
'/en/news',
'/zh/news',
'/en/products',
@@ -31,6 +33,10 @@ const CACHE_PATHS = [
const CACHE_ROUTE_PATTERNS = [
'/en/downloads/[slug]',
'/zh/downloads/[slug]',
'/en/media-assets/[category]',
'/zh/media-assets/[category]',
'/en/media-assets/[category]/[subcategory]',
'/zh/media-assets/[category]/[subcategory]',
'/en/news/[slug]',
'/zh/news/[slug]',
'/en/products/[slug]',
@@ -19,6 +19,11 @@ const QUICK_LINKS = [
href: '/admin/collections/downloads',
title: 'Downloads',
},
{
description: 'Maintain press-facing media asset categories, thumbnails, package links, and downloadable files.',
href: '/admin/collections/mediaAssetCategories',
title: 'Media assets',
},
{
description: 'Keep support landing copy, FAQ content, contact instructions, and warranty policy aligned.',
href: '/admin/globals/supportPage',
@@ -55,6 +60,7 @@ async function getOverviewCounts() {
downloads,
productVideos,
productReviews,
mediaAssetCategories,
news,
faqs,
dealers,
@@ -66,6 +72,7 @@ async function getOverviewCounts() {
payload.count({ collection: 'downloads' }),
payload.count({ collection: 'productVideos' }),
payload.count({ collection: 'productReviews' }),
payload.count({ collection: 'mediaAssetCategories' }),
payload.count({ collection: 'news' }),
payload.count({ collection: 'faqs' }),
payload.count({ collection: 'dealers' }),
@@ -79,6 +86,7 @@ async function getOverviewCounts() {
{ eyebrow: 'Catalog', href: '/admin/collections/downloads', title: 'Downloads', value: downloads.totalDocs },
{ eyebrow: 'Social Proof', href: '/admin/collections/productVideos', title: 'Product Videos', value: productVideos.totalDocs },
{ eyebrow: 'Social Proof', href: '/admin/collections/productReviews', title: 'Product Reviews', value: productReviews.totalDocs },
{ eyebrow: 'Assets', href: '/admin/collections/mediaAssetCategories', title: 'Media Assets', value: mediaAssetCategories.totalDocs },
{ eyebrow: 'Publishing', href: '/admin/collections/news', title: 'News', value: news.totalDocs },
{ eyebrow: 'Support', href: '/admin/collections/faqs', title: 'FAQs', value: faqs.totalDocs },
{ eyebrow: 'Sales', href: '/admin/collections/dealers', title: 'Dealers', value: dealers.totalDocs },
+14 -1
View File
@@ -1,5 +1,6 @@
import type { MetadataRoute } from 'next'
import { getPayloadClient } from '@/lib/payload'
import { getMediaAssetSitemapPaths } from '@/lib/media-assets'
import { buildAbsoluteUrl } from '@/lib/seo'
import type { Locale } from '@/i18n/config'
import type { GlobalSetting } from '@/payload-types'
@@ -27,6 +28,7 @@ const STATIC_PATHS: Array<{
{ key: 'includeSupport', path: '/support/contact', changeFrequency: 'monthly', priority: 0.65 },
{ key: 'includeTutorials', path: '/support/tutorial', changeFrequency: 'monthly', priority: 0.65 },
{ key: 'includeDownloads', path: '/downloads', changeFrequency: 'weekly', priority: 0.75 },
{ key: 'includeDownloads', path: '/media-assets', changeFrequency: 'weekly', priority: 0.65 },
{ key: 'includeAbout', path: '/about', changeFrequency: 'monthly', priority: 0.65 },
{ key: 'includeContact', path: '/contact', changeFrequency: 'monthly', priority: 0.65 },
{ key: 'includeWhereToBuy', path: '/where-to-buy', changeFrequency: 'monthly', priority: 0.7 },
@@ -46,7 +48,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
})) as GlobalSetting
const sitemapSettings = globalSettings.sitemap
const [productsEn, productsZh, newsEn, newsZh] = await Promise.all([
const [productsEn, productsZh, newsEn, newsZh, mediaAssetsEn, mediaAssetsZh] = await Promise.all([
isEnabled(sitemapSettings, 'includeProductDetails')
? payload.find({
collection: 'products',
@@ -93,6 +95,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
where: { docLocale: { equals: 'zh' } },
})
: Promise.resolve({ docs: [] }),
isEnabled(sitemapSettings, 'includeDownloads') ? getMediaAssetSitemapPaths('en') : Promise.resolve([]),
isEnabled(sitemapSettings, 'includeDownloads') ? getMediaAssetSitemapPaths('zh') : Promise.resolve([]),
])
const entries: MetadataRoute.Sitemap = []
@@ -154,5 +158,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}
}
for (const entry of [...mediaAssetsEn, ...mediaAssetsZh]) {
entries.push({
changeFrequency: 'monthly',
lastModified: entry.updatedAt || new Date(),
priority: 0.6,
url: buildAbsoluteUrl(entry.path),
})
}
return entries
}
+3
View File
@@ -14,6 +14,9 @@ export const pathnames = {
'/support/warranty': '/support/warranty',
'/support/contact': '/support/contact',
'/downloads': '/downloads',
'/media-assets': '/media-assets',
'/media-assets/[category]': '/media-assets/[category]',
'/media-assets/[category]/[subcategory]': '/media-assets/[category]/[subcategory]',
'/about': '/about',
'/where-to-buy': '/where-to-buy',
'/news': '/news',
+35 -1
View File
@@ -188,6 +188,40 @@ function ensureReviewsFooterLink(columns: FooterColumn[], locale: Locale): Foote
)
}
function ensureMediaAssetsFooterLink(columns: FooterColumn[], locale: Locale): FooterColumn[] {
const href = `/${locale}/media-assets`
const hasMediaAssets = columns.some((column) =>
column.items.some((item) => item.href === href || /\/media-assets(?:[#/?].*)?$/i.test(item.href)),
)
if (hasMediaAssets) return columns
const label = locale === 'zh' ? '媒体资料' : 'Media Assets'
const preferredTitles = locale === 'zh' ? ['关于', '支持'] : ['About', 'Support']
const targetIndex = preferredTitles
.map((title) => columns.findIndex((column) => column.title === title))
.find((index) => index !== -1)
if (targetIndex === undefined || targetIndex === -1) {
return [
...columns,
{
title: locale === 'zh' ? '关于' : 'About',
items: [{ href, label }],
},
]
}
return columns.map((column, index) =>
index === targetIndex
? {
...column,
items: [...column.items, { href, label }],
}
: column,
)
}
export const getSiteChromeSettings = cacheCmsQuery<[Locale], SiteChromeSettings>(
['site-chrome-settings'],
async (locale) => {
@@ -219,7 +253,7 @@ export const getSiteChromeSettings = cacheCmsQuery<[Locale], SiteChromeSettings>
appLink: ensureInternalHref(settings.navigation?.app?.href, locale, fallback.appLink),
appLabel: cmsValue(settings.navigation?.app?.label, fallback.appLabel),
appOpenInNewTab: settings.navigation?.app?.openInNewTab ?? true,
footerColumns: ensureReviewsFooterLink(footerColumns, locale),
footerColumns: ensureMediaAssetsFooterLink(ensureReviewsFooterLink(footerColumns, locale), locale),
footerCopyright: cmsValue(settings.footer?.copyright, fallback.footerCopyright),
legalLinks:
settings.footer?.legalLinks?.map((item) => ({
+270
View File
@@ -0,0 +1,270 @@
import type { Locale } from '@/i18n/config'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { getPayloadClient } from '@/lib/payload'
import { normalizeUploadUrl } from '@/lib/upload-url'
import type { Media, MediaAssetCategory } from '@/payload-types'
export type MediaAssetCategoryCard = {
assetCount: number
childCount: number
description: string | null
href: string
id: number
imageUrl: string | null
name: string
slug: string
}
export type MediaAssetFileItem = {
description: string | null
externalUrl: string
fileFormat: string
fileSize: string | null
id: string
thumbnailUrl: string | null
title: string
type: 'image' | 'pdf' | 'video'
}
export type MediaAssetDetailPageData = {
archiveDownloadUrl: string | null
archiveFileSize: string | null
archiveFormat: string | null
assets: MediaAssetFileItem[]
category: MediaAssetCategoryCard
subcategory: MediaAssetCategoryCard
}
type CategoryWithCounts = MediaAssetCategory & {
assetCount: number
childCount: number
}
function trimValue(value: string | null | undefined) {
const trimmed = value?.trim()
return trimmed || null
}
function decodeSlug(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function slugMatches(storedSlug: string | null | undefined, routeSlug: string) {
if (!storedSlug) return false
const decoded = decodeSlug(routeSlug)
return storedSlug === routeSlug || storedSlug === decoded
}
function getParentId(category: MediaAssetCategory) {
const parent = category.parent
if (!parent) return null
return typeof parent === 'number' ? parent : parent.id
}
function getMediaUrl(media: Media | number | null | undefined, size: 'card' | 'hero' | 'thumb' = 'card') {
if (!media || typeof media === 'number') return null
return normalizeUploadUrl(
media.sizes?.[size]?.url ??
media.sizes?.card?.url ??
media.sizes?.thumb?.url ??
media.url ??
media.thumbnailURL ??
null,
)
}
function getFormatFromUrl(url: string | null | undefined) {
if (!url) return null
try {
const parsed = new URL(url)
const extension = parsed.pathname.split('.').pop()
return extension && extension.length <= 8 ? extension.toUpperCase() : null
} catch {
const extension = url.split('?')[0]?.split('.').pop()
return extension && extension.length <= 8 ? extension.toUpperCase() : null
}
}
function getFallbackFormat(type: MediaAssetFileItem['type']) {
if (type === 'pdf') return 'PDF'
if (type === 'video') return 'VIDEO'
return 'IMAGE'
}
function getCategoryHref(category: MediaAssetCategory, locale: Locale, parent?: MediaAssetCategory | null) {
const slug = encodeURIComponent(category.slug)
if (!parent) return `/${locale}/media-assets/${slug}`
return `/${locale}/media-assets/${encodeURIComponent(parent.slug)}/${slug}`
}
function sortCategories(left: MediaAssetCategory, right: MediaAssetCategory) {
const leftOrder = left.order ?? 0
const rightOrder = right.order ?? 0
if (leftOrder !== rightOrder) return leftOrder - rightOrder
return left.name.localeCompare(right.name)
}
function sortAssets(
left: NonNullable<MediaAssetCategory['assets']>[number],
right: NonNullable<MediaAssetCategory['assets']>[number],
) {
return left.title.localeCompare(right.title)
}
function buildCounts(categories: MediaAssetCategory[]) {
const childCount = new Map<number, number>()
for (const category of categories) {
const parentId = getParentId(category)
if (!parentId) continue
childCount.set(parentId, (childCount.get(parentId) ?? 0) + 1)
}
return categories.map((category): CategoryWithCounts => ({
...category,
assetCount: category.assets?.filter((asset) => trimValue(asset.title) && trimValue(asset.externalUrl)).length ?? 0,
childCount: childCount.get(category.id) ?? 0,
}))
}
function mapCategoryCard(
category: CategoryWithCounts,
locale: Locale,
parent?: MediaAssetCategory | null,
): MediaAssetCategoryCard {
return {
assetCount: category.assetCount,
childCount: category.childCount,
description: trimValue(category.description),
href: getCategoryHref(category, locale, parent),
id: category.id,
imageUrl: getMediaUrl(category.image, 'card'),
name: category.name,
slug: category.slug,
}
}
function mapAssets(category: MediaAssetCategory): MediaAssetFileItem[] {
return [...(category.assets ?? [])]
.filter((asset) => trimValue(asset.title) && trimValue(asset.externalUrl))
.sort(sortAssets)
.map((asset, index) => {
const externalUrl = trimValue(asset.externalUrl) || ''
const type = asset.type || 'image'
const fileFormat = trimValue(asset.fileFormat) || getFormatFromUrl(externalUrl) || getFallbackFormat(type)
return {
description: trimValue(asset.description),
externalUrl,
fileFormat,
fileSize: trimValue(asset.fileSize),
id: asset.id || `${category.id}-${index}`,
thumbnailUrl: getMediaUrl(asset.thumbnail, 'card'),
title: asset.title,
type,
}
})
}
const getAllMediaAssetCategories = cacheCmsQuery<[Locale], MediaAssetCategory[]>(
['media-asset-categories'],
async (locale) => {
const payload = await getPayloadClient()
const result = await payload.find({
collection: 'mediaAssetCategories',
depth: 2,
limit: 300,
locale,
sort: 'order',
where: {
docLocale: {
equals: locale,
},
},
})
return result.docs as MediaAssetCategory[]
},
)
export async function getMediaAssetLanding(locale: Locale) {
const categories = buildCounts(await getAllMediaAssetCategories(locale))
return categories
.filter((category) => !getParentId(category))
.sort(sortCategories)
.map((category) => mapCategoryCard(category, locale))
}
export async function getMediaAssetCategoryPage(locale: Locale, categorySlug: string) {
const categories = buildCounts(await getAllMediaAssetCategories(locale))
const category = categories.find((item) => !getParentId(item) && slugMatches(item.slug, categorySlug))
if (!category) return null
const children = categories
.filter((item) => getParentId(item) === category.id)
.sort(sortCategories)
.map((item) => mapCategoryCard(item, locale, category))
return {
category: mapCategoryCard(category, locale),
children,
}
}
export async function getMediaAssetDetailPage(
locale: Locale,
categorySlug: string,
subcategorySlug: string,
): Promise<MediaAssetDetailPageData | null> {
const categories = buildCounts(await getAllMediaAssetCategories(locale))
const category = categories.find((item) => !getParentId(item) && slugMatches(item.slug, categorySlug))
if (!category) return null
const subcategory = categories.find(
(item) => getParentId(item) === category.id && slugMatches(item.slug, subcategorySlug),
)
if (!subcategory) return null
return {
archiveDownloadUrl: trimValue(subcategory.archiveDownloadUrl),
archiveFileSize: trimValue(subcategory.archiveFileSize),
archiveFormat: trimValue(subcategory.archiveFormat),
assets: mapAssets(subcategory),
category: mapCategoryCard(category, locale),
subcategory: mapCategoryCard(subcategory, locale, category),
}
}
export async function getMediaAssetSitemapPaths(locale: Locale) {
const categories = buildCounts(await getAllMediaAssetCategories(locale))
const topLevel = categories.filter((category) => !getParentId(category)).sort(sortCategories)
const paths: Array<{ updatedAt: string; path: string }> = []
for (const category of topLevel) {
paths.push({
path: `/${locale}/media-assets/${encodeURIComponent(category.slug)}`,
updatedAt: category.updatedAt,
})
const children = categories.filter((item) => getParentId(item) === category.id).sort(sortCategories)
for (const child of children) {
paths.push({
path: `/${locale}/media-assets/${encodeURIComponent(category.slug)}/${encodeURIComponent(child.slug)}`,
updatedAt: child.updatedAt,
})
}
}
return paths
}
@@ -0,0 +1,127 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "enum_media_asset_categories_doc_locale" AS ENUM ('en', 'zh');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE "enum_media_asset_categories_assets_type" AS ENUM ('image', 'pdf', 'video');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE TABLE IF NOT EXISTS "media_asset_categories" (
"id" serial PRIMARY KEY NOT NULL,
"doc_locale" enum_media_asset_categories_doc_locale DEFAULT 'en' NOT NULL,
"parent_id" integer,
"name" varchar NOT NULL,
"slug" varchar NOT NULL,
"image_id" integer,
"description" varchar,
"order" numeric DEFAULT 0,
"archive_download_url" varchar,
"archive_format" varchar,
"archive_file_size" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS "media_asset_categories_assets" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"type" enum_media_asset_categories_assets_type DEFAULT 'image' NOT NULL,
"title" varchar NOT NULL,
"thumbnail_id" integer NOT NULL,
"external_url" varchar NOT NULL,
"file_format" varchar,
"file_size" varchar,
"description" varchar
);
CREATE INDEX IF NOT EXISTS "media_asset_categories_updated_at_idx"
ON "media_asset_categories" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "media_asset_categories_created_at_idx"
ON "media_asset_categories" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "media_asset_categories_parent_idx"
ON "media_asset_categories" USING btree ("parent_id");
CREATE INDEX IF NOT EXISTS "media_asset_categories_image_idx"
ON "media_asset_categories" USING btree ("image_id");
CREATE INDEX IF NOT EXISTS "media_asset_categories_locale_parent_order_idx"
ON "media_asset_categories" USING btree ("doc_locale", "parent_id", "order");
CREATE INDEX IF NOT EXISTS "media_asset_categories_locale_slug_idx"
ON "media_asset_categories" USING btree ("doc_locale", "slug");
CREATE INDEX IF NOT EXISTS "media_asset_categories_assets_order_idx"
ON "media_asset_categories_assets" USING btree ("_order");
CREATE INDEX IF NOT EXISTS "media_asset_categories_assets_parent_id_idx"
ON "media_asset_categories_assets" USING btree ("_parent_id");
CREATE INDEX IF NOT EXISTS "media_asset_categories_assets_thumbnail_idx"
ON "media_asset_categories_assets" USING btree ("thumbnail_id");
DO $$ BEGIN
ALTER TABLE "media_asset_categories"
ADD CONSTRAINT "media_asset_categories_parent_id_fk"
FOREIGN KEY ("parent_id") REFERENCES "media_asset_categories"("id") ON DELETE SET NULL ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "media_asset_categories"
ADD CONSTRAINT "media_asset_categories_image_id_media_id_fk"
FOREIGN KEY ("image_id") REFERENCES "media"("id") ON DELETE SET NULL ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "media_asset_categories_assets"
ADD CONSTRAINT "media_asset_categories_assets_parent_id_fk"
FOREIGN KEY ("_parent_id") REFERENCES "media_asset_categories"("id") ON DELETE CASCADE ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "media_asset_categories_assets"
ADD CONSTRAINT "media_asset_categories_assets_thumbnail_id_media_id_fk"
FOREIGN KEY ("thumbnail_id") REFERENCES "media"("id") ON DELETE SET NULL ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "media_asset_categories_id" integer;
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels"
ADD CONSTRAINT "payload_locked_documents_rels_media_asset_categories_fk"
FOREIGN KEY ("media_asset_categories_id") REFERENCES "media_asset_categories"("id") ON DELETE CASCADE ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_media_asset_categories_id_idx"
ON "payload_locked_documents_rels" USING btree ("media_asset_categories_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_media_asset_categories_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_media_asset_categories_id_idx";
ALTER TABLE "payload_locked_documents_rels"
DROP COLUMN IF EXISTS "media_asset_categories_id";
DROP TABLE IF EXISTS "media_asset_categories_assets";
DROP TABLE IF EXISTS "media_asset_categories";
DROP TYPE IF EXISTS "enum_media_asset_categories_assets_type";
DROP TYPE IF EXISTS "enum_media_asset_categories_doc_locale";
`)
}
+6
View File
@@ -41,6 +41,7 @@ import * as migration_20260428_044000_add_homepage_hero_slide_visibility_overlay
import * as migration_20260428_045000_add_faq_page_global from './20260428_045000_add_faq_page_global';
import * as migration_20260428_045500_add_faq_page_support_path from './20260428_045500_add_faq_page_support_path';
import * as migration_20260429_070000_add_public_query_indexes from './20260429_070000_add_public_query_indexes';
import * as migration_20260430_090000_add_media_asset_categories from './20260430_090000_add_media_asset_categories';
export const migrations = [
{
@@ -258,4 +259,9 @@ export const migrations = [
down: migration_20260429_070000_add_public_query_indexes.down,
name: '20260429_070000_add_public_query_indexes'
},
{
up: migration_20260430_090000_add_media_asset_categories.up,
down: migration_20260430_090000_add_media_asset_categories.down,
name: '20260430_090000_add_media_asset_categories'
},
];
+81
View File
@@ -69,6 +69,7 @@ export interface Config {
collections: {
users: User;
media: Media;
mediaAssetCategories: MediaAssetCategory;
files: File;
videos: Video;
products: Product;
@@ -94,6 +95,7 @@ export interface Config {
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
mediaAssetCategories: MediaAssetCategoriesSelect<false> | MediaAssetCategoriesSelect<true>;
files: FilesSelect<false> | FilesSelect<true>;
videos: VideosSelect<false> | VideosSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
@@ -263,6 +265,51 @@ export interface Media {
};
};
}
/**
* Top-level and secondary media asset kit categories. Leave parent empty for the first level; add assets and archive links on secondary categories.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "mediaAssetCategories".
*/
export interface MediaAssetCategory {
id: number;
docLocale?: ('en' | 'zh') | null;
/**
* Leave empty for a top-level category. Select a top-level category for the second level.
*/
parent?: (number | null) | MediaAssetCategory;
name: string;
/**
* Auto-filled from the name when left blank. Keep it stable after publishing.
*/
slug: string;
image?: (number | null) | Media;
description?: string | null;
order?: number | null;
/**
* External URL for the top Download All button on the third-level page.
*/
archiveDownloadUrl?: string | null;
archiveFormat?: string | null;
archiveFileSize?: string | null;
/**
* Only used on second-level categories. Thumbnail is local media; preview/download URL is external.
*/
assets?:
| {
type: 'image' | 'pdf' | 'video';
title: string;
thumbnail: number | Media;
externalUrl: string;
fileFormat?: string | null;
fileSize?: string | null;
description?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* Shared downloadable assets such as firmware packages, manuals, and software installers. File records stay global even when product/download content is edited separately per language.
*
@@ -916,6 +963,10 @@ export interface PayloadLockedDocument {
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'mediaAssetCategories';
value: number | MediaAssetCategory;
} | null)
| ({
relationTo: 'files';
value: number | File;
@@ -1106,6 +1157,36 @@ export interface MediaSelect<T extends boolean = true> {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "mediaAssetCategories_select".
*/
export interface MediaAssetCategoriesSelect<T extends boolean = true> {
docLocale?: T;
parent?: T;
name?: T;
slug?: T;
image?: T;
description?: T;
order?: T;
archiveDownloadUrl?: T;
archiveFormat?: T;
archiveFileSize?: T;
assets?:
| T
| {
type?: T;
title?: T;
thumbnail?: T;
externalUrl?: T;
fileFormat?: T;
fileSize?: T;
description?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "files_select".
+2
View File
@@ -7,6 +7,7 @@ import { zh } from '@payloadcms/translations/languages/zh'
import sharp from 'sharp'
import { eversoloRichTextEditor } from './payload/rich-text-editor'
import { Media } from './payload/collections/Media'
import { MediaAssetCategories } from './payload/collections/MediaAssetCategories'
import { Files } from './payload/collections/Files'
import { Videos } from './payload/collections/Videos'
import { Products } from './payload/collections/Products'
@@ -94,6 +95,7 @@ export default buildConfig({
collections: [
Users,
Media,
MediaAssetCategories,
Files,
Videos,
Products,
@@ -0,0 +1,202 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { compactArrayAdmin } from '@/payload/admin/compact-array-admin'
import { independentDocListFilter } from '@/payload/admin/localized-list-filter'
import { getDocLocaleDefault, resolveDocLocale, syncDocLocaleWithRequestLocale } from '@/payload/doc-locale-hook'
const assetTypeOptions = [
{ label: 'Image / 图片', value: 'image' },
{ label: 'PDF', value: 'pdf' },
{ label: 'Video / 视频', value: 'video' },
] as const
function slugify(value: unknown) {
if (typeof value !== 'string') return ''
return value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
const syncSlugFromName: CollectionBeforeValidateHook = ({ data }) => {
if (!data || typeof data.slug === 'string' && data.slug.trim()) return data
const slug = slugify(data.name)
if (slug) {
return {
...data,
slug,
}
}
return data
}
export const MediaAssetCategories: CollectionConfig = {
slug: 'mediaAssetCategories',
hooks: {
beforeChange: [syncDocLocaleWithRequestLocale],
beforeValidate: [syncDocLocaleWithRequestLocale, syncSlugFromName],
},
admin: {
baseFilter: independentDocListFilter('name'),
defaultColumns: ['name', 'slug', 'parent', 'order', 'updatedAt'],
description:
'Top-level and secondary media asset kit categories. Leave parent empty for the first level; add assets and archive links on secondary categories.',
group: { en: 'Assets', zh: '素材' },
useAsTitle: 'name',
},
labels: {
plural: { en: 'Media Asset Kits', zh: '媒体资料包' },
singular: { en: 'Media Asset Kit', zh: '媒体资料包' },
},
access: {
read: ({ req }) => {
const locale = resolveDocLocale(req)
if (!locale) return true
return { docLocale: { equals: locale } }
},
},
fields: [
{
name: 'docLocale',
label: { en: 'Doc Locale', zh: '文档语言' },
type: 'select',
required: true,
defaultValue: getDocLocaleDefault,
options: [
{ label: 'English / 英文', value: 'en' },
{ label: '简体中文', value: 'zh' },
],
admin: {
condition: () => false,
},
},
{
name: 'parent',
label: { en: 'Parent Category', zh: '上级分类' },
type: 'relationship',
relationTo: 'mediaAssetCategories',
admin: {
description: 'Leave empty for a top-level category. Select a top-level category for the second level.',
},
},
{
name: 'name',
label: { en: 'Name', zh: '名称' },
type: 'text',
required: true,
},
{
name: 'slug',
label: { en: 'Slug', zh: 'URL 标识' },
type: 'text',
required: true,
admin: {
description: 'Auto-filled from the name when left blank. Keep it stable after publishing.',
},
},
{
name: 'image',
label: { en: 'Category Image', zh: '分类图片' },
type: 'upload',
relationTo: 'media',
},
{
name: 'description',
label: { en: 'Description', zh: '描述' },
type: 'textarea',
},
{
name: 'order',
label: { en: 'Order', zh: '排序' },
type: 'number',
defaultValue: 0,
},
{
name: 'archiveDownloadUrl',
label: { en: 'Download All URL', zh: '顶部压缩包下载地址' },
type: 'text',
admin: {
description: 'External URL for the top Download All button on the third-level page.',
},
},
{
name: 'archiveFormat',
label: { en: 'Archive Format', zh: '压缩包格式' },
type: 'text',
admin: {
placeholder: 'ZIP',
},
},
{
name: 'archiveFileSize',
label: { en: 'Archive File Size', zh: '压缩包大小' },
type: 'text',
admin: {
placeholder: '245 MB',
},
},
{
name: 'assets',
label: { en: 'Assets', zh: '资源' },
type: 'array',
admin: compactArrayAdmin({
description: 'Only used on second-level categories. Thumbnail is local media; preview/download URL is external.',
}),
fields: [
{
name: 'type',
label: { en: 'Type', zh: '类型' },
type: 'select',
required: true,
defaultValue: 'image',
options: [...assetTypeOptions],
},
{
name: 'title',
label: { en: 'Title', zh: '标题' },
type: 'text',
required: true,
},
{
name: 'thumbnail',
label: { en: 'Thumbnail', zh: '缩略图' },
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'externalUrl',
label: { en: 'Preview / Download URL', zh: '预览 / 下载外链' },
type: 'text',
required: true,
},
{
name: 'fileFormat',
label: { en: 'File Format', zh: '文件格式' },
type: 'text',
admin: {
placeholder: 'PNG, PDF, MP4',
},
},
{
name: 'fileSize',
label: { en: 'File Size', zh: '文件大小' },
type: 'text',
admin: {
placeholder: '8.4 MB',
},
},
{
name: 'description',
label: { en: 'Description', zh: '描述' },
type: 'textarea',
},
],
},
],
}