qudoco-fe/components/main/media-library.tsx

429 lines
14 KiB
TypeScript
Raw Normal View History

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>
);
}