feat: add AWS media resource management

This commit is contained in:
Codex
2026-05-08 23:40:42 +08:00
parent bf28e37ae5
commit 4e226e7eda
51 changed files with 6796 additions and 199 deletions
@@ -19,6 +19,17 @@ function DownloadIcon({ className = '' }: { className?: string }) {
)
}
function getArchiveDownloadFilename(name: string, format: string | null) {
const safeName = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, '-')
.replace(/^-+|-+$/g, '')
const extension = format?.trim().replace(/^\./, '').toLowerCase()
return `${safeName || 'media-assets'}${extension ? `.${extension}` : '.zip'}`
}
export async function generateMetadata({
params,
}: {
@@ -53,6 +64,9 @@ export default async function MediaAssetDetailPage({
if (!pageData) notFound()
const archiveMeta = [pageData.archiveFormat, pageData.archiveFileSize].filter(Boolean).join(' / ')
const archiveHref = pageData.archiveOpensOriginalDownload
? pageData.archiveDownloadUrl
: pageData.archiveProxyDownloadUrl
return (
<div className="mx-auto max-w-page px-6 pb-12 pt-4 md:px-12 md:pb-16 md:pt-5">
@@ -80,7 +94,7 @@ export default async function MediaAssetDetailPage({
</p>
</div>
{pageData.archiveDownloadUrl ? (
{archiveHref ? (
<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">
@@ -88,9 +102,14 @@ export default async function MediaAssetDetailPage({
</span>
) : null}
<a
href={pageData.archiveDownloadUrl}
target="_blank"
rel="noopener noreferrer"
href={archiveHref}
download={
pageData.archiveOpensOriginalDownload
? undefined
: getArchiveDownloadFilename(pageData.subcategory.name, pageData.archiveFormat)
}
target={pageData.archiveOpensOriginalDownload ? '_blank' : undefined}
rel={pageData.archiveOpensOriginalDownload ? 'noopener noreferrer' : undefined}
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" />
@@ -106,8 +125,8 @@ export default async function MediaAssetDetailPage({
) : (
<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.'}
? '这个资料包还没有添加资料条目。请在后台媒体资料包的资料列表中添加图片、PDF 或视频。'
: 'This kit does not have downloadable assets yet. Add image, PDF, or video entries in the Media Asset Kit assets list.'}
<div className="mt-4">
<Link href={pageData.category.href} className="font-medium text-text-primary underline underline-offset-4">
{locale === 'zh' ? '返回上级分类' : 'Back to category'}
@@ -21,6 +21,35 @@ function CloseGlyph({ className = '' }: { className?: string }) {
)
}
function getDownloadFilename(item: MediaAssetFileItem) {
const safeTitle = item.title
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, '-')
.replace(/^-+|-+$/g, '')
const format = item.fileFormat.trim().replace(/^\./, '').toLowerCase()
return `${safeTitle || 'media-asset'}${format ? `.${format}` : ''}`
}
function FilePreview({ item }: { item: MediaAssetFileItem }) {
if (item.thumbnailUrl) {
return (
<img
src={item.thumbnailUrl}
alt={item.title}
className="max-h-[64vh] w-full object-contain"
/>
)
}
return (
<div className="flex aspect-[4/3] w-full max-w-xl items-center justify-center bg-[#edf0ee] text-4xl font-medium tracking-[0.08em] text-text-secondary">
{item.fileFormat}
</div>
)
}
function getVideoEmbedUrl(url: string) {
try {
const parsed = new URL(url)
@@ -55,7 +84,7 @@ function PreviewFrame({ item }: { item: MediaAssetFileItem }) {
<img
src={item.externalUrl}
alt={item.title}
className="max-h-[76vh] w-full object-contain"
className="max-h-[64vh] w-full object-contain"
/>
)
}
@@ -65,11 +94,15 @@ function PreviewFrame({ item }: { item: MediaAssetFileItem }) {
<iframe
src={item.externalUrl}
title={item.title}
className="h-[76vh] w-full bg-white"
className="h-[64vh] w-full bg-white"
/>
)
}
if (item.type === 'ai' || item.type === 'psd' || item.type === 'file') {
return <FilePreview item={item} />
}
const embedUrl = getVideoEmbedUrl(item.externalUrl)
if (embedUrl) {
@@ -87,7 +120,7 @@ function PreviewFrame({ item }: { item: MediaAssetFileItem }) {
return (
<video
src={item.externalUrl}
className="max-h-[76vh] w-full bg-black"
className="max-h-[64vh] w-full bg-black"
controls
playsInline
/>
@@ -96,7 +129,10 @@ function PreviewFrame({ item }: { item: MediaAssetFileItem }) {
function typeLabel(type: MediaAssetFileItem['type'], locale: Locale) {
if (type === 'pdf') return 'PDF'
if (type === 'ai') return 'AI'
if (type === 'psd') return 'PSD'
if (type === 'video') return locale === 'zh' ? '视频' : 'Video'
if (type === 'file') return locale === 'zh' ? '文件' : 'File'
return locale === 'zh' ? '图片' : 'Image'
}
@@ -135,48 +171,52 @@ export function MediaAssetGallery({
{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]"
className="group/card overflow-hidden 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}
<div className="relative aspect-[4/3] w-full overflow-hidden bg-[#edf0ee]">
<button
type="button"
onClick={() => setActiveId(item.id)}
className="block h-full w-full text-left"
>
{item.thumbnailUrl ? (
<img
src={item.thumbnailUrl}
alt={item.title}
className="h-full w-full object-cover transition duration-500 group-hover/card: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>
)}
<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>
</button>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex translate-y-3 items-center justify-between gap-3 bg-black/92 px-4 py-3 text-[11px] font-medium uppercase tracking-[0.14em] text-white opacity-0 shadow-[0_-14px_36px_rgba(0,0,0,0.24)] backdrop-blur-[2px] transition duration-200 delay-100 group-hover/card:pointer-events-auto group-hover/card:translate-y-0 group-hover/card:opacity-100 group-focus-within/card:pointer-events-auto group-focus-within/card:translate-y-0 group-focus-within/card:opacity-100">
<div className="min-w-0">
<span>{item.fileFormat}</span>
{item.fileSize ? <span className="ml-3 text-white/65">{item.fileSize}</span> : null}
</div>
<a
href={item.downloadUrl}
download={item.opensOriginalDownload ? undefined : getDownloadFilename(item)}
target={item.opensOriginalDownload ? '_blank' : undefined}
rel={item.opensOriginalDownload ? 'noopener noreferrer' : undefined}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-black transition hover:bg-[#d8cbb6] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
aria-label={locale === 'zh' ? `下载 ${item.title}` : `Download ${item.title}`}
>
<DownloadGlyph className="h-4 w-4" />
</a>
</div>
</div>
<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>
<h3 className="max-w-full text-xl font-medium leading-tight tracking-tight text-text-primary">
{item.title}
</h3>
{item.description ? (
<p className="mt-3 line-clamp-2 text-sm leading-6 text-text-secondary">
@@ -190,7 +230,7 @@ export function MediaAssetGallery({
{activeItem ? (
<div
className="fixed inset-0 z-[120] bg-black/72 px-4 py-4 backdrop-blur-sm md:px-8 md:py-8"
className="fixed inset-0 z-[120] flex items-center justify-center 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}
@@ -198,7 +238,16 @@ export function MediaAssetGallery({
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="relative grid h-[min(82vh,840px)] max-h-[calc(100vh-2rem)] w-full max-w-[1240px] overflow-hidden bg-[#f5f5f1] shadow-[0_30px_120px_rgba(0,0,0,0.35)] lg:grid-cols-[minmax(0,1fr)_340px]">
<button
type="button"
onClick={() => setActiveId(null)}
className="absolute right-3 top-3 z-30 hidden h-10 w-10 items-center justify-center rounded-full border border-black/10 bg-white text-text-primary shadow-[0_10px_28px_rgba(0,0,0,0.18)] transition hover:bg-subtle lg:inline-flex"
aria-label={locale === 'zh' ? '关闭预览' : 'Close preview'}
>
<CloseGlyph className="h-5 w-5" />
</button>
<div className="flex min-h-0 items-center justify-center overflow-auto bg-[#101112] p-4 md:p-8">
<PreviewFrame item={activeItem} />
</div>
@@ -207,7 +256,7 @@ export function MediaAssetGallery({
<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"
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 lg:hidden"
aria-label={locale === 'zh' ? '关闭预览' : 'Close preview'}
>
<CloseGlyph className="h-5 w-5" />
@@ -241,9 +290,10 @@ export function MediaAssetGallery({
<div className="mt-auto pt-8">
<a
href={activeItem.externalUrl}
target="_blank"
rel="noopener noreferrer"
href={activeItem.downloadUrl}
download={activeItem.opensOriginalDownload ? undefined : getDownloadFilename(activeItem)}
target={activeItem.opensOriginalDownload ? '_blank' : undefined}
rel={activeItem.opensOriginalDownload ? 'noopener noreferrer' : undefined}
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" />