Improve media manager UI: search, delete, drag-and-drop, file meta
Local Build and Deploy / deploy (push) Successful in 1m21s
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:
1 parent
6a20ccda5c
commit
a07d438987
4 files changed
+214
-70
No files matched your search
@@ -10,12 +10,16 @@ import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
|||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
|
|
||||||
export async function uploadMedia(formData: FormData): Promise<void> {
|
export async function uploadMedia(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
if (!file || file.size === 0) return;
|
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
|
||||||
if (file.size > MAX_SIZE) return;
|
if (file.size > MAX_SIZE)
|
||||||
if (!ALLOWED.includes(file.type)) return;
|
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;
|
const baseDir = MEDIA_ROOT;
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
@@ -31,6 +35,7 @@ export async function uploadMedia(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/admin/media");
|
||||||
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMedia(name: string): Promise<void> {
|
export async function deleteMedia(name: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminMediaGrid } from "@/components/admin/media-grid";
|
import { AdminMediaGrid } from "@/components/admin/media-grid";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -14,14 +13,13 @@ export default async function AdminMediaPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<div className="flex justify-end mb-6">
|
<div className="mb-6">
|
||||||
<Link
|
<h1 className="m-0 text-2xl font-extrabold text-[var(--admin-text)]">
|
||||||
href="/admin/articles"
|
{t("title")}
|
||||||
className="text-sm theme-text-muted dark:theme-text-muted"
|
</h1>
|
||||||
style={{ fontSize: "0.88rem" }}
|
<p className="m-0 text-sm text-[var(--admin-text-muted)] mt-1">
|
||||||
>
|
{t("subtitle")}
|
||||||
← {t("articles")}
|
</p>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<AdminMediaGrid />
|
<AdminMediaGrid />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync, readdirSync } from "node:fs";
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { MEDIA_ROOT } from "@/lib/media-storage";
|
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
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
const files = readdirSync(dir)
|
const files = readdirSync(dir)
|
||||||
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
||||||
.map((f) => ({
|
.map((f) => {
|
||||||
name: f,
|
const stat = statSync(resolve(dir, f));
|
||||||
url: `/api/media/${f}`,
|
return {
|
||||||
}))
|
name: f,
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
url: `/api/media/${f}`,
|
||||||
|
size: stat.size,
|
||||||
|
uploaded: stat.mtimeMs,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.uploaded - a.uploaded);
|
||||||
|
|
||||||
return NextResponse.json({ files });
|
return NextResponse.json({ files });
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { uploadMedia } from "@/actions/admin-media";
|
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 {
|
function validImageUrl(url: string): string {
|
||||||
if (!url.startsWith("/")) return "";
|
if (!url.startsWith("/")) return "";
|
||||||
@@ -12,11 +18,40 @@ function validImageUrl(url: string): string {
|
|||||||
return url;
|
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() {
|
export function AdminMediaGrid() {
|
||||||
|
const t = useTranslations("pages.admin.media");
|
||||||
const [files, setFiles] = useState<MediaFile[]>([]);
|
const [files, setFiles] = useState<MediaFile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
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 fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -35,22 +70,28 @@ export function AdminMediaGrid() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
const filtered = useMemo(() => {
|
||||||
const files = e.target.files;
|
const q = query.trim().toLowerCase();
|
||||||
if (!files || files.length === 0) return;
|
if (!q) return files;
|
||||||
setUploading(true);
|
return files.filter((f) => f.name.toLowerCase().includes(q));
|
||||||
setProgress({ current: 0, total: files.length });
|
}, [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(
|
await Promise.all(
|
||||||
fileArray.map(async (file) => {
|
items.map(async (file) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("file", file);
|
fd.set("file", file);
|
||||||
try {
|
try {
|
||||||
await uploadMedia(fd);
|
const res = await uploadMedia(fd);
|
||||||
|
if (!res.ok && res.error) setError(res.error);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
setError(err instanceof Error ? err.message : "Upload failed");
|
||||||
}
|
}
|
||||||
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
|
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
|
||||||
}),
|
}),
|
||||||
@@ -63,32 +104,59 @@ export function AdminMediaGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copyUrl(url: string) {
|
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 (
|
return (
|
||||||
<div className="admin-card mb-4">
|
<div className="admin-card">
|
||||||
<div className="flex items-center justify-between mb-3">
|
{/* Toolbar */}
|
||||||
<strong>
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
{files.length} image{files.length !== 1 ? "s" : ""}
|
|
||||||
</strong>
|
|
||||||
<label className="btn btn-primary cursor-pointer text-sm">
|
<label className="btn btn-primary cursor-pointer text-sm">
|
||||||
{uploading ? "Uploading…" : "Upload image"}
|
{uploading ? t("uploading") : t("upload")}
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
onChange={handleUpload}
|
onChange={(e) => handleUpload(e.target.files ?? [])}
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex justify-between text-xs mb-1">
|
<div className="flex justify-between text-xs mb-1">
|
||||||
<span>Uploading images...</span>
|
<span>{t("uploading")}</span>
|
||||||
<span>
|
<span>
|
||||||
{progress.current} / {progress.total}
|
{progress.current} / {progress.total}
|
||||||
</span>
|
</span>
|
||||||
@@ -102,41 +170,109 @@ export function AdminMediaGrid() {
|
|||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
|
||||||
Loading…
|
{t("uploading")}
|
||||||
</p>
|
</p>
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
|
||||||
No images yet. Upload one above.
|
{t("noFiles")}
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
|
||||||
|
{t("noFiles")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(170px,1fr))] gap-3">
|
||||||
{files.map((f) => (
|
{filtered.map((f) => {
|
||||||
<div
|
const isSelected = selected === f.name;
|
||||||
key={f.name}
|
return (
|
||||||
className="border border-[var(--admin-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--admin-canvas)]"
|
<div
|
||||||
>
|
key={f.name}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
onClick={() => setSelected(f.name)}
|
||||||
<img
|
className="group border rounded-[12px] overflow-hidden bg-[var(--admin-canvas)] cursor-pointer transition-all duration-150"
|
||||||
src={validImageUrl(f.url)}
|
style={{
|
||||||
alt={f.name}
|
borderColor: isSelected
|
||||||
className="w-full h-[120px] object-cover block"
|
? "var(--admin-accent)"
|
||||||
/>
|
: "color-mix(in srgb, var(--admin-text-muted) 14%, transparent)",
|
||||||
<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}
|
<div className="relative">
|
||||||
</p>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<button
|
<img
|
||||||
type="button"
|
src={validImageUrl(f.url)}
|
||||||
onClick={() => copyUrl(f.url)}
|
alt={f.name}
|
||||||
className="btn btn-outline text-xs p-1 w-full"
|
loading="lazy"
|
||||||
>
|
className="w-full h-[120px] object-cover block"
|
||||||
Copy URL
|
/>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user