From a07d43898757c8331985b13b0fc2461c762f4710 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 18 Jul 2026 17:29:01 +0200 Subject: [PATCH] Improve media manager UI: search, delete, drag-and-drop, file meta 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. --- src/actions/admin-media.ts | 13 +- src/app/admin/media/page.tsx | 16 +- src/app/api/media/route.ts | 19 ++- src/components/admin/media-grid.tsx | 236 ++++++++++++++++++++++------ 4 files changed, 214 insertions(+), 70 deletions(-) diff --git a/src/actions/admin-media.ts b/src/actions/admin-media.ts index d7df44b6..f8260daa 100644 --- a/src/actions/admin-media.ts +++ b/src/actions/admin-media.ts @@ -10,12 +10,16 @@ import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage"; const MAX_SIZE = 5 * 1024 * 1024; // 5MB const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"]; -export async function uploadMedia(formData: FormData): Promise { +export async function uploadMedia( + formData: FormData, +): Promise<{ ok: boolean; error?: string }> { await requirePermission(PERMS.PAGES_EDIT); const file = formData.get("file") as File | null; - if (!file || file.size === 0) return; - if (file.size > MAX_SIZE) return; - if (!ALLOWED.includes(file.type)) return; + if (!file || file.size === 0) return { ok: false, error: "No file provided" }; + if (file.size > MAX_SIZE) + return { ok: false, error: "File too large (max 5MB)" }; + if (!ALLOWED.includes(file.type)) + return { ok: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP" }; const baseDir = MEDIA_ROOT; // eslint-disable-next-line security/detect-non-literal-fs-filename @@ -31,6 +35,7 @@ export async function uploadMedia(formData: FormData): Promise { revalidatePath("/api/media"); revalidatePath("/admin/media"); + return { ok: true }; } export async function deleteMedia(name: string): Promise { diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx index dc9be935..8b57ad5d 100644 --- a/src/app/admin/media/page.tsx +++ b/src/app/admin/media/page.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { AdminMediaGrid } from "@/components/admin/media-grid"; import { redirect } from "next/navigation"; @@ -14,14 +13,13 @@ export default async function AdminMediaPage() { return (
-
- - ← {t("articles")} - +
+

+ {t("title")} +

+

+ {t("subtitle")} +

diff --git a/src/app/api/media/route.ts b/src/app/api/media/route.ts index e24b67c4..e4153c1d 100644 --- a/src/app/api/media/route.ts +++ b/src/app/api/media/route.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync } from "node:fs"; -import path from "node:path"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { resolve } from "node:path"; import { NextResponse } from "next/server"; import { MEDIA_ROOT } from "@/lib/media-storage"; @@ -14,11 +14,16 @@ export async function GET() { // eslint-disable-next-line security/detect-non-literal-fs-filename const files = readdirSync(dir) .filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f)) - .map((f) => ({ - name: f, - url: `/api/media/${f}`, - })) - .sort((a, b) => a.name.localeCompare(b.name)); + .map((f) => { + const stat = statSync(resolve(dir, f)); + return { + name: f, + url: `/api/media/${f}`, + size: stat.size, + uploaded: stat.mtimeMs, + }; + }) + .sort((a, b) => b.uploaded - a.uploaded); return NextResponse.json({ files }); } diff --git a/src/components/admin/media-grid.tsx b/src/components/admin/media-grid.tsx index f2013cae..9252afeb 100644 --- a/src/components/admin/media-grid.tsx +++ b/src/components/admin/media-grid.tsx @@ -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([]); 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(null); + const [copied, setCopied] = useState(null); + const [error, setError] = useState(null); const fileRef = useRef(null); const load = useCallback(async () => { @@ -35,22 +70,28 @@ export function AdminMediaGrid() { load(); }, [load]); - async function handleUpload(e: React.ChangeEvent) { - 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 ( -
-
- - {files.length} image{files.length !== 1 ? "s" : ""} - +
+ {/* Toolbar */} +
+ + setQuery(e.target.value)} + placeholder={t("searchPlaceholder")} + className="input input-bordered flex-1 min-w-[180px] text-sm" + /> + + + {filtered.length} / {files.length} +
{uploading && (
- Uploading images... + {t("uploading")} {progress.current} / {progress.total} @@ -102,41 +170,109 @@ export function AdminMediaGrid() {
)} + {error && ( +

{error}

+ )} + + {/* Dropzone */} +
{ + 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", + }} + > +

+ {t("uploadSubtitle")} +

+
+ {loading ? ( -

- Loading… +

+ {t("uploading")}

) : files.length === 0 ? ( -

- No images yet. Upload one above. +

+ {t("noFiles")} +

+ ) : filtered.length === 0 ? ( +

+ {t("noFiles")}

) : ( -
- {files.map((f) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {f.name} -
-

- {f.name} -

- +
+ {filtered.map((f) => { + const isSelected = selected === f.name; + return ( +
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)", + }} + > +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {f.name} + + {extOf(f.name)} + +
+
+

+ {f.name} +

+

+ {formatSize(f.size)} · {formatDate(f.uploaded)} +

+
+ + +
+
-
- ))} + ); + })}
)}