feat: add media assets library
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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";
|
||||
`)
|
||||
}
|
||||
@@ -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'
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user