@@ -175,7 +178,10 @@ export default function ProductSection({
}
return (
-
+
diff --git a/components/landing-page/service.tsx b/components/landing-page/service.tsx
index 903adc2..1bd46f6 100644
--- a/components/landing-page/service.tsx
+++ b/components/landing-page/service.tsx
@@ -118,7 +118,10 @@ export default function ServiceSection({
if (list.length === 0) {
return (
-
+
@@ -144,7 +147,10 @@ export default function ServiceSection({
}
return (
-
+
diff --git a/components/main/media-library.tsx b/components/main/media-library.tsx
index 5d2d3a2..8043ecb 100644
--- a/components/main/media-library.tsx
+++ b/components/main/media-library.tsx
@@ -4,101 +4,295 @@ 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, Filter, ImageIcon, Film, Music } from "lucide-react";
-import { useState } from "react";
-
-const stats = [
- { title: "Total Files", value: 24 },
- { title: "Images", value: 18 },
- { title: "Videos", value: 4 },
- { title: "Audio", value: 2 },
-];
-
-const files = [
- {
- name: "hero-banner.jpg",
- type: "image",
- size: "2.4 MB",
- date: "2024-01-20",
- },
- {
- name: "product-showcase.jpg",
- type: "image",
- size: "2.4 MB",
- date: "2024-01-20",
- },
- {
- name: "company-logo.svg",
- type: "image",
- size: "124 KB",
- date: "2024-01-20",
- },
- {
- name: "promo-video.mp4",
- type: "video",
- size: "2.4 MB",
- date: "2024-01-20",
- },
-];
+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 ;
+ return ;
case "audio":
- return ;
+ return ;
+ case "document":
+ return ;
default:
- return ;
+ 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 (
- {/* ================= HEADER ================= */}
Media Library
- Upload and manage images, videos, and documents
+ 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 AREA ================= */}
-
+
-
-
-
Upload Media Files
-
- Drag and drop files here, or click to browse
+
+
+
Upload ke Media Library
+
+ File disimpan di MinIO (prefix cms/media-library) dan URL viewer otomatis
+ didaftarkan di tabel ini.
-
-
- Select Files
+ void onSelectFiles(e)}
+ />
+ fileRef.current?.click()}
+ >
+ {uploading ? (
+ <>
+
+ Mengunggah…
+ >
+ ) : (
+ "Pilih file"
+ )}
-
-
- Supports: JPG, PNG, SVG, PDF, MP4 (Max 50MB)
+
+ Gambar, video, audio, PDF/DOC/TXT (satu file per request batch di atas)
- {/* ================= STATS ================= */}
-
+
+
+
+ Daftarkan URL yang sudah ada
+ setRegisterUrl(e.target.value)}
+ />
+
+
+ Label (opsional)
+ setRegisterLabel(e.target.value)}
+ />
+
+
void onRegisterManual()}
+ >
+ {registering ? : }
+ Simpan ke library
+
+
+
+
+
{stats.map((item, i) => (
@@ -109,48 +303,126 @@ export default function MediaLibrary() {
))}
- {/* ================= SEARCH + FILTER ================= */}
-
+
-
+
setSearch(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ setPage(1);
+ void load();
+ }
+ }}
/>
-
-
-
- Filters
-
+
+ { setSourceFilter(v); setPage(1); }}>
+
+
+
+
+ {SOURCE_OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ void load()}>
+ Refresh
+
+
- {/* ================= FILE GRID ================= */}
-
- {files.map((file, index) => (
-
-
- {getFileIcon(file.type)}
-
-
-
- {file.name}
-
-
-
{file.type}
-
{file.size}
+ {loading ? (
+
+
+
+ ) : items.length === 0 ? (
+
Belum ada media.
+ ) : (
+
+ {items.map((file) => (
+
+
+
- {file.date}
-
-
- ))}
-
+
+
+ {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}
);
}
diff --git a/service/media-library.ts b/service/media-library.ts
new file mode 100644
index 0000000..78fbfc9
--- /dev/null
+++ b/service/media-library.ts
@@ -0,0 +1,78 @@
+import {
+ httpDeleteInterceptor,
+ httpGetInterceptor,
+ httpPostFormDataInterceptor,
+ httpPostInterceptor,
+} from "./http-config/http-interceptor-services";
+export type MediaLibraryItem = {
+ id: number;
+ public_url: string;
+ object_key?: string | null;
+ original_filename?: string | null;
+ file_category: string;
+ size_bytes?: number | null;
+ source_type: string;
+ source_label?: string | null;
+ article_file_id?: number | null;
+ created_by_id: number;
+ created_at: string;
+ updated_at: string;
+};
+
+export type MediaLibraryListMeta = {
+ limit?: number;
+ page?: number;
+ count?: number;
+ totalPage?: number;
+ nextPage?: number;
+ previousPage?: number;
+};
+
+export async function getMediaLibrary(params: {
+ page?: number;
+ limit?: number;
+ q?: string;
+ source_type?: string;
+}) {
+ const sp = new URLSearchParams();
+ if (params.page != null) sp.set("page", String(params.page));
+ if (params.limit != null) sp.set("limit", String(params.limit));
+ if (params.q?.trim()) sp.set("q", params.q.trim());
+ if (params.source_type?.trim()) sp.set("source_type", params.source_type.trim());
+ const q = sp.toString();
+ return await httpGetInterceptor(`/media-library${q ? `?${q}` : ""}`);
+}
+
+export function parseMediaLibraryList(res: unknown): {
+ items: MediaLibraryItem[];
+ meta: MediaLibraryListMeta | null;
+} {
+ if (!res || typeof res !== "object") return { items: [], meta: null };
+ const r = res as { error?: boolean; data?: { data?: unknown; meta?: MediaLibraryListMeta } };
+ if (r.error || !r.data) return { items: [], meta: null };
+ const raw = r.data.data;
+ const items = Array.isArray(raw) ? (raw as MediaLibraryItem[]) : [];
+ return { items, meta: r.data.meta ?? null };
+}
+
+/** Multipart: field `file` */
+export async function uploadMediaLibraryFile(formData: FormData) {
+ return await httpPostFormDataInterceptor("/media-library/upload", formData);
+}
+
+export async function registerMediaLibrary(body: {
+ public_url: string;
+ object_key?: string;
+ original_filename?: string;
+ file_category?: string;
+ size_bytes?: number;
+ source_type: string;
+ source_label?: string;
+ article_file_id?: number;
+}) {
+ return await httpPostInterceptor("/media-library/register", body);
+}
+
+export async function deleteMediaLibraryItem(id: number) {
+ return await httpDeleteInterceptor(`/media-library/${id}`);
+}