fix(admin): complete media manager
This commit is contained in:
parent
203c94b128
commit
ec30f69c72
5 changed files with 417 additions and 96 deletions
220
components/admin/MediaManager.tsx
Normal file
220
components/admin/MediaManager.tsx
Normal file
|
|
@ -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<FilterKey>("all");
|
||||
const [sort, setSort] = useState<SortKey>("date");
|
||||
const [query, setQuery] = useState("");
|
||||
const [toast, setToast] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [preview, setPreview] = useState<MediaFile | null>(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<HTMLInputElement>) {
|
||||
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 && <p className="success admin-message">{toast}</p>}
|
||||
{error && <p className="error admin-message">{error}</p>}
|
||||
|
||||
<section className="admin-card media-toolbar">
|
||||
<label>
|
||||
Datei hochladen
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" onChange={upload} disabled={pending} />
|
||||
</label>
|
||||
<label>
|
||||
Suche
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Dateiname oder Typ" />
|
||||
</label>
|
||||
<label>
|
||||
Filter
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value as FilterKey)}>
|
||||
<option value="all">Alle</option>
|
||||
<option value="images">Bilder</option>
|
||||
<option value="pdf">PDF</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Sortierung
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as SortKey)}>
|
||||
<option value="date">Datum</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="size">Größe</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="button light" type="button" onClick={refresh}>Aktualisieren</button>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>Uploads</h2>
|
||||
{visibleFiles.length === 0 ? (
|
||||
<div className="media-empty">
|
||||
<h3>Noch keine Medien vorhanden</h3>
|
||||
<p>Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau, Metadaten und Aktionen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="media-card-grid">
|
||||
{visibleFiles.map((file) => (
|
||||
<article key={file.name} className="media-card">
|
||||
<button className="media-card-preview" type="button" onClick={() => openPreview(file)}>
|
||||
{file.isImage ? <img src={file.url} alt={file.name} /> : <span className="pdf-icon">PDF</span>}
|
||||
</button>
|
||||
<div className="media-card-body">
|
||||
<h3 title={file.name}>{file.name}</h3>
|
||||
<dl className="media-meta">
|
||||
<div><dt>Typ</dt><dd>{file.extension}</dd></div>
|
||||
<div><dt>Größe</dt><dd>{formatBytes(file.size)}</dd></div>
|
||||
<div><dt>Upload</dt><dd>{new Date(file.uploadedAt).toLocaleString("de-DE")}</dd></div>
|
||||
</dl>
|
||||
<div className="media-actions">
|
||||
<button type="button" onClick={() => openPreview(file)}>Vorschau</button>
|
||||
<a href={file.url} download={file.name}>Download</a>
|
||||
<button type="button" onClick={() => copyUrl(file)}>URL kopieren</button>
|
||||
<button type="button" className="danger" onClick={() => remove(file)}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{preview && (
|
||||
<div className="lightbox" role="dialog" aria-modal="true" aria-label={`Vorschau ${preview.name}`}>
|
||||
<div className="lightbox-toolbar">
|
||||
<strong>{preview.name}</strong>
|
||||
<div>
|
||||
<button type="button" onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}>Zoom -</button>
|
||||
<button type="button" onClick={() => setZoom((value) => Math.min(3, value + 0.25))}>Zoom +</button>
|
||||
<button type="button" onClick={() => { setPreview(null); setZoom(1); }}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="lightbox-backdrop" type="button" aria-label="Vorschau schließen" onClick={() => { setPreview(null); setZoom(1); }} />
|
||||
<div className="lightbox-stage">
|
||||
<img src={preview.url} alt={preview.name} style={{ transform: `scale(${zoom})` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue