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
+1 -1
View File
@@ -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"]
+102
View File
@@ -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));
}
}
+183
View File
@@ -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 (
<RootDashboardShell
copy={copy}
active="media"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
>
<div className="space-y-6">
{searchParams?.success ? (
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
) : null}
{searchParams?.error ? (
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
) : null}
<AppCard>
<CardHeader>
<CardTitle>New Media Asset</CardTitle>
<CardDescription>Upload a file or store an external URL for reuse across the site.</CardDescription>
</CardHeader>
<CardContent>
<form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="label">Label</Label>
<Input id="label" name="label" required />
</div>
<div className="space-y-2">
<Label htmlFor="kind">Kind</Label>
<select
id="kind"
name="kind"
defaultValue="IMAGE"
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<option value="IMAGE">IMAGE</option>
<option value="DOCUMENT">DOCUMENT</option>
</select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="file">Upload File</Label>
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="externalUrl">External URL</Label>
<Input id="externalUrl" name="externalUrl" placeholder="https://example.com/image.jpg" />
</div>
<div className="md:col-span-2">
<Button type="submit">
<ImageIcon className="h-4 w-4" />
Save Media
</Button>
</div>
</form>
</CardContent>
</AppCard>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{mediaAssets.map((asset) => (
<AppCard key={asset.id}>
<CardHeader>
<CardTitle className="text-base">{asset.label}</CardTitle>
<CardDescription className="flex flex-wrap gap-2">
<span>{asset.kind}</span>
<span>{asset.source}</span>
<span>{asset.usages.length} usages</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{asset.kind === "IMAGE" ? (
<div className="overflow-hidden rounded-surface border border-border bg-surface-1">
<img src={asset.url} alt={asset.label} className="h-48 w-full object-cover" />
</div>
) : (
<div className="rounded-surface border border-border bg-surface-1 px-4 py-6 text-sm text-muted-foreground">
{asset.fileName}
</div>
)}
<div className="space-y-2 text-sm text-muted-foreground">
<p className="truncate">{asset.url}</p>
<div className="flex flex-wrap gap-3">
<Link href={asset.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-foreground">
<ExternalLink className="h-4 w-4" />
Open
</Link>
</div>
</div>
{asset.usages.length > 0 ? (
<div className="space-y-2 rounded-nested border border-border bg-surface-1 px-4 py-3 text-xs text-muted-foreground">
{asset.usages.map((usage) => (
<p key={usage.id}>
{usage.usageType} / {usage.entityType} / {usage.fieldKey}
</p>
))}
</div>
) : null}
<form action={deleteMediaAssetAction}>
<input type="hidden" name="assetId" value={asset.id} />
<Button type="submit" variant="destructive" disabled={asset.usages.length > 0}>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</form>
</CardContent>
</AppCard>
))}
</div>
{mediaAssets.length === 0 ? (
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
No media assets found yet.
</CardContent>
</AppCard>
) : null}
</div>
</RootDashboardShell>
);
}
+19 -8
View File
@@ -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."));
}
}
+45
View File
@@ -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<string, string> = {
".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,
});
}
}
+150
View File
@@ -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<MediaFieldState["mode"]> = ["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 (
<div className="space-y-3 rounded-surface border border-border p-4">
<div className="space-y-2">
<Label>{title}</Label>
<div className="flex flex-wrap gap-2">
{modeOptions.map((mode) => (
<button
key={mode}
type="button"
onClick={() =>
onChange({
...value,
mode,
assetId: mode === "library" ? value.assetId : "",
url: mode === "external" ? value.url : "",
})
}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
value.mode === mode
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
)}
>
{mode}
</button>
))}
</div>
</div>
<input
type="hidden"
name={inputName}
value={JSON.stringify({
mode: value.mode,
assetId: value.assetId,
url: value.url,
label: value.label,
kind: value.kind,
})}
/>
<div className="space-y-2">
<Label>Label</Label>
<Input
value={value.label}
onChange={(event) => onChange({ ...value, label: event.target.value })}
placeholder="Homepage Hero"
/>
</div>
{value.mode === "upload" ? (
<div className="space-y-2">
<Label>{fileFieldName}</Label>
<Input name={fileFieldName} type="file" accept={accept} />
</div>
) : null}
{value.mode === "external" ? (
<div className="space-y-2">
<Label>External URL</Label>
<Input
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value })}
placeholder="https://example.com/image.jpg"
/>
</div>
) : null}
{value.mode === "library" ? (
<div className="space-y-2">
<Label>Media Library</Label>
<select
value={value.assetId}
onChange={(event) => onChange({ ...value, assetId: event.target.value })}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<option value="">Select media</option>
{filteredOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
) : null}
{previewUrl ? (
value.kind === "IMAGE" ? (
<div className="overflow-hidden rounded-nested border border-border bg-surface-1">
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
</div>
) : (
<div className="rounded-nested border border-border bg-surface-1 px-4 py-3 text-sm text-muted-foreground">
{previewUrl}
</div>
)
) : null}
</div>
);
}
+3
View File
@@ -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:
+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) {
@@ -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;