From ec30f69c72cf2cf1b89b5b6f64be87981224539a Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 23:25:41 +0200 Subject: [PATCH] fix(admin): complete media manager --- app/admin/medien/page.tsx | 56 +------- app/api/admin/media/route.ts | 29 ++-- app/globals.css | 179 ++++++++++++++++++++---- components/admin/MediaManager.tsx | 220 ++++++++++++++++++++++++++++++ lib/admin/media.ts | 29 +++- 5 files changed, 417 insertions(+), 96 deletions(-) create mode 100644 components/admin/MediaManager.tsx diff --git a/app/admin/medien/page.tsx b/app/admin/medien/page.tsx index f7232b7..9f90659 100644 --- a/app/admin/medien/page.tsx +++ b/app/admin/medien/page.tsx @@ -1,26 +1,12 @@ -import Image from "next/image"; import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import MediaManager from "@/components/admin/MediaManager"; import { requireAdminSession } from "@/lib/admin/auth"; import { listMediaFiles } from "@/lib/admin/media"; -function formatBytes(size: number) { - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / 1024 / 1024).toFixed(1)} MB`; -} - -function uploadErrorMessage(error?: string) { - if (!error) return ""; - if (error === "missing-file") return "Bitte wählen Sie eine Datei aus."; - if (error === "storage") return "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen."; - return error; -} - -export default async function AdminMediaPage({ searchParams }: { searchParams: Promise<{ uploaded?: string; error?: string }> }) { +export default async function AdminMediaPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [files, params] = await Promise.all([listMediaFiles(), searchParams]); - const error = uploadErrorMessage(params.error); + const files = await listMediaFiles(); return ( @@ -28,41 +14,7 @@ export default async function AdminMediaPage({ searchParams }: { searchParams: P

Assets

Medienverwaltung

- {params.uploaded === "1" &&

Datei wurde erfolgreich hochgeladen.

} - {error &&

Upload fehlgeschlagen: {error}

} -
- - -
-
-

Uploads

- {files.length === 0 ? ( -
-

Noch keine Medien vorhanden

-

Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau und Metadaten.

-
- ) : ( -
- {files.map((file) => ( - - ))} -
- )} -
+
); } diff --git a/app/api/admin/media/route.ts b/app/api/admin/media/route.ts index 134e0b8..3ba158a 100644 --- a/app/api/admin/media/route.ts +++ b/app/api/admin/media/route.ts @@ -1,13 +1,6 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { listMediaFiles, saveMediaFile } from "@/lib/admin/media"; - -function relativeRedirect(target: string) { - return new NextResponse(null, { - status: 303, - headers: { Location: target }, - }); -} +import { deleteMediaFile, listMediaFiles, saveMediaFile } from "@/lib/admin/media"; export async function GET() { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); @@ -20,15 +13,27 @@ export async function POST(request: Request) { const file = form.get("file"); if (!(file instanceof File)) { - return relativeRedirect("/admin/medien?error=missing-file"); + return NextResponse.json({ message: "Bitte wählen Sie eine Datei aus." }, { status: 400 }); } try { const result = await saveMediaFile(file); - if (result.error) return relativeRedirect(`/admin/medien?error=${encodeURIComponent(result.error)}`); + if (result.error) return NextResponse.json({ message: result.error }, { status: 400 }); } catch { - return relativeRedirect("/admin/medien?error=storage"); + return NextResponse.json({ message: "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen." }, { status: 500 }); } - return relativeRedirect("/admin/medien?uploaded=1"); + return NextResponse.json({ message: "Datei erfolgreich hochgeladen.", files: await listMediaFiles() }); +} + +export async function DELETE(request: Request) { + if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); + + const body = await request.json() as { name?: string }; + if (!body.name) return NextResponse.json({ message: "Dateiname fehlt." }, { status: 400 }); + + const result = await deleteMediaFile(body.name); + if (result.error) return NextResponse.json({ message: result.error }, { status: 400 }); + + return NextResponse.json({ message: "Datei gelöscht.", files: await listMediaFiles() }); } diff --git a/app/globals.css b/app/globals.css index eb29a89..25bc51b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -754,26 +754,29 @@ h3 { margin-bottom: 18px; } -.media-grid { +.media-toolbar { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) 150px 150px auto; + gap: 14px; + align-items: end; } -.media-item { +.media-toolbar label { display: grid; - min-height: 130px; - place-items: center; - border: 1px solid var(--line); - border-radius: 8px; - background: #f8fafc; - overflow: hidden; + gap: 7px; + color: var(--ink); + font-size: 14px; + font-weight: 800; } -.media-item img { +.media-toolbar input, +.media-toolbar select { width: 100%; - height: 150px; - object-fit: cover; + border: 1px solid #cfd8e5; + border-radius: 8px; + background: #fff; + padding: 11px 12px; + color: var(--ink); } .media-empty { @@ -787,47 +790,81 @@ h3 { color: var(--muted); } -.media-list { +.media-card-grid { display: grid; - gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; } -.media-row { +.media-card { display: grid; - grid-template-columns: 180px minmax(0, 1fr); - gap: 16px; - align-items: center; border: 1px solid var(--line); border-radius: 8px; background: #fff; - padding: 12px; + overflow: hidden; + box-shadow: 0 10px 26px rgba(8, 42, 96, 0.06); } -.media-preview { +.media-card-preview { display: grid; - min-height: 110px; + width: 100%; + aspect-ratio: 16 / 10; place-items: center; - border: 1px solid var(--line); - border-radius: 8px; + border: 0; + border-bottom: 1px solid var(--line); background: #f8fafc; color: var(--navy); font-weight: 800; overflow: hidden; + cursor: pointer; + padding: 0; } -.media-preview img { +.media-card-preview img { width: 100%; - height: 110px; + height: 100%; object-fit: cover; } +.pdf-icon { + display: grid; + width: 72px; + height: 92px; + place-items: center; + border: 2px solid var(--navy); + border-radius: 6px; + background: #fff; + color: var(--navy); + font-size: 18px; + font-weight: 900; +} + +.media-card-body { + display: grid; + gap: 14px; + padding: 16px; +} + +.media-card-body h3 { + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + .media-meta { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: 1fr; gap: 10px; margin: 0; } +.media-meta div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; +} + .media-meta dt { color: var(--muted); font-size: 13px; @@ -839,6 +876,91 @@ h3 { overflow-wrap: anywhere; } +.media-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.media-actions button, +.media-actions a { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--navy); + font-weight: 800; + padding: 0 10px; + text-align: center; +} + +.media-actions .danger { + border-color: #f1b5ad; + color: #b42318; +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: rgba(5, 16, 32, 0.9); + color: #fff; +} + +.lightbox-toolbar { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(5, 16, 32, 0.96); + padding: 12px 18px; +} + +.lightbox-toolbar div { + display: flex; + gap: 8px; +} + +.lightbox-toolbar button { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + color: #fff; + font-weight: 800; + padding: 8px 10px; +} + +.lightbox-backdrop { + position: fixed; + inset: 0; + border: 0; + background: transparent; +} + +.lightbox-stage { + position: relative; + z-index: 1; + display: grid; + place-items: center; + overflow: auto; + padding: 28px; +} + +.lightbox-stage img { + max-width: min(92vw, 1400px); + max-height: 82vh; + transform-origin: center; + transition: transform 140ms ease; +} + .system-list { display: grid; gap: 0; @@ -963,7 +1085,8 @@ h3 { display: grid; } - .media-row, + .media-toolbar, + .media-card-grid, .media-meta { grid-template-columns: 1fr; } diff --git a/components/admin/MediaManager.tsx b/components/admin/MediaManager.tsx new file mode 100644 index 0000000..d36a4ab --- /dev/null +++ b/components/admin/MediaManager.tsx @@ -0,0 +1,220 @@ +"use client"; + +/* eslint-disable @next/next/no-img-element */ + +import { useEffect, useMemo, useState, type ChangeEvent } from "react"; +import type { MediaFile } from "@/lib/admin/media"; + +type SortKey = "date" | "name" | "size"; +type FilterKey = "all" | "images" | "pdf"; + +type ApiResponse = { + message?: string; + files?: MediaFile[]; +}; + +function formatBytes(size: number) { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +function sortFiles(files: MediaFile[], sort: SortKey) { + return [...files].sort((left, right) => { + if (sort === "name") return left.name.localeCompare(right.name); + if (sort === "size") return right.size - left.size; + return right.uploadedAt.localeCompare(left.uploadedAt); + }); +} + +export default function MediaManager({ initialFiles }: { initialFiles: MediaFile[] }) { + const [files, setFiles] = useState(initialFiles); + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("date"); + const [query, setQuery] = useState(""); + const [toast, setToast] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const [preview, setPreview] = useState(null); + const [zoom, setZoom] = useState(1); + + useEffect(() => { + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setPreview(null); + setZoom(1); + } + } + + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, []); + + const visibleFiles = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + const filtered = files.filter((file) => { + if (filter === "images" && !file.isImage) return false; + if (filter === "pdf" && !file.isPdf) return false; + if (!normalizedQuery) return true; + return file.name.toLowerCase().includes(normalizedQuery) || file.extension.toLowerCase().includes(normalizedQuery); + }); + return sortFiles(filtered, sort); + }, [files, filter, query, sort]); + + async function upload(event: ChangeEvent) { + const file = event.currentTarget.files?.[0]; + if (!file) return; + + setPending(true); + setToast(""); + setError(""); + + const form = new FormData(); + form.set("file", file); + + try { + const response = await fetch("/api/admin/media", { method: "POST", body: form }); + const result = await response.json() as ApiResponse; + if (!response.ok) throw new Error(result.message ?? "Upload fehlgeschlagen."); + if (result.files) setFiles(result.files); + setToast(result.message ?? "Datei erfolgreich hochgeladen."); + event.currentTarget.value = ""; + } catch (err) { + setError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); + } finally { + setPending(false); + } + } + + async function refresh() { + const response = await fetch("/api/admin/media", { method: "GET" }); + const result = await response.json() as ApiResponse; + if (response.ok && result.files) setFiles(result.files); + } + + async function remove(file: MediaFile) { + if (!window.confirm(`Datei wirklich löschen?\n\n${file.name}`)) return; + setToast(""); + setError(""); + + try { + const response = await fetch("/api/admin/media", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: file.name }), + }); + const result = await response.json() as ApiResponse; + if (!response.ok) throw new Error(result.message ?? "Datei konnte nicht gelöscht werden."); + if (result.files) setFiles(result.files); + setToast(result.message ?? "Datei gelöscht."); + } catch (err) { + setError(err instanceof Error ? err.message : "Datei konnte nicht gelöscht werden."); + } + } + + async function copyUrl(file: MediaFile) { + try { + await navigator.clipboard.writeText(file.url); + setToast("Relative URL wurde kopiert."); + setError(""); + } catch { + setError("URL konnte nicht kopiert werden."); + } + } + + function openPreview(file: MediaFile) { + if (file.isImage) { + setPreview(file); + setZoom(1); + return; + } + + window.open(file.url, "_blank", "noopener,noreferrer"); + } + + return ( + <> + {toast &&

{toast}

} + {error &&

{error}

} + +
+ + + + + +
+ +
+

Uploads

+ {visibleFiles.length === 0 ? ( +
+

Noch keine Medien vorhanden

+

Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau, Metadaten und Aktionen.

+
+ ) : ( +
+ {visibleFiles.map((file) => ( +
+ +
+

{file.name}

+
+
Typ
{file.extension}
+
Größe
{formatBytes(file.size)}
+
Upload
{new Date(file.uploadedAt).toLocaleString("de-DE")}
+
+
+ + Download + + +
+
+
+ ))} +
+ )} +
+ + {preview && ( +
+
+ {preview.name} +
+ + + +
+
+
+ )} + + ); +} diff --git a/lib/admin/media.ts b/lib/admin/media.ts index cbe4b22..411b718 100644 --- a/lib/admin/media.ts +++ b/lib/admin/media.ts @@ -15,10 +15,12 @@ const allowedMimeTypes = new Map([ export type MediaFile = { name: string; url: string; - type: string; + type: "image" | "pdf"; + extension: string; size: number; uploadedAt: string; isImage: boolean; + isPdf: boolean; }; export async function ensureUploadDirectory() { @@ -72,21 +74,31 @@ export function resolveUploadTarget(fileName: string) { return target; } +function isAllowedStoredFile(fileName: string) { + const extension = path.extname(fileName).toLowerCase(); + const allowedExtensions = [...allowedMimeTypes.values()].flat(); + return !fileName.startsWith(".") && allowedExtensions.includes(extension); +} + export async function listMediaFiles(): Promise { await mkdir(uploadDirectory, { recursive: true }); const entries = await readdir(uploadDirectory); - const visibleEntries = entries.filter((entry) => !entry.startsWith(".")); + const visibleEntries = entries.filter(isAllowedStoredFile); const files = await Promise.all(visibleEntries.map(async (entry) => { const fileStat = await stat(path.join(uploadDirectory, entry)); const extension = path.extname(entry).toLowerCase(); + const isImage = [".jpg", ".jpeg", ".png", ".webp"].includes(extension); + const isPdf = extension === ".pdf"; return { name: entry, url: `/uploads/images/${encodeURIComponent(entry)}`, - type: extension.replace(".", "").toUpperCase() || "Datei", + type: isPdf ? "pdf" as const : "image" as const, + extension: extension.replace(".", "").toUpperCase() || "Datei", size: fileStat.size, uploadedAt: fileStat.birthtime.toISOString(), - isImage: [".jpg", ".jpeg", ".png", ".webp"].includes(extension), + isImage, + isPdf, }; })); @@ -104,3 +116,12 @@ export async function saveMediaFile(file: File) { return { fileName: safeName }; } + +export async function deleteMediaFile(fileName: string) { + if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { + return { error: "Ungültiger Dateiname." }; + } + + await rm(resolveUploadTarget(fileName), { force: true }); + return {}; +}