"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Upload,
Search,
ImageIcon,
Film,
Music,
FileText,
Link2,
Trash2,
Loader2,
Copy,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
deleteMediaLibraryItem,
getMediaLibrary,
parseMediaLibraryList,
registerMediaLibrary,
type MediaLibraryItem,
uploadMediaLibraryFile,
} from "@/service/media-library";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
const getFileIcon = (type: string) => {
switch (type) {
case "video":
return ;
case "audio":
return ;
case "document":
return ;
default:
return ;
}
};
function looksLikeImageUrl(url: string): boolean {
const path = url.split("?")[0].split("#")[0].toLowerCase();
return /\.(jpe?g|png|gif|webp|svg|bmp|avif)$/i.test(path);
}
function isDisplayableImage(file: MediaLibraryItem): boolean {
return file.file_category === "image" || looksLikeImageUrl(file.public_url);
}
function LibraryMediaPreview({ file }: { file: MediaLibraryItem }) {
const [broken, setBroken] = useState(false);
const showImg = isDisplayableImage(file) && !broken && Boolean(file.public_url?.trim());
if (showImg) {
return (
// eslint-disable-next-line @next/next/no-img-element -- dynamic API/MinIO URLs
setBroken(true)}
/>
);
}
return (
{getFileIcon(file.file_category)}
);
}
const getBadgeStyle = (type: string) => {
switch (type) {
case "video":
return "bg-purple-100 text-purple-600";
case "audio":
return "bg-green-100 text-green-600";
case "document":
return "bg-amber-100 text-amber-800";
default:
return "bg-blue-100 text-blue-600";
}
};
function formatBytes(n: number | null | undefined): string {
if (n == null || Number.isNaN(n)) return "—";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return iso;
}
}
const SOURCE_OPTIONS = [
{ value: "all", label: "Semua sumber" },
{ value: "article_file", label: "Artikel" },
{ value: "cms", label: "Content Website" },
{ value: "upload", label: "Upload langsung" },
];
export default function MediaLibrary() {
const [search, setSearch] = useState("");
const [sourceFilter, setSourceFilter] = useState("all");
const [page, setPage] = useState(1);
const [items, setItems] = useState([]);
const [totalPage, setTotalPage] = useState(1);
const [totalCount, setTotalCount] = useState(null);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [registerUrl, setRegisterUrl] = useState("");
const [registerLabel, setRegisterLabel] = useState("");
const [registering, setRegistering] = useState(false);
const fileRef = useRef(null);
const load = useCallback(async () => {
setLoading(true);
const res = await getMediaLibrary({
page,
limit: 24,
q: search.trim() || undefined,
source_type: sourceFilter === "all" ? undefined : sourceFilter,
});
const { items: rows, meta } = parseMediaLibraryList(res);
setItems(rows);
setTotalPage(Math.max(1, meta?.totalPage ?? 1));
if (typeof meta?.count === "number") setTotalCount(meta.count);
setLoading(false);
}, [page, search, sourceFilter]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on deps
void load();
}, [load]);
const stats = useMemo(() => {
const images = items.filter((i) => i.file_category === "image").length;
const videos = items.filter((i) => i.file_category === "video").length;
const audio = items.filter((i) => i.file_category === "audio").length;
const docs = items.filter((i) => i.file_category === "document").length;
return [
{ title: "Total (filter)", value: totalCount ?? "—" },
{ title: "Gambar (halaman)", value: images },
{ title: "Video (halaman)", value: videos },
{ title: "Audio/Dokumen (halaman)", value: audio + docs },
];
}, [items, totalCount]);
const copyLink = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
} catch {
window.prompt("Salin URL:", url);
}
};
const onSelectFiles = async (e: React.ChangeEvent) => {
const files = e.target.files;
if (!files?.length) return;
setUploading(true);
for (let i = 0; i < files.length; i++) {
const fd = new FormData();
fd.append("file", files[i]);
await uploadMediaLibraryFile(fd);
}
setUploading(false);
e.target.value = "";
setPage(1);
await load();
};
const onRegisterManual = async () => {
const url = registerUrl.trim();
if (!url) return;
setRegistering(true);
await registerMediaLibrary({
public_url: url,
source_type: "upload",
source_label: registerLabel.trim() || "manual_register",
});
setRegisterUrl("");
setRegisterLabel("");
setRegistering(false);
setPage(1);
await load();
};
const onDelete = async (id: number) => {
if (!confirm("Hapus entri dari Media Library? (file di storage tidak dihapus)")) return;
await deleteMediaLibraryItem(id);
await load();
};
return (
Media Library
Katalog URL media dari artikel, Content Website, dan upload admin. File fisik
tetap satu; yang disimpan di sini adalah tautan publik untuk pencarian dan salin
cepat.
Upload ke Media Library
File disimpan di MinIO (prefix cms/media-library) dan URL viewer otomatis
didaftarkan di tabel ini.
void onSelectFiles(e)}
/>
fileRef.current?.click()}
>
{uploading ? (
<>
Mengunggah…
>
) : (
"Pilih file"
)}
Gambar, video, audio, PDF/DOC/TXT (satu file per request batch di atas)
{stats.map((item, i) => (
{item.value}
{item.title}
))}
{loading ? (
) : items.length === 0 ? (
Belum ada media.
) : (
{items.map((file) => (
{file.original_filename || "—"}
{file.file_category}
{formatBytes(file.size_bytes ?? undefined)}
{file.source_type}
{file.source_label ? ` · ${file.source_label}` : ""}
{formatDate(file.created_at)}
void copyLink(file.public_url)}
>
Salin URL
void onDelete(file.id)}
>
))}
)}
{totalPage > 1 ? (
setPage((p) => Math.max(1, p - 1))}
>
Sebelumnya
Halaman {page} / {totalPage}
= totalPage}
onClick={() => setPage((p) => p + 1)}
>
Berikutnya
) : null}
);
}