"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { uploadMedia, deleteMedia } from "@/actions/admin-media"; import { formatDate } from "@/lib/format-date"; type MediaFile = { name: string; url: string; size: number; uploaded: number; }; function validImageUrl(url: string): string { if (!url.startsWith("/")) return ""; if (/[<>"']/.test(url)) return ""; if (/(data|javascript|vbscript|file):/i.test(url)) return ""; 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 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 () => { setLoading(true); try { const res = await fetch("/api/media"); const data = await res.json(); setFiles(data.files ?? []); } catch { /* ignore */ } setLoading(false); }, []); useEffect(() => { load(); }, [load]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return files; return files.filter((f) => f.name.toLowerCase().includes(q)); }, [files, query]); 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( items.map(async (file) => { const fd = new FormData(); fd.set("file", file); try { const res = await uploadMedia(fd); if (!res.ok && res.error) setError(res.error); } catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); } setProgress((prev) => ({ ...prev, current: prev.current + 1 })); }), ); await load(); setUploading(false); setProgress({ current: 0, total: 0 }); if (fileRef.current) fileRef.current.value = ""; } function copyUrl(url: string) { 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 (
{/* Toolbar */}
setQuery(e.target.value)} placeholder={t("searchPlaceholder")} className="input input-bordered flex-1 min-w-[180px] text-sm" /> {filtered.length} / {files.length}
{uploading && (
{t("uploading")} {progress.current} / {progress.total}
)} {error && (

{error}

)} {/* Dropzone */} {loading ? (

{t("uploading")}

) : files.length === 0 ? (

{t("noFiles")}

) : filtered.length === 0 ? (

{t("noFiles")}

) : (
{filtered.map((f) => { const isSelected = selected === f.name; return ( // biome-ignore lint/a11y/useSemanticElements: grid tile uses role=button for selection
setSelected(f.name)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); 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(new Date(f.uploaded), "date")}

); })}
)}
); }