+
+
+
+ {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" ? (
+
+

+
+ ) : (
+
+ {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