Add production-ready media library
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-07 16:20:11 +01:00
parent ea64373853
commit 0f9f9e5f79
13 changed files with 1084 additions and 11 deletions
+200
View File
@@ -0,0 +1,200 @@
import path from "path";
import { MediaKind, MediaSource } from "@prisma/client";
import { createMediaAsset, getMediaAssetById } from "@/lib/media";
import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage";
import type { MediaFieldInput } from "@/lib/media-validation";
function getFileNameFromUrl(url: string) {
const pathname = new URL(url, "https://placeholder.local").pathname;
const fileName = path.basename(pathname);
return fileName && fileName !== "/" ? fileName : "external-file";
}
export async function resolveMediaSelection(input: {
media: MediaFieldInput | undefined;
uploadFile: FormDataEntryValue | null;
folder: string;
fallbackLabel: string;
required: boolean;
}) {
const media = input.media;
if (!media) {
if (input.required) {
throw new Error("Media configuration is missing.");
}
return {
assetId: null,
url: "",
uploadedUrl: null,
};
}
if (media.mode === "library") {
const asset = media.assetId ? await getMediaAssetById(media.assetId) : null;
if (!asset) {
throw new Error("Selected media asset was not found.");
}
return {
assetId: asset.id,
url: asset.url,
createdAssetId: null,
uploadedUrl: null,
};
}
if (media.mode === "external") {
if (!media.url) {
if (input.required) {
throw new Error("Media URL is required.");
}
return {
assetId: null,
url: "",
uploadedUrl: null,
};
}
const asset = await createMediaAsset({
source: MediaSource.EXTERNAL,
kind: media.kind,
url: media.url,
fileName: getFileNameFromUrl(media.url),
label: media.label || input.fallbackLabel,
altText: media.label || input.fallbackLabel,
});
return {
assetId: asset.id,
url: asset.url,
createdAssetId: asset.id,
uploadedUrl: null,
};
}
const uploadFile = input.uploadFile;
if (!(uploadFile instanceof File) || uploadFile.size === 0) {
if (input.required) {
throw new Error("Upload file is required.");
}
return {
assetId: null,
url: "",
createdAssetId: null,
uploadedUrl: null,
};
}
const savedFile = await saveMediaUpload(uploadFile, input.folder);
if (!savedFile) {
throw new Error("Unable to save upload.");
}
const asset = await createMediaAsset({
source: MediaSource.UPLOAD,
kind: media.kind,
url: savedFile.url,
fileName: savedFile.fileName,
label: media.label || input.fallbackLabel,
altText: media.label || input.fallbackLabel,
mimeType: savedFile.mimeType,
size: savedFile.size,
});
return {
assetId: asset.id,
url: asset.url,
createdAssetId: asset.id,
uploadedUrl: asset.url,
};
}
export async function createStandaloneMediaAsset(input: {
kind: MediaKind;
label: string;
uploadFile: FormDataEntryValue | null;
externalUrl: string;
}) {
const trimmedLabel = input.label.trim();
if (!trimmedLabel) {
throw new Error("Media label is required.");
}
if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
const savedFile = await saveMediaUpload(input.uploadFile, input.kind.toLowerCase());
if (!savedFile) {
throw new Error("Unable to save upload.");
}
return createMediaAsset({
source: MediaSource.UPLOAD,
kind: input.kind,
url: savedFile.url,
fileName: savedFile.fileName,
label: trimmedLabel,
altText: trimmedLabel,
mimeType: savedFile.mimeType,
size: savedFile.size,
});
}
const url = input.externalUrl.trim();
if (!url) {
throw new Error("Either an upload file or an external URL is required.");
}
return createMediaAsset({
source: MediaSource.EXTERNAL,
kind: input.kind,
url,
fileName: getFileNameFromUrl(url),
label: trimmedLabel,
altText: trimmedLabel,
});
}
export async function deleteMediaAssetAndFile(input: {
assetId: string;
assetUrl: string;
}) {
await removeManagedMediaFile(input.assetUrl);
}
export function inferMediaKindFromMimeType(mimeType: string | null | undefined): MediaKind {
return mimeType?.startsWith("image/") || mimeType === "image/svg+xml"
? MediaKind.IMAGE
: MediaKind.DOCUMENT;
}
export function inferMediaKindFromFileName(fileName: string): MediaKind {
const extension = path.extname(fileName).toLowerCase();
if ([".jpg", ".jpeg", ".png", ".webp", ".svg"].includes(extension)) {
return MediaKind.IMAGE;
}
return MediaKind.DOCUMENT;
}
export function getKindFromUploadFile(file: File) {
const extension = getExtensionForMimeType(file.type);
if (extension && [".jpg", ".jpeg", ".png", ".webp", ".svg"].includes(extension)) {
return MediaKind.IMAGE;
}
return MediaKind.DOCUMENT;
}
+90
View File
@@ -0,0 +1,90 @@
import { randomUUID } from "crypto";
import { mkdir, rm, writeFile } from "fs/promises";
import path from "path";
export const MEDIA_UPLOAD_ROOT = path.join(process.cwd(), "public", "uploads", "media");
export const MAX_MEDIA_FILE_SIZE = 5 * 1024 * 1024;
const MIME_EXTENSIONS: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"application/pdf": ".pdf",
};
export function sanitizeBaseName(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
export function getExtensionForMimeType(mimeType: string) {
return MIME_EXTENSIONS[mimeType] ?? null;
}
export function isManagedMediaFilePath(filePath: string | null | undefined) {
return typeof filePath === "string" && filePath.startsWith("/uploads/media/");
}
export function resolveMediaUploadPath(filePath: string) {
if (!isManagedMediaFilePath(filePath)) {
throw new Error("Only managed media uploads can be resolved.");
}
const relativePath = filePath.replace("/uploads/media/", "");
const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath);
if (!absolutePath.startsWith(MEDIA_UPLOAD_ROOT)) {
throw new Error("Resolved media upload path escapes the upload root.");
}
return absolutePath;
}
export async function removeManagedMediaFile(filePath: string | null | undefined) {
if (!isManagedMediaFilePath(filePath)) {
return false;
}
const managedFilePath = filePath as string;
await rm(resolveMediaUploadPath(managedFilePath), {
force: true,
});
return true;
}
export async function saveMediaUpload(file: File, folder: string) {
if (!file || file.size === 0) {
return null;
}
const extension = getExtensionForMimeType(file.type);
if (!extension) {
throw new Error("Unsupported file type.");
}
if (file.size > MAX_MEDIA_FILE_SIZE) {
throw new Error("File is too large.");
}
const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
const finalName = `${Date.now()}-${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
const targetDir = path.join(MEDIA_UPLOAD_ROOT, folder);
const targetPath = path.join(targetDir, finalName);
await mkdir(targetDir, { recursive: true });
await writeFile(targetPath, Buffer.from(await file.arrayBuffer()));
return {
url: `/uploads/media/${folder}/${finalName}`,
fileName: finalName,
mimeType: file.type,
size: file.size,
};
}
+42
View File
@@ -0,0 +1,42 @@
import { MediaKind } from "@prisma/client";
import { z } from "zod";
const mediaModeSchema = z.enum(["library", "external", "upload"]);
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
export const mediaFieldInputSchema = z
.object({
mode: mediaModeSchema,
assetId: optionalTrimmedText,
url: optionalTrimmedText,
label: optionalTrimmedText,
kind: z.nativeEnum(MediaKind),
})
.superRefine((value, context) => {
if (value.mode === "library" && !value.assetId) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["assetId"],
message: "Library selection requires a media asset.",
});
}
if (value.mode === "external" && !value.url) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["url"],
message: "External media requires a URL.",
});
}
if (value.url && !/^https?:\/\//.test(value.url) && !value.url.startsWith("/")) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["url"],
message: "Media URL must be an absolute URL or start with /.",
});
}
});
export type MediaFieldInput = z.infer<typeof mediaFieldInputSchema>;
+203
View File
@@ -0,0 +1,203 @@
import type {
MediaAsset,
MediaKind,
MediaSource,
MediaUsage,
MediaUsageType,
} from "@prisma/client";
import { prisma } from "@/lib/prisma";
export type MediaAssetView = Pick<
MediaAsset,
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
> & {
usages: Array<
Pick<MediaUsage, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">
>;
};
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
export type PortfolioMediaBindings = {
coverAssetId: string | null;
sectionAssetIds: Record<string, string>;
assetIds: Record<string, string>;
};
function mapMediaAsset(
asset: MediaAsset & {
usages: MediaUsage[];
},
): MediaAssetView {
return {
id: asset.id,
source: asset.source,
kind: asset.kind,
url: asset.url,
fileName: asset.fileName,
label: asset.label,
altText: asset.altText,
mimeType: asset.mimeType,
size: asset.size,
createdAt: asset.createdAt,
usages: asset.usages.map((usage) => ({
id: usage.id,
usageType: usage.usageType,
entityType: usage.entityType,
entityId: usage.entityId,
fieldKey: usage.fieldKey,
})),
};
}
export async function getAdminMediaAssets() {
const assets = await prisma.mediaAsset.findMany({
include: {
usages: {
orderBy: [{ createdAt: "desc" }],
},
},
orderBy: [{ createdAt: "desc" }],
});
return assets.map(mapMediaAsset);
}
export async function getMediaOptions(filters?: { kind?: MediaKind }) {
const assets = await prisma.mediaAsset.findMany({
where: filters?.kind ? { kind: filters.kind } : undefined,
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
kind: true,
url: true,
label: true,
source: true,
},
});
return assets;
}
export async function getMediaAssetById(id: string) {
const asset = await prisma.mediaAsset.findUnique({
where: { id },
include: {
usages: true,
},
});
return asset ? mapMediaAsset(asset) : null;
}
export async function createMediaAsset(input: {
source: MediaSource;
kind: MediaKind;
url: string;
fileName: string;
label: string;
altText?: string | null;
mimeType?: string | null;
size?: number | null;
}) {
return prisma.mediaAsset.create({
data: {
source: input.source,
kind: input.kind,
url: input.url,
fileName: input.fileName,
label: input.label,
altText: input.altText ?? null,
mimeType: input.mimeType ?? null,
size: input.size ?? null,
},
});
}
export async function replaceEntityMediaUsages(input: {
entityType: string;
entityId: string;
usages: Array<{
assetId: string;
usageType: MediaUsageType;
fieldKey: string;
}>;
}) {
await prisma.$transaction(async (tx) => {
await tx.mediaUsage.deleteMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
});
if (input.usages.length === 0) {
return;
}
await tx.mediaUsage.createMany({
data: input.usages.map((usage) => ({
assetId: usage.assetId,
usageType: usage.usageType,
entityType: input.entityType,
entityId: input.entityId,
fieldKey: usage.fieldKey,
})),
});
});
}
export async function deleteEntityMediaUsages(entityType: string, entityId: string) {
await prisma.mediaUsage.deleteMany({
where: {
entityType,
entityId,
},
});
}
export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
select: {
assetId: true,
usageType: true,
fieldKey: true,
},
});
return usages.reduce<PortfolioMediaBindings>(
(result, usage) => {
if (usage.usageType === "PORTFOLIO_COVER") {
result.coverAssetId = usage.assetId;
}
if (usage.usageType === "PORTFOLIO_SECTION") {
result.sectionAssetIds[usage.fieldKey] = usage.assetId;
}
if (usage.usageType === "PORTFOLIO_ASSET") {
result.assetIds[usage.fieldKey] = usage.assetId;
}
return result;
},
{
coverAssetId: null,
sectionAssetIds: {},
assetIds: {},
},
);
}
export async function countMediaUsageReferences(assetId: string) {
return prisma.mediaUsage.count({
where: {
assetId,
},
});
}
+2 -2
View File
@@ -268,7 +268,7 @@ export async function getAdminPortfolioProjects(filters?: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return projects.map(mapProject);
return projects.map((project) => mapProject(project));
}
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
@@ -292,7 +292,7 @@ export async function getPublishedPortfolioProjects(filters?: { categorySlug?: s
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
});
return projects.map(mapProject);
return projects.map((project) => mapProject(project));
}
export async function getPublishedPortfolioProjectBySlug(slug: string) {