feat: add AWS media resource management
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user