2026-02-17 10:02:35 +00:00
|
|
|
"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";
|
2026-04-13 15:20:26 +00:00
|
|
|
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";
|
2026-02-17 10:02:35 +00:00
|
|
|
|
|
|
|
|
const getFileIcon = (type: string) => {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "video":
|
2026-04-13 15:20:26 +00:00
|
|
|
return <Film className="h-8 w-8 text-purple-500" />;
|
2026-02-17 10:02:35 +00:00
|
|
|
case "audio":
|
2026-04-13 15:20:26 +00:00
|
|
|
return <Music className="h-8 w-8 text-green-500" />;
|
|
|
|
|
case "document":
|
|
|
|
|
return <FileText className="h-8 w-8 text-amber-600" />;
|
2026-02-17 10:02:35 +00:00
|
|
|
default:
|
2026-04-13 15:20:26 +00:00
|
|
|
return <ImageIcon className="h-8 w-8 text-blue-500" />;
|
2026-02-17 10:02:35 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
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
|
|
|
|
|
<img
|
|
|
|
|
src={file.public_url}
|
|
|
|
|
alt={file.original_filename || "Preview"}
|
|
|
|
|
className="h-full w-full object-cover"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
decoding="async"
|
|
|
|
|
onError={() => setBroken(true)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center bg-slate-100">
|
|
|
|
|
{getFileIcon(file.file_category)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 10:02:35 +00:00
|
|
|
const getBadgeStyle = (type: string) => {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "video":
|
|
|
|
|
return "bg-purple-100 text-purple-600";
|
|
|
|
|
case "audio":
|
|
|
|
|
return "bg-green-100 text-green-600";
|
2026-04-13 15:20:26 +00:00
|
|
|
case "document":
|
|
|
|
|
return "bg-amber-100 text-amber-800";
|
2026-02-17 10:02:35 +00:00
|
|
|
default:
|
|
|
|
|
return "bg-blue-100 text-blue-600";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
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" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-17 10:02:35 +00:00
|
|
|
export default function MediaLibrary() {
|
|
|
|
|
const [search, setSearch] = useState("");
|
2026-04-13 15:20:26 +00:00
|
|
|
const [sourceFilter, setSourceFilter] = useState("all");
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
const [items, setItems] = useState<MediaLibraryItem[]>([]);
|
|
|
|
|
const [totalPage, setTotalPage] = useState(1);
|
|
|
|
|
const [totalCount, setTotalCount] = useState<number | null>(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<HTMLInputElement>(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<HTMLInputElement>) => {
|
|
|
|
|
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();
|
|
|
|
|
};
|
2026-02-17 10:02:35 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-8">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-semibold">Media Library</h1>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-04-13 15:20:26 +00:00
|
|
|
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.
|
2026-02-17 10:02:35 +00:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
<Card className="overflow-hidden rounded-2xl p-0">
|
2026-02-17 10:02:35 +00:00
|
|
|
<CardContent className="p-0">
|
2026-04-13 15:20:26 +00:00
|
|
|
<div className="space-y-4 bg-gradient-to-r from-[#966314] to-[#b07c18] p-8 text-center text-white">
|
|
|
|
|
<Upload className="mx-auto h-10 w-10" />
|
|
|
|
|
<h2 className="text-lg font-semibold">Upload ke Media Library</h2>
|
|
|
|
|
<p className="text-sm text-white/90">
|
|
|
|
|
File disimpan di MinIO (prefix cms/media-library) dan URL viewer otomatis
|
|
|
|
|
didaftarkan di tabel ini.
|
2026-02-17 10:02:35 +00:00
|
|
|
</p>
|
2026-04-13 15:20:26 +00:00
|
|
|
<input
|
|
|
|
|
ref={fileRef}
|
|
|
|
|
type="file"
|
|
|
|
|
className="hidden"
|
|
|
|
|
accept=".jpg,.jpeg,.png,.gif,.webp,.svg,.mp4,.webm,.mov,.mp3,.wav,.ogg,.m4a,.pdf,.doc,.docx,.txt,.csv"
|
|
|
|
|
multiple
|
|
|
|
|
onChange={(e) => void onSelectFiles(e)}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
className="bg-white text-[#966314] hover:bg-white/90"
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
onClick={() => fileRef.current?.click()}
|
|
|
|
|
>
|
|
|
|
|
{uploading ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
Mengunggah…
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"Pilih file"
|
|
|
|
|
)}
|
2026-02-17 10:02:35 +00:00
|
|
|
</Button>
|
2026-04-13 15:20:26 +00:00
|
|
|
<p className="text-xs text-white/80">
|
|
|
|
|
Gambar, video, audio, PDF/DOC/TXT (satu file per request batch di atas)
|
2026-02-17 10:02:35 +00:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
<Card className="rounded-xl border-dashed p-4">
|
|
|
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
|
|
|
|
<div className="flex-1 space-y-2">
|
|
|
|
|
<Label>Daftarkan URL yang sudah ada</Label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="https://… (mis. dari artikel atau CMS)"
|
|
|
|
|
value={registerUrl}
|
|
|
|
|
onChange={(e) => setRegisterUrl(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="w-full space-y-2 md:w-56">
|
|
|
|
|
<Label>Label (opsional)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="mis. campaign_q1"
|
|
|
|
|
value={registerLabel}
|
|
|
|
|
onChange={(e) => setRegisterLabel(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
disabled={registering || !registerUrl.trim()}
|
|
|
|
|
onClick={() => void onRegisterManual()}
|
|
|
|
|
>
|
|
|
|
|
{registering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />}
|
|
|
|
|
<span className="ml-2">Simpan ke library</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
2026-02-17 10:02:35 +00:00
|
|
|
{stats.map((item, i) => (
|
|
|
|
|
<Card key={i} className="rounded-xl shadow-sm">
|
|
|
|
|
<CardContent className="p-5">
|
|
|
|
|
<p className="text-2xl font-semibold">{item.value}</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">{item.title}</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
2026-02-17 10:02:35 +00:00
|
|
|
<div className="relative w-full md:max-w-md">
|
2026-04-13 15:20:26 +00:00
|
|
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
2026-02-17 10:02:35 +00:00
|
|
|
<Input
|
2026-04-13 15:20:26 +00:00
|
|
|
placeholder="Cari nama file atau URL…"
|
2026-02-17 10:02:35 +00:00
|
|
|
className="pl-9"
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
2026-04-13 15:20:26 +00:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
setPage(1);
|
|
|
|
|
void load();
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-02-17 10:02:35 +00:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-04-13 15:20:26 +00:00
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<Select value={sourceFilter} onValueChange={(v) => { setSourceFilter(v); setPage(1); }}>
|
|
|
|
|
<SelectTrigger className="w-[200px]">
|
|
|
|
|
<SelectValue placeholder="Sumber" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{SOURCE_OPTIONS.map((o) => (
|
|
|
|
|
<SelectItem key={o.value} value={o.value}>
|
|
|
|
|
{o.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<Button variant="outline" type="button" onClick={() => void load()}>
|
|
|
|
|
Refresh
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-02-17 10:02:35 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex justify-center py-16 text-muted-foreground">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
) : items.length === 0 ? (
|
|
|
|
|
<p className="py-12 text-center text-muted-foreground">Belum ada media.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
|
|
|
{items.map((file) => (
|
|
|
|
|
<Card
|
|
|
|
|
key={file.id}
|
|
|
|
|
className="overflow-hidden rounded-2xl shadow-sm transition hover:shadow-md"
|
|
|
|
|
>
|
|
|
|
|
<div className="relative h-36 w-full overflow-hidden bg-slate-100">
|
|
|
|
|
<LibraryMediaPreview file={file} />
|
|
|
|
|
</div>
|
2026-02-17 10:02:35 +00:00
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
<CardContent className="space-y-2 p-4">
|
|
|
|
|
<p className="truncate text-sm font-medium" title={file.original_filename ?? ""}>
|
|
|
|
|
{file.original_filename || "—"}
|
|
|
|
|
</p>
|
2026-02-17 10:02:35 +00:00
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
|
|
|
<Badge className={getBadgeStyle(file.file_category)}>
|
|
|
|
|
{file.file_category}
|
|
|
|
|
</Badge>
|
|
|
|
|
<span>{formatBytes(file.size_bytes ?? undefined)}</span>
|
|
|
|
|
</div>
|
2026-02-17 10:02:35 +00:00
|
|
|
|
2026-04-13 15:20:26 +00:00
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{file.source_type}
|
|
|
|
|
{file.source_label ? ` · ${file.source_label}` : ""}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">{formatDate(file.created_at)}</p>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 flex-1"
|
|
|
|
|
onClick={() => void copyLink(file.public_url)}
|
|
|
|
|
>
|
|
|
|
|
<Copy className="mr-1 h-3.5 w-3.5" />
|
|
|
|
|
Salin URL
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 text-destructive"
|
|
|
|
|
onClick={() => void onDelete(file.id)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{totalPage > 1 ? (
|
|
|
|
|
<div className="flex justify-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={page <= 1}
|
|
|
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
>
|
|
|
|
|
Sebelumnya
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
|
|
|
|
Halaman {page} / {totalPage}
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={page >= totalPage}
|
|
|
|
|
onClick={() => setPage((p) => p + 1)}
|
|
|
|
|
>
|
|
|
|
|
Berikutnya
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-02-17 10:02:35 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|