feat(media): add in-place media file replacement flow

This commit is contained in:
2026-02-12 23:12:29 +01:00
parent 18b709b4b0
commit d2a645df6f
3 changed files with 107 additions and 4 deletions

View File

@@ -1,10 +1,15 @@
import {
getMediaUploadMaxBytes,
isMimeAllowedForMediaType,
mediaAssetTypeSchema,
} from "@cms/content"
import { deleteMediaAsset, getMediaAssetById, updateMediaAsset } from "@cms/db"
import { Button } from "@cms/ui/button"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { deleteStoredMediaObject } from "@/lib/media/storage"
import { deleteStoredMediaObject, storeUpload } from "@/lib/media/storage"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
@@ -79,7 +84,10 @@ function readTags(formData: FormData): string[] {
.filter((item) => item.length > 0)
}
function redirectWithState(mediaAssetId: string, params: { notice?: string; error?: string }) {
function redirectWithState(
mediaAssetId: string,
params: { notice?: string; error?: string },
): never {
const query = new URLSearchParams()
if (params.notice) {
@@ -189,6 +197,80 @@ export default async function MediaAssetEditorPage({ params, searchParams }: Pag
redirect("/media?notice=Media+asset+deleted")
}
async function replaceMediaFileAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/media",
permission: "media:write",
scope: "team",
})
const fileEntry = formData.get("file")
if (!(fileEntry instanceof File) || fileEntry.size === 0) {
redirectWithState(mediaAssetId, { error: "Select a replacement file first." })
}
const parsedType = mediaAssetTypeSchema.safeParse(mediaAsset.type)
if (!parsedType.success) {
redirectWithState(mediaAssetId, { error: "Media type is invalid for replacement." })
}
const mediaType = parsedType.data
const maxBytes = getMediaUploadMaxBytes(mediaType)
if (fileEntry.size > maxBytes) {
redirectWithState(mediaAssetId, {
error: `Replacement file is too large for ${mediaType}. Max ${Math.floor(maxBytes / 1024 / 1024)} MB.`,
})
}
if (!isMimeAllowedForMediaType(mediaType, fileEntry.type)) {
redirectWithState(mediaAssetId, {
error: `File type ${fileEntry.type || "unknown"} is not allowed for ${mediaType}.`,
})
}
const previousStorageKey = mediaAsset.storageKey
try {
const stored = await storeUpload({
file: fileEntry,
assetId: mediaAssetId,
variant: "original",
fileRole: "original",
})
await updateMediaAsset({
id: mediaAssetId,
storageKey: stored.storageKey,
mimeType: fileEntry.type || null,
sizeBytes: fileEntry.size,
width: null,
height: null,
})
if (previousStorageKey && previousStorageKey !== stored.storageKey) {
try {
await deleteStoredMediaObject(previousStorageKey)
} catch {
redirectWithState(mediaAssetId, {
notice: "Media file replaced, but old file cleanup failed in storage backend.",
})
}
}
const replacementNotice = stored.fallbackReason
? `Media file replaced. ${stored.fallbackReason}`
: `Media file replaced via ${stored.provider}.`
redirectWithState(mediaAssetId, { notice: replacementNotice })
} catch {
redirectWithState(mediaAssetId, { error: "Failed to replace media file." })
}
}
return (
<AdminShell
role={role}
@@ -254,6 +336,25 @@ export default async function MediaAssetEditorPage({ params, searchParams }: Pag
) : null}
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h3 className="text-lg font-medium">Replace File</h3>
<p className="mt-1 text-sm text-neutral-600">
Upload a new source file for this media asset while keeping the same metadata and asset
ID.
</p>
<form action={replaceMediaFileAction} className="mt-4 flex flex-wrap items-center gap-3">
<input
name="file"
type="file"
required
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit" variant="secondary">
Replace media file
</Button>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>