Improve media manager UI: search, delete, drag-and-drop, file meta
Local Build and Deploy / deploy (push) Successful in 1m21s

Add a richer media manager with filename search, per-file delete with
confirmation, drag-and-drop uploads, copy-URL feedback, file size/type/date
metadata, and server-side upload validation that returns errors to the UI.
Extend the /api/media listing with size and uploaded timestamps.
This commit is contained in:
openhands committed 2026-07-18 17:29:01 +02:00
1 parent 6a20ccda5c
commit a07d438987
4 files changed
+214 -70

No files matched your search

+186 -50
View File
@@ -1,9 +1,15 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { uploadMedia } from "@/actions/admin-media";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { uploadMedia, deleteMedia } from "@/actions/admin-media";
type MediaFile = { name: string; url: string };
type MediaFile = {
name: string;
url: string;
size: number;
uploaded: number;
};
function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
@@ -12,11 +18,40 @@ function validImageUrl(url: string): string {
return url;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(ms: number): string {
try {
return new Date(ms).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return "";
}
}
function extOf(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i + 1).toUpperCase() : "";
}
export function AdminMediaGrid() {
const t = useTranslations("pages.admin.media");
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
@@ -35,22 +70,28 @@ export function AdminMediaGrid() {
load();
}, [load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setProgress({ current: 0, total: files.length });
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return files;
return files.filter((f) => f.name.toLowerCase().includes(q));
}, [files, query]);
const fileArray = Array.from(files);
async function handleUpload(fileList: FileList | File[]) {
const items = Array.from(fileList);
if (items.length === 0) return;
setUploading(true);
setError(null);
setProgress({ current: 0, total: items.length });
await Promise.all(
fileArray.map(async (file) => {
items.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
const res = await uploadMedia(fd);
if (!res.ok && res.error) setError(res.error);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Upload failed");
}
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
}),
@@ -63,32 +104,59 @@ export function AdminMediaGrid() {
}
function copyUrl(url: string) {
navigator.clipboard.writeText(window.location.origin + url).catch(() => {});
navigator.clipboard
.writeText(window.location.origin + url)
.then(() => {
setCopied(url);
setTimeout(() => setCopied(null), 1500);
})
.catch(() => {});
}
async function remove(name: string) {
if (!window.confirm(t("confirmDelete"))) return;
try {
await deleteMedia(name);
setFiles((prev) => prev.filter((f) => f.name !== name));
if (selected === name) setSelected(null);
} catch {
/* ignore */
}
}
return (
<div className="admin-card mb-4">
<div className="flex items-center justify-between mb-3">
<strong>
{files.length} image{files.length !== 1 ? "s" : ""}
</strong>
<div className="admin-card">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<label className="btn btn-primary cursor-pointer text-sm">
{uploading ? "Uploading…" : "Upload image"}
{uploading ? t("uploading") : t("upload")}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
onChange={(e) => handleUpload(e.target.files ?? [])}
multiple
className="hidden"
/>
</label>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
className="input input-bordered flex-1 min-w-[180px] text-sm"
/>
<span className="text-xs theme-text-muted dark:theme-text-muted whitespace-nowrap">
{filtered.length} / {files.length}
</span>
</div>
{uploading && (
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span>Uploading images...</span>
<span>{t("uploading")}</span>
<span>
{progress.current} / {progress.total}
</span>
@@ -102,41 +170,109 @@ export function AdminMediaGrid() {
</div>
)}
{error && (
<p className="text-xs text-[var(--admin-error)] mb-3">{error}</p>
)}
{/* Dropzone */}
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files?.length) handleUpload(e.dataTransfer.files);
}}
className="rounded-xl border-2 border-dashed mb-4 text-center py-6 transition-colors duration-150"
style={{
borderColor: dragging
? "var(--admin-accent)"
: "color-mix(in srgb, var(--admin-text-muted) 25%, transparent)",
background: dragging
? "color-mix(in srgb, var(--admin-accent) 8%, transparent)"
: "transparent",
}}
>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("uploadSubtitle")}
</p>
</div>
{loading ? (
<p className="text-xs theme-text-muted dark:theme-text-muted">
Loading…
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
{t("uploading")}
</p>
) : files.length === 0 ? (
<p className="text-xs theme-text-muted dark:theme-text-muted">
No images yet. Upload one above.
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
{t("noFiles")}
</p>
) : filtered.length === 0 ? (
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
{t("noFiles")}
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{files.map((f) => (
<div
key={f.name}
className="border border-[var(--admin-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--admin-canvas)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[120px] object-cover block"
/>
<div className="p-2">
<p className="text-xs text-[var(--admin-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</p>
<button
type="button"
onClick={() => copyUrl(f.url)}
className="btn btn-outline text-xs p-1 w-full"
>
Copy URL
</button>
<div className="grid grid-cols-[repeat(auto-fill,minmax(170px,1fr))] gap-3">
{filtered.map((f) => {
const isSelected = selected === f.name;
return (
<div
key={f.name}
onClick={() => setSelected(f.name)}
className="group border rounded-[12px] overflow-hidden bg-[var(--admin-canvas)] cursor-pointer transition-all duration-150"
style={{
borderColor: isSelected
? "var(--admin-accent)"
: "color-mix(in srgb, var(--admin-text-muted) 14%, transparent)",
}}
>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
loading="lazy"
className="w-full h-[120px] object-cover block"
/>
<span className="absolute top-1.5 right-1.5 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-black/55 text-white uppercase">
{extOf(f.name)}
</span>
</div>
<div className="p-2">
<p className="text-xs m-0 mb-0.5 overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{f.name}
</p>
<p className="text-[11px] m-0 text-[var(--admin-text-muted)]">
{formatSize(f.size)} · {formatDate(f.uploaded)}
</p>
<div className="flex gap-1.5 mt-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copyUrl(f.url);
}}
className="btn btn-outline text-[11px] py-1 flex-1"
>
{copied === f.url ? t("urlCopied") : t("copyUrl")}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
remove(f.name);
}}
className="btn text-[11px] py-1 px-2 text-[var(--admin-error)]"
>
{t("delete")}
</button>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>