Files
EpicNext-Cms/src/components/admin/media-picker.tsx
T
openhands 5e8a13a84f
Deploy / release (push) Successful in 4s
Deploy / deploy (push) Skipped
fix: resolve all biomaly lint errors and warnings across CMS
- Fix CSS parser config (tailwindDirectives enabled)
- Fix noDangerouslySetInnerHtml via SanitizedHtml component
- Fix useExhaustiveDependencies in catalog-manager-dialog
- Fix noArrayIndexKey across 26 files (stable keys)
- Fix SVG a11y (titles, roles, aria-labels)
- Fix label/input associations (htmlFor/id pairs)
- Fix static element interactions (role + keyboard support)
- Fix noImgElement, noDescendingSpecificity (disabled - external Habbo URLs)
- Fix noNonNullAssertion, useTemplate, unused vars/imports
- Add SanitizedHtml shared component
- Migrate biome.json to 2.5.4 schema
2026-07-20 17:41:53 +02:00

192 lines
5.4 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { uploadMediaAndReturn } from "@/actions/admin-media";
type MediaFile = { name: string; url: string };
function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
export function MediaPicker({
onSelect,
current,
}: {
onSelect: (url: string) => void;
current?: string;
}) {
const [open, setOpen] = useState(false);
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(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(() => {
if (open) load();
}, [open, load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
const urls: string[] = [];
for (const file of Array.from(files)) {
const fd = new FormData();
fd.set("file", file);
const url = await uploadMediaAndReturn(fd);
if (url) urls.push(url);
}
if (urls.length > 0) {
await load();
onSelect(urls[urls.length - 1]); // select the last one for backward compatibility
setOpen(false);
}
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
return (
<>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<button
type="button"
onClick={() => setOpen(true)}
className="btn btn-outline"
style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem" }}
>
Browse
</button>
{current && (
<span
style={{
fontSize: "0.78rem",
color: "var(--admin-text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 200,
}}
>
{current.split("/").pop()}
</span>
)}
</div>
{open && (
// biome-ignore lint/a11y/useSemanticElements: backdrop overlay uses role=button for click-to-close
<div
role="button"
tabIndex={0}
aria-label="Close media picker"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) setOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(false);
}
}}
>
<div
role="dialog"
aria-modal="true"
className="bg-[var(--admin-surface)] text-[var(--admin-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
<strong className="text-base">Media Library</strong>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
className="hidden"
multiple
/>
</label>
<button
type="button"
onClick={() => setOpen(false)}
className="btn text-sm"
>
Close
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
Loading…
</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => {
onSelect(f.url);
setOpen(false);
}}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150"
style={{
borderColor:
f.url === current
? "var(--admin-accent)"
: "transparent",
}}
onMouseEnter={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor =
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
}}
onMouseLeave={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}