From 0f9f9e5f79a4c8cdaf9eacf618eb3befaed4bedc Mon Sep 17 00:00:00 2001 From: MOH Date: Sat, 7 Mar 2026 16:20:11 +0100 Subject: [PATCH] Add production-ready media library --- Dockerfile | 2 +- app/root/media/actions.ts | 102 +++++++++ app/root/media/page.tsx | 183 ++++++++++++++++ app/root/portfolio/actions.ts | 27 ++- app/uploads/media/[...segments]/route.ts | 45 ++++ components/root/media-field-picker.tsx | 150 +++++++++++++ docker-compose.yml | 3 + lib/media-service.ts | 200 +++++++++++++++++ lib/media-storage.ts | 90 ++++++++ lib/media-validation.ts | 42 ++++ lib/media.ts | 203 ++++++++++++++++++ lib/portfolio.ts | 4 +- .../migration.sql | 44 ++++ 13 files changed, 1084 insertions(+), 11 deletions(-) create mode 100644 app/root/media/actions.ts create mode 100644 app/root/media/page.tsx create mode 100644 app/uploads/media/[...segments]/route.ts create mode 100644 components/root/media-field-picker.tsx create mode 100644 lib/media-service.ts create mode 100644 lib/media-storage.ts create mode 100644 lib/media-validation.ts create mode 100644 lib/media.ts create mode 100644 prisma/migrations/20260307183000_add_media_library/migration.sql diff --git a/Dockerfile b/Dockerfile index 5ba15b8..c9988d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ COPY --from=builder /app/next.config.mjs ./next.config.mjs EXPOSE 3000 -CMD ["/bin/sh", "-c", "npm run db:migrate && npm run start -- --hostname 0.0.0.0 --port 3000"] +CMD ["/bin/sh", "-c", "mkdir -p /app/public/uploads/media && npm run db:migrate && npm run start -- --hostname 0.0.0.0 --port 3000"] diff --git a/app/root/media/actions.ts b/app/root/media/actions.ts new file mode 100644 index 0000000..a30b2a0 --- /dev/null +++ b/app/root/media/actions.ts @@ -0,0 +1,102 @@ +"use server"; + +import { MediaKind } from "@prisma/client"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { isRedirectError } from "next/dist/client/components/redirect"; + +import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media"; +import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service"; +import { isManagedMediaFilePath } from "@/lib/media-storage"; +import { prisma } from "@/lib/prisma"; + +function ensureAdmin() { + if (!isAdminAuthenticated()) { + clearAdminSessionCookie(); + redirect("/root"); + } +} + +function withMessage(pathname: string, type: "success" | "error", message: string) { + const params = new URLSearchParams(); + params.set(type, message); + + return `${pathname}?${params.toString()}`; +} + +function revalidateMediaPages() { + revalidatePath("/root"); + revalidatePath("/root/media"); + revalidatePath("/root/portfolio"); + revalidatePath("/root/portfolio/projects"); +} + +export async function createMediaAssetAction(formData: FormData) { + ensureAdmin(); + + try { + const kindValue = String(formData.get("kind") ?? "IMAGE"); + const kind = kindValue === "DOCUMENT" ? MediaKind.DOCUMENT : MediaKind.IMAGE; + + await createStandaloneMediaAsset({ + kind, + label: String(formData.get("label") ?? ""), + uploadFile: formData.get("file"), + externalUrl: String(formData.get("externalUrl") ?? ""), + }); + + revalidateMediaPages(); + redirect(withMessage("/root/media", "success", "Media asset created.")); + } catch (error) { + if (isRedirectError(error)) { + throw error; + } + + const message = error instanceof Error ? error.message : "Unable to create media asset."; + redirect(withMessage("/root/media", "error", message)); + } +} + +export async function deleteMediaAssetAction(formData: FormData) { + ensureAdmin(); + + const assetId = String(formData.get("assetId") ?? ""); + + try { + const asset = await getMediaAssetById(assetId); + + if (!asset) { + redirect(withMessage("/root/media", "error", "Media asset not found.")); + } + + const usageCount = await countMediaUsageReferences(asset.id); + + if (usageCount > 0) { + redirect(withMessage("/root/media", "error", "Media asset is still in use.")); + } + + await prisma.mediaAsset.delete({ + where: { + id: asset.id, + }, + }); + + if (isManagedMediaFilePath(asset.url)) { + await deleteMediaAssetAndFile({ + assetId: asset.id, + assetUrl: asset.url, + }); + } + + revalidateMediaPages(); + redirect(withMessage("/root/media", "success", "Media asset deleted.")); + } catch (error) { + if (isRedirectError(error)) { + throw error; + } + + const message = error instanceof Error ? error.message : "Unable to delete media asset."; + redirect(withMessage("/root/media", "error", message)); + } +} diff --git a/app/root/media/page.tsx b/app/root/media/page.tsx new file mode 100644 index 0000000..2c318a1 --- /dev/null +++ b/app/root/media/page.tsx @@ -0,0 +1,183 @@ +/* eslint-disable @next/next/no-img-element */ + +import { ExternalLink, ImageIcon, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; +import { AppCard } from "@/components/ui/app-card"; +import { Button } from "@/components/ui/button"; +import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { getAdminMediaAssets } from "@/lib/media"; + +import { createMediaAssetAction, deleteMediaAssetAction } from "./actions"; + +export const dynamic = "force-dynamic"; + +const copy = { + title: "Media Library", + subtitle: "Zentrale Dateien fuer Portfolio und spaetere Inhaltsbereiche.", + overview: "Uebersicht", + maintenance: "Wartungsmodus", + uiKit: "UI Kit", + media: "Media", + portfolio: "Portfolio", + logout: "Ausloggen", + backToSite: "Zur Website", +}; + +type RootMediaPageProps = { + searchParams?: { + success?: string; + error?: string; + }; +}; + +export default async function RootMediaPage({ searchParams }: RootMediaPageProps) { + if (!isAdminAuthenticated()) { + redirect("/root"); + } + + async function logoutAction() { + "use server"; + + clearAdminSessionCookie(); + redirect("/root"); + } + + const mediaAssets = await getAdminMediaAssets(); + + return ( + +
+ {searchParams?.success ? ( +

+ {searchParams.success} +

+ ) : null} + + {searchParams?.error ? ( +

+ {searchParams.error} +

+ ) : null} + + + + New Media Asset + Upload a file or store an external URL for reuse across the site. + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ +
+ {mediaAssets.map((asset) => ( + + + {asset.label} + + {asset.kind} + {asset.source} + {asset.usages.length} usages + + + + {asset.kind === "IMAGE" ? ( +
+ {asset.label} +
+ ) : ( +
+ {asset.fileName} +
+ )} + +
+

{asset.url}

+
+ + + Open + +
+
+ + {asset.usages.length > 0 ? ( +
+ {asset.usages.map((usage) => ( +

+ {usage.usageType} / {usage.entityType} / {usage.fieldKey} +

+ ))} +
+ ) : null} + +
+ + +
+
+
+ ))} +
+ + {mediaAssets.length === 0 ? ( + + + No media assets found yet. + + + ) : null} +
+
+ ); +} diff --git a/app/root/portfolio/actions.ts b/app/root/portfolio/actions.ts index 1893a32..52501c6 100644 --- a/app/root/portfolio/actions.ts +++ b/app/root/portfolio/actions.ts @@ -3,6 +3,7 @@ import { MediaUsageType, Prisma } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { isRedirectError } from "next/dist/client/components/redirect"; import { ZodError } from "zod"; import { routing } from "@/i18n/routing"; @@ -136,6 +137,10 @@ export async function upsertCategoryAction(formData: FormData) { await revalidatePortfolioPages(); redirect(withMessage(redirectPath, "success", "Category saved.")); } catch (error) { + if (isRedirectError(error)) { + throw error; + } + const message = error instanceof ZodError ? parseZodError(error) @@ -172,7 +177,11 @@ export async function deleteCategoryAction(formData: FormData) { await revalidatePortfolioPages(); redirect(withMessage(redirectPath, "success", "Category deleted.")); - } catch { + } catch (error) { + if (isRedirectError(error)) { + throw error; + } + redirect(withMessage(redirectPath, "error", "Unable to delete category.")); } } @@ -515,6 +524,10 @@ export async function saveProjectAction(formData: FormData) { withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Project saved."), ); } catch (error) { + if (isRedirectError(error)) { + throw error; + } + const message = error instanceof ZodError ? parseZodError(error) @@ -564,12 +577,6 @@ export async function deleteProjectAction(formData: FormData) { redirect(withMessage("/root/portfolio/projects", "error", "Project not found.")); } - const projectPaths = collectUniqueManagedPaths([ - project.coverImagePath, - ...project.sections.map((section) => section.imagePath), - ...project.assets.map((asset) => asset.filePath), - ]); - await prisma.portfolioProject.delete({ where: { id, @@ -585,7 +592,11 @@ export async function deleteProjectAction(formData: FormData) { } redirect(withMessage("/root/portfolio/projects", "success", "Project deleted.")); - } catch { + } catch (error) { + if (isRedirectError(error)) { + throw error; + } + redirect(withMessage("/root/portfolio/projects", "error", "Unable to delete project.")); } } diff --git a/app/uploads/media/[...segments]/route.ts b/app/uploads/media/[...segments]/route.ts new file mode 100644 index 0000000..9da8ed6 --- /dev/null +++ b/app/uploads/media/[...segments]/route.ts @@ -0,0 +1,45 @@ +import { readFile } from "fs/promises"; +import { NextResponse } from "next/server"; +import path from "path"; + +import { resolveMediaUploadPath } from "@/lib/media-storage"; + +type MediaFileRouteProps = { + params: { + segments: string[]; + }; +}; + +export const dynamic = "force-dynamic"; + +const CONTENT_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", +}; + +export async function GET(_: Request, { params }: MediaFileRouteProps) { + const relativePath = params.segments.join("/"); + const publicPath = `/uploads/media/${relativePath}`; + + try { + const absolutePath = resolveMediaUploadPath(publicPath); + const fileBuffer = await readFile(absolutePath); + const contentType = CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream"; + + return new NextResponse(fileBuffer, { + status: 200, + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch { + return new NextResponse("Not Found", { + status: 404, + }); + } +} diff --git a/components/root/media-field-picker.tsx b/components/root/media-field-picker.tsx new file mode 100644 index 0000000..220fd82 --- /dev/null +++ b/components/root/media-field-picker.tsx @@ -0,0 +1,150 @@ +"use client"; + +/* eslint-disable @next/next/no-img-element */ + +import type { MediaKind } from "@prisma/client"; + +import type { MediaOption } from "@/lib/media"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export type MediaFieldState = { + mode: "upload" | "external" | "library"; + assetId: string; + url: string; + label: string; + kind: MediaKind; +}; + +type MediaFieldPickerProps = { + title: string; + value: MediaFieldState; + onChange: (nextValue: MediaFieldState) => void; + options: MediaOption[]; + inputName: string; + fileFieldName: string; + accept?: string; +}; + +const modeOptions: Array = ["upload", "external", "library"]; + +export function MediaFieldPicker({ + title, + value, + onChange, + options, + inputName, + fileFieldName, + accept, +}: MediaFieldPickerProps) { + const filteredOptions = options.filter((option) => option.kind === value.kind); + const selectedOption = filteredOptions.find((option) => option.id === value.assetId) ?? null; + const previewUrl = + value.mode === "library" + ? selectedOption?.url ?? "" + : value.mode === "external" + ? value.url + : ""; + + return ( +
+
+ +
+ {modeOptions.map((mode) => ( + + ))} +
+
+ + + +
+ + onChange({ ...value, label: event.target.value })} + placeholder="Homepage Hero" + /> +
+ + {value.mode === "upload" ? ( +
+ + +
+ ) : null} + + {value.mode === "external" ? ( +
+ + onChange({ ...value, url: event.target.value })} + placeholder="https://example.com/image.jpg" + /> +
+ ) : null} + + {value.mode === "library" ? ( +
+ + +
+ ) : null} + + {previewUrl ? ( + value.kind === "IMAGE" ? ( +
+ {value.label +
+ ) : ( +
+ {previewUrl} +
+ ) + ) : null} +
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index dcdd434..d06d11c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: - "traefik.http.routers.sass-mohfarawati.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`)" - "traefik.http.routers.sass-mohfarawati.entrypoints=web" - "traefik.http.services.sass-mohfarawati.loadbalancer.server.port=3000" + volumes: + - media_uploads:/app/public/uploads/media networks: - appnet - proxy @@ -52,6 +54,7 @@ services: volumes: postgres_data: + media_uploads: networks: appnet: diff --git a/lib/media-service.ts b/lib/media-service.ts new file mode 100644 index 0000000..6978dde --- /dev/null +++ b/lib/media-service.ts @@ -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; +} diff --git a/lib/media-storage.ts b/lib/media-storage.ts new file mode 100644 index 0000000..4315ca8 --- /dev/null +++ b/lib/media-storage.ts @@ -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 = { + "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, + }; +} diff --git a/lib/media-validation.ts b/lib/media-validation.ts new file mode 100644 index 0000000..974ba29 --- /dev/null +++ b/lib/media-validation.ts @@ -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; diff --git a/lib/media.ts b/lib/media.ts new file mode 100644 index 0000000..0a22426 --- /dev/null +++ b/lib/media.ts @@ -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 + >; +}; + +export type MediaOption = Pick; + +export type PortfolioMediaBindings = { + coverAssetId: string | null; + sectionAssetIds: Record; + assetIds: Record; +}; + +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 { + const usages = await prisma.mediaUsage.findMany({ + where: { + entityType: "portfolio-project", + entityId: projectId, + }, + select: { + assetId: true, + usageType: true, + fieldKey: true, + }, + }); + + return usages.reduce( + (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, + }, + }); +} diff --git a/lib/portfolio.ts b/lib/portfolio.ts index d579bfd..4f6d85d 100644 --- a/lib/portfolio.ts +++ b/lib/portfolio.ts @@ -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) { diff --git a/prisma/migrations/20260307183000_add_media_library/migration.sql b/prisma/migrations/20260307183000_add_media_library/migration.sql new file mode 100644 index 0000000..ad9139d --- /dev/null +++ b/prisma/migrations/20260307183000_add_media_library/migration.sql @@ -0,0 +1,44 @@ +CREATE TYPE "MediaSource" AS ENUM ('UPLOAD', 'EXTERNAL'); + +CREATE TYPE "MediaKind" AS ENUM ('IMAGE', 'DOCUMENT'); + +CREATE TYPE "MediaUsageType" AS ENUM ('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC'); + +CREATE TABLE "MediaAsset" ( + "id" TEXT NOT NULL, + "source" "MediaSource" NOT NULL, + "kind" "MediaKind" NOT NULL, + "url" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "label" TEXT NOT NULL, + "altText" TEXT, + "mimeType" TEXT, + "size" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "MediaUsage" ( + "id" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "usageType" "MediaUsageType" NOT NULL, + "entityType" TEXT NOT NULL, + "entityId" TEXT NOT NULL, + "fieldKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MediaUsage_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset"("kind", "createdAt"); + +CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage"("assetId"); + +CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage"("entityType", "entityId"); + +CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage"("usageType", "entityType", "entityId", "fieldKey"); + +ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "MediaAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;