160 lines
6.4 KiB
TypeScript
160 lines
6.4 KiB
TypeScript
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
|
|
|
import { AdminShell } from "@/components/admin-shell"
|
|
import { FlashQueryCleanup } from "@/components/media/flash-query-cleanup"
|
|
import { MediaUploadForm } from "@/components/media/media-upload-form"
|
|
import { resolveMediaStorageProvider } from "@/lib/media/storage"
|
|
import { requirePermissionForRoute } from "@/lib/route-guards"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
type SearchParamsInput = Record<string, string | string[] | undefined>
|
|
|
|
function readFirstValue(value: string | string[] | undefined): string | null {
|
|
if (Array.isArray(value)) {
|
|
return value[0] ?? null
|
|
}
|
|
|
|
return value ?? null
|
|
}
|
|
|
|
export default async function MediaManagementPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<SearchParamsInput>
|
|
}) {
|
|
const role = await requirePermissionForRoute({
|
|
nextPath: "/media",
|
|
permission: "media:read",
|
|
scope: "team",
|
|
})
|
|
const [resolvedSearchParams, summary, assets] = await Promise.all([
|
|
searchParams,
|
|
getMediaFoundationSummary(),
|
|
listMediaAssets(20),
|
|
])
|
|
const notice = readFirstValue(resolvedSearchParams.notice)
|
|
const error = readFirstValue(resolvedSearchParams.error)
|
|
const uploadedVia = readFirstValue(resolvedSearchParams.uploadedVia)
|
|
const warning = readFirstValue(resolvedSearchParams.warning)
|
|
const activeStorageProvider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
|
const hasFlashQuery = Boolean(notice || error || warning || uploadedVia)
|
|
|
|
return (
|
|
<AdminShell
|
|
role={role}
|
|
activePath="/media"
|
|
badge="Admin App"
|
|
title="Media"
|
|
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
|
|
>
|
|
<FlashQueryCleanup enabled={hasFlashQuery} />
|
|
|
|
{notice ? (
|
|
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span>{notice}</span>
|
|
{uploadedVia ? (
|
|
<span className="rounded border border-emerald-300 bg-white px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-emerald-700">
|
|
Stored via: {uploadedVia}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
|
{error}
|
|
</section>
|
|
) : null}
|
|
|
|
{warning ? (
|
|
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
|
{warning}
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
<article className="rounded-xl border border-neutral-200 p-4">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
|
|
<p className="mt-2 text-3xl font-semibold">{summary.mediaAssets}</p>
|
|
</article>
|
|
<article className="rounded-xl border border-neutral-200 p-4">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Artworks</p>
|
|
<p className="mt-2 text-3xl font-semibold">{summary.artworks}</p>
|
|
</article>
|
|
<article className="rounded-xl border border-neutral-200 p-4">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Groups</p>
|
|
<p className="mt-2 text-3xl font-semibold">
|
|
{summary.galleries + summary.albums + summary.categories + summary.tags}
|
|
</p>
|
|
<p className="mt-1 text-xs text-neutral-500">
|
|
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
|
|
categories · {summary.tags} tags
|
|
</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-neutral-200 p-6">
|
|
<h2 className="text-xl font-medium">Upload Media Asset</h2>
|
|
<p className="mt-1 text-sm text-neutral-600">
|
|
Upload storage provider: <strong>{activeStorageProvider}</strong>. You can switch via
|
|
`CMS_MEDIA_STORAGE_PROVIDER` (`s3` default, `local` fallback) until the admin settings
|
|
toggle lands.
|
|
</p>
|
|
<MediaUploadForm />
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-neutral-200 p-6">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h2 className="text-xl font-medium">Recent Media Assets</h2>
|
|
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
|
|
MVP1 Upload Pipeline
|
|
</span>
|
|
</div>
|
|
<div className="mt-4 overflow-x-auto">
|
|
<table className="min-w-full text-left text-sm">
|
|
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
|
<tr>
|
|
<th className="py-2 pr-4">Title</th>
|
|
<th className="py-2 pr-4">Type</th>
|
|
<th className="py-2 pr-4">MIME</th>
|
|
<th className="py-2 pr-4">Size</th>
|
|
<th className="py-2 pr-4">Published</th>
|
|
<th className="py-2 pr-4">Updated</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{assets.length === 0 ? (
|
|
<tr>
|
|
<td className="py-3 text-neutral-500" colSpan={6}>
|
|
No media assets yet. Upload your first asset above.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
assets.map((asset) => (
|
|
<tr key={asset.id} className="border-t border-neutral-200">
|
|
<td className="py-3 pr-4">{asset.title}</td>
|
|
<td className="py-3 pr-4">{asset.type}</td>
|
|
<td className="py-3 pr-4 text-neutral-600">{asset.mimeType ?? "-"}</td>
|
|
<td className="py-3 pr-4 text-neutral-600">
|
|
{typeof asset.sizeBytes === "number"
|
|
? `${Math.max(1, Math.round(asset.sizeBytes / 1024))} KB`
|
|
: "-"}
|
|
</td>
|
|
<td className="py-3 pr-4">{asset.isPublished ? "yes" : "no"}</td>
|
|
<td className="py-3 pr-4 text-neutral-600">
|
|
{asset.updatedAt.toLocaleDateString("en-US")}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|