feat: update media library features

This commit is contained in:
hanif salafi 2026-04-13 22:20:26 +07:00
parent abad08e12b
commit 9a91a4ff7b
15 changed files with 792 additions and 272 deletions

4
.env
View File

@ -1,3 +1,3 @@
MEDOLS_CLIENT_KEY=bb65b1ad-e954-4a1a-b4d0-74df5bb0b640
# NEXT_PUBLIC_API_URL=http://localhost:8800
NEXT_PUBLIC_API_URL=https://qudo.id/api
NEXT_PUBLIC_API_URL=http://localhost:8800
# NEXT_PUBLIC_API_URL=https://qudo.id/api

View File

@ -11,7 +11,7 @@ import DocumentDetailSection from "@/components/details/document-selections";
import AudioPlayerSection from "@/components/details/audio-selections";
import DocumentSidebar from "@/components/details/document-sidebar-details";
import AudioSidebar from "@/components/details/audio-sidebar-details";
import FloatingMenuNews from "@/components/landing-page/floating-news";
import LandingSiteNav from "@/components/landing-page/landing-site-nav";
import Footer from "@/components/landing-page/footer";
export default function DetailsPage() {
@ -20,8 +20,13 @@ export default function DetailsPage() {
return (
<div className="min-h-screen bg-white">
<div className="max-w-7xl mx-auto px-6 py-10">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<LandingSiteNav
newsHub
searchFormAction="/news-services"
searchPlaceholder="Cari berita, artikel, atau topik..."
/>
<div className="mx-auto max-w-7xl px-6 pt-28 pb-10 md:pt-36">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* LEFT */}
<div className="lg:col-span-2">
{type === "video" && <VideoPlayerSection />}
@ -37,7 +42,6 @@ export default function DetailsPage() {
{type === "text" && <DocumentSidebar />}
{type === "audio" && <AudioSidebar />}
</div>
<FloatingMenuNews />
</div>
</div>
<Footer />

View File

@ -1,5 +1,5 @@
import Footer from "@/components/landing-page/footer";
import FloatingMenuNews from "@/components/landing-page/floating-news";
import LandingSiteNav from "@/components/landing-page/landing-site-nav";
import NewsAndServicesHeader from "@/components/landing-page/headers-news-services";
import ContentLatest from "@/components/landing-page/content-latest";
import ContentPopular from "@/components/landing-page/content-popular";
@ -83,7 +83,12 @@ export default async function NewsAndServicesPage({ searchParams }: PageProps) {
return (
<div className="relative min-h-screen bg-white">
<FloatingMenuNews />
<LandingSiteNav
newsHub
searchFormAction="/news-services"
searchDefaultValue={q ?? ""}
searchPlaceholder="Cari berita, artikel, atau topik..."
/>
<Suspense fallback={null}>
<NewsAndServicesHeader
featured={featured}

View File

@ -2,7 +2,7 @@ import Link from "next/link";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import Footer from "@/components/landing-page/footer";
import FloatingMenuNews from "@/components/landing-page/floating-news";
import LandingSiteNav from "@/components/landing-page/landing-site-nav";
import ArticleThumbnail from "@/components/landing-page/article-thumbnail";
import { ARTICLE_TYPE } from "@/constants/article-content-types";
import { fetchArticlePublic } from "@/lib/articles-public";
@ -54,8 +54,12 @@ export default async function NewsDetailPage({ params }: Props) {
return (
<div className="relative min-h-screen bg-white">
<FloatingMenuNews />
<article className="container mx-auto max-w-4xl px-6 py-16">
<LandingSiteNav
newsHub
searchFormAction="/news-services"
searchPlaceholder="Cari berita, artikel, atau topik..."
/>
<article className="container mx-auto max-w-4xl px-6 pt-28 pb-16 md:pt-36">
<Link
href="/news-services"
className="mb-8 inline-block text-sm font-medium text-[#b07c18] hover:underline"

View File

@ -4,7 +4,6 @@ import ProductSection from "@/components/landing-page/product";
import ServiceSection from "@/components/landing-page/service";
import Technology from "@/components/landing-page/technology";
import Footer from "@/components/landing-page/footer";
import FloatingMenu from "@/components/landing-page/floating";
import PopupNewsBanner from "@/components/landing-page/popup-news";
import { publicFetch } from "@/lib/public-api";
import type {
@ -42,7 +41,6 @@ export default async function Home() {
return (
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
<FloatingMenu />
<PopupNewsBanner popups={popups} />
<Header hero={hero} />
<AboutSection about={about} />

View File

@ -7,9 +7,9 @@ import {
useState,
type ReactNode,
} from "react";
import { useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { Menu } from "lucide-react";
import FloatingMenuNews from "@/components/landing-page/floating-news";
import LandingSiteNav from "@/components/landing-page/landing-site-nav";
import Footer from "@/components/landing-page/footer";
import {
fetchPublishedArticles,
@ -30,6 +30,7 @@ type Props = {
};
export default function ArticleTypeFilterPage({ config, sidebar }: Props) {
const pathname = usePathname();
const searchParams = useSearchParams();
const q = searchParams.get("q")?.trim() ?? "";
const tag = searchParams.get("tag")?.trim() ?? "";
@ -76,7 +77,13 @@ export default function ArticleTypeFilterPage({ config, sidebar }: Props) {
return (
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
<section className="min-h-screen py-10">
<LandingSiteNav
newsHub
searchFormAction={pathname}
searchDefaultValue={q}
searchPlaceholder="Cari konten..."
/>
<section className="min-h-screen py-10 pt-24 md:pt-[5.5rem]">
<div className="container mx-auto px-6">
<div className="mb-6 flex items-center justify-between lg:hidden">
<button
@ -135,7 +142,6 @@ export default function ArticleTypeFilterPage({ config, sidebar }: Props) {
<option value="latest">Terbaru</option>
<option value="popular">Terpopuler</option>
</select>
<FloatingMenuNews />
</div>
</div>

View File

@ -1,122 +0,0 @@
"use client";
import { useState } from "react";
import {
Menu,
X,
Home,
ChevronDown,
Video,
Music,
Image as ImageIcon,
FileText,
} from "lucide-react";
import Link from "next/link";
export default function FloatingMenuNews() {
const [open, setOpen] = useState(false);
const [openKonten, setOpenKonten] = useState(false);
return (
<>
{/* FLOATING BUTTON */}
<button
onClick={() => setOpen(true)}
className="fixed right-6 top-6 z-[100] flex h-12 w-12 items-center justify-center rounded-md bg-[#966314] text-white shadow-lg"
>
<Menu />
</button>
{/* OVERLAY */}
{open && (
<div
onClick={() => setOpen(false)}
className="fixed inset-0 z-[90] bg-black/40"
/>
)}
{/* SIDEBAR */}
<aside
className={`fixed right-0 top-0 z-[110] h-full w-[280px] bg-[#8a5a0c] text-white transition-transform duration-300 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
{/* HEADER */}
<div className="flex items-center justify-between border-b border-white/20 p-6">
<div className="flex rounded-full bg-white text-sm font-semibold text-[#8a5a0c]">
<button className="rounded-full bg-white px-3 py-1">ID</button>
<button className="px-3 py-1">EN</button>
</div>
<button onClick={() => setOpen(false)}>
<X />
</button>
</div>
{/* MENU */}
<nav className="flex flex-col text-base font-medium">
{/* HOME */}
<Link href="/news-services">
<div className="flex items-center justify-between border-b border-white/20 px-6 py-5 hover:bg-white/10 transition">
<span>Home</span>
<Home size={20} />
</div>
</Link>
{/* KONTEN */}
<div>
<button
onClick={() => setOpenKonten(!openKonten)}
className="flex w-full items-center justify-between border-b border-white/20 px-6 py-5 hover:bg-white/10 transition"
>
<span>Konten</span>
<ChevronDown
size={20}
className={`transition-transform duration-300 ${
openKonten ? "rotate-180" : ""
}`}
/>
</button>
{/* SUBMENU */}
<div
className={`overflow-hidden bg-[#a8772a] transition-all duration-300 ${
openKonten ? "max-h-60" : "max-h-0"
}`}
>
<Link href={"/video/filter"}>
<SubMenuItem icon={<Video size={18} />} label="Audio Visual" />
</Link>
<Link href={"/audio/filter"}>
<SubMenuItem icon={<Music size={18} />} label="Audio" />
</Link>
<Link href={"/image/filter"}>
<SubMenuItem icon={<ImageIcon size={18} />} label="Foto" />
</Link>
<Link href={"/document/filter"}>
<SubMenuItem icon={<FileText size={18} />} label="Teks" />
</Link>
</div>
</div>
</nav>
</aside>
</>
);
}
/* ================= SUBMENU ================= */
function SubMenuItem({
icon,
label,
}: {
icon: React.ReactNode;
label: string;
}) {
return (
<div className="flex items-center justify-between px-8 py-4 text-sm hover:bg-white/20 transition cursor-pointer">
<span>{label}</span>
{icon}
</div>
);
}

View File

@ -84,7 +84,7 @@ export default function Footer() {
{/* Divider */}
<div className="mt-14 border-t border-white/20 pt-6 text-center text-xs opacity-80">
© 2024 Copyrights by company. All Rights Reserved. Designed by{" "}
© 2026 Copyrights by company. All Rights Reserved. Designed by{" "}
<span className="font-semibold">Qudoco Team</span>
</div>
</div>

View File

@ -88,7 +88,7 @@ export default function NewsAndServicesHeader({
return (
<>
<section className="relative w-full bg-[#f8f8f8] py-24">
<section className="relative w-full bg-[#f8f8f8] pt-32 pb-24 md:pt-40 md:pb-32">
<div className="container relative mx-auto px-6">
{slideCount > 0 ? (
<>
@ -219,7 +219,7 @@ export default function NewsAndServicesHeader({
<AnimatePresence>
{open && slideCount > 0 && modalArticle && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

View File

@ -3,7 +3,8 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { Menu, X, Home, Box, Briefcase, Newspaper } from "lucide-react";
import { X } from "lucide-react";
import LandingSiteNav from "@/components/landing-page/landing-site-nav";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
@ -15,7 +16,6 @@ function isExternalUrl(url: string) {
}
export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
const [open, setOpen] = useState(false);
const [contactOpen, setContactOpen] = useState(false);
// Only main title is required in CMS; optional fields stay empty (no placeholder dashes or fake CTAs).
@ -33,35 +33,9 @@ export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
return (
<>
<header className="relative w-full bg-white overflow-hidden">
{/* SIDEBAR */}
<aside
className={`fixed right-0 top-0 z-50 h-full w-[280px] bg-[#966314] text-white transition-transform duration-300 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/20 p-6">
<div className="flex rounded-full bg-white text-sm font-semibold text-[#966314]">
<button className="rounded-full bg-white px-3 py-1">ID</button>
<button className="px-3 py-1">EN</button>
</div>
<button onClick={() => setOpen(false)}>
<X />
</button>
</div>
<nav className="flex flex-col gap-6 p-6 text-sm font-medium">
<MenuItem icon={<Home size={18} />} label="Home" />
<MenuItem icon={<Box size={18} />} label="Product" />
<MenuItem icon={<Briefcase size={18} />} label="Services" />
<MenuItem
icon={<Newspaper size={18} />}
label="News and Services"
/>
</nav>
</aside>
<LandingSiteNav />
<header className="relative w-full bg-white pt-20 md:pt-[5.5rem]">
{/* HERO */}
<div className="container mx-auto flex min-h-[90vh] items-center px-6">
<div className="flex-1 space-y-6">
@ -131,15 +105,6 @@ export default function Header({ hero }: { hero?: CmsHeroContent | null }) {
);
}
function MenuItem({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="flex cursor-pointer items-center justify-between border-b border-white/20 pb-3">
<span>{label}</span>
{icon}
</div>
);
}
function ContactDialog({ onClose }: { onClose: () => void }) {
const [contactMethod, setContactMethod] = useState("office");

View File

@ -0,0 +1,298 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
Menu,
X,
Home,
Box,
Briefcase,
Newspaper,
Search,
Globe,
LogIn,
ChevronDown,
Video,
Music,
Image as ImageIcon,
FileText,
} from "lucide-react";
const LANDING_SECTION_IDS = new Set(["products", "services"]);
function scrollToSectionId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
export type LandingSiteNavProps = {
/** GET form target for navbar search (e.g. `/news-services`). If omitted, search is display-only. */
searchFormAction?: string;
searchDefaultValue?: string;
searchPlaceholder?: string;
/** News hub pages: “Home” → `/news-services`, plus Konten submenu in drawer */
newsHub?: boolean;
};
export default function LandingSiteNav({
searchFormAction,
searchDefaultValue = "",
searchPlaceholder = "Search",
newsHub = false,
}: LandingSiteNavProps) {
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
const [openKonten, setOpenKonten] = useState(false);
useEffect(() => {
if (pathname !== "/") return;
const syncFromHash = () => {
const raw = window.location.hash.replace(/^#/, "");
if (!LANDING_SECTION_IDS.has(raw)) return;
const el = document.getElementById(raw);
if (!el) return;
requestAnimationFrame(() =>
el.scrollIntoView({ behavior: "smooth", block: "start" }),
);
};
const t = window.setTimeout(syncFromHash, 0);
window.addEventListener("hashchange", syncFromHash);
return () => {
window.clearTimeout(t);
window.removeEventListener("hashchange", syncFromHash);
};
}, [pathname]);
const homePath = newsHub ? "/news-services" : "/";
const goHome = () => {
setOpen(false);
if (pathname === homePath) {
window.scrollTo({ top: 0, behavior: "smooth" });
} else {
router.push(homePath);
}
};
const searchField = (
<div className="relative min-w-0 max-w-[min(100%,11rem)] flex-1 sm:max-w-none sm:flex-initial md:w-56 lg:w-64">
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-[18px] -translate-y-1/2 text-gray-400"
aria-hidden
/>
<input
type="search"
name="q"
defaultValue={searchDefaultValue}
placeholder={searchPlaceholder}
className="w-full rounded-full border border-[#966314]/55 bg-white py-2 pl-10 pr-4 text-sm text-gray-900 placeholder:text-gray-400 outline-none focus-visible:ring-2 focus-visible:ring-[#966314]/30"
/>
</div>
);
return (
<>
<nav
className="fixed top-0 left-0 right-0 z-30 w-full border-b border-gray-100 bg-white/95 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-white/80"
aria-label="Main"
>
<div className="container mx-auto flex items-center justify-between gap-4 px-4 py-4 md:px-6">
<Link href="/" className="shrink-0">
<Image
src="/image/qudo1.png"
alt="Qudoco"
width={140}
height={56}
className="h-12 w-auto md:h-14"
priority
/>
</Link>
<div className="flex min-w-0 flex-1 items-center justify-end gap-3 md:gap-5">
{searchFormAction ? (
<form
method="get"
action={searchFormAction}
className="contents"
>
{searchField}
</form>
) : (
searchField
)}
<button
type="button"
className="hidden items-center gap-2 text-sm text-gray-800 sm:flex"
>
<Globe className="size-[18px] text-gray-600" strokeWidth={1.75} />
English
</button>
<span
className="hidden h-5 w-px shrink-0 bg-gray-300 sm:block"
aria-hidden
/>
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-2 text-sm font-semibold text-gray-900"
>
<Menu className="size-5 text-gray-900" strokeWidth={2} />
Menu
</button>
</div>
</div>
</nav>
{open ? (
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-[90] bg-black/40"
onClick={() => setOpen(false)}
/>
) : null}
<aside
className={`fixed top-0 right-0 z-[100] h-full w-[280px] bg-[#966314] text-white transition-transform duration-300 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/20 p-6">
<div className="flex rounded-full bg-white text-sm font-semibold text-[#966314]">
<button type="button" className="rounded-full bg-white px-3 py-1">
ID
</button>
<button type="button" className="px-3 py-1">
EN
</button>
</div>
<button type="button" onClick={() => setOpen(false)}>
<X />
</button>
</div>
<nav className="flex flex-col gap-6 overflow-y-auto p-6 text-sm font-medium">
<button
type="button"
className="w-full border-0 bg-transparent p-0 text-left text-inherit"
onClick={goHome}
>
<MenuItem icon={<Home size={18} />} label="Home" />
</button>
{newsHub ? (
<div className="-mt-2 border-b border-white/20 pb-2">
<button
type="button"
className="flex w-full items-center justify-between border-0 bg-transparent py-1 text-left text-inherit"
onClick={() => setOpenKonten((v) => !v)}
>
<span>Konten</span>
<ChevronDown
size={18}
className={`transition-transform ${openKonten ? "rotate-180" : ""}`}
/>
</button>
<div
className={`mt-2 space-y-1 overflow-hidden pl-2 transition-all ${
openKonten ? "max-h-60 opacity-100" : "max-h-0 opacity-0"
}`}
>
<Link
href="/video/filter"
className="block rounded-md px-2 py-2 hover:bg-white/10"
onClick={() => setOpen(false)}
>
<span className="flex items-center justify-between text-sm">
Audio Visual <Video size={16} />
</span>
</Link>
<Link
href="/audio/filter"
className="block rounded-md px-2 py-2 hover:bg-white/10"
onClick={() => setOpen(false)}
>
<span className="flex items-center justify-between text-sm">
Audio <Music size={16} />
</span>
</Link>
<Link
href="/image/filter"
className="block rounded-md px-2 py-2 hover:bg-white/10"
onClick={() => setOpen(false)}
>
<span className="flex items-center justify-between text-sm">
Foto <ImageIcon size={16} />
</span>
</Link>
<Link
href="/document/filter"
className="block rounded-md px-2 py-2 hover:bg-white/10"
onClick={() => setOpen(false)}
>
<span className="flex items-center justify-between text-sm">
Teks <FileText size={16} />
</span>
</Link>
</div>
</div>
) : null}
<button
type="button"
className="w-full border-0 bg-transparent p-0 text-left text-inherit"
onClick={() => {
setOpen(false);
if (pathname === "/") {
scrollToSectionId("products");
} else {
router.push("/#products");
}
}}
>
<MenuItem icon={<Box size={18} />} label="Product" />
</button>
<button
type="button"
className="w-full border-0 bg-transparent p-0 text-left text-inherit"
onClick={() => {
setOpen(false);
if (pathname === "/") {
scrollToSectionId("services");
} else {
router.push("/#services");
}
}}
>
<MenuItem icon={<Briefcase size={18} />} label="Services" />
</button>
<Link href="/news-services?highlight=1" onClick={() => setOpen(false)}>
<MenuItem
icon={<Newspaper size={18} />}
label="News and Services"
/>
</Link>
<Link href="/auth" onClick={() => setOpen(false)}>
<MenuItem icon={<LogIn size={18} />} label="Login" />
</Link>
</nav>
</aside>
</>
);
}
function MenuItem({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="flex cursor-pointer items-center justify-between border-b border-white/20 pb-3">
<span>{label}</span>
{icon}
</div>
);
}

View File

@ -144,7 +144,10 @@ export default function ProductSection({
if (list.length === 0) {
return (
<section className="bg-white py-24 md:py-32">
<section
id="products"
className="scroll-mt-24 bg-white py-24 md:scroll-mt-[5.5rem] md:py-32"
>
<div className="container mx-auto px-6">
<div className="mb-16 text-center md:mb-20">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
@ -175,7 +178,10 @@ export default function ProductSection({
}
return (
<section className="bg-white py-24 md:py-32">
<section
id="products"
className="scroll-mt-24 bg-white py-24 md:scroll-mt-[5.5rem] md:py-32"
>
<div className="container mx-auto px-6">
<div className="mb-16 text-center md:mb-20">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">

View File

@ -118,7 +118,10 @@ export default function ServiceSection({
if (list.length === 0) {
return (
<section className="bg-white py-24 md:py-32">
<section
id="services"
className="scroll-mt-24 bg-white py-24 md:scroll-mt-[5.5rem] md:py-32"
>
<div className="container mx-auto px-6">
<div className="mb-16 text-center md:mb-20">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">
@ -144,7 +147,10 @@ export default function ServiceSection({
}
return (
<section className="bg-white py-24 md:py-32">
<section
id="services"
className="scroll-mt-24 bg-white py-24 md:scroll-mt-[5.5rem] md:py-32"
>
<div className="container mx-auto px-6">
<div className="mb-16 text-center md:mb-20">
<p className="mb-4 text-sm font-semibold uppercase tracking-widest text-gray-400">

View File

@ -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 <Film className="w-8 h-8 text-purple-500" />;
return <Film className="h-8 w-8 text-purple-500" />;
case "audio":
return <Music className="w-8 h-8 text-green-500" />;
return <Music className="h-8 w-8 text-green-500" />;
case "document":
return <FileText className="h-8 w-8 text-amber-600" />;
default:
return <ImageIcon className="w-8 h-8 text-blue-500" />;
return <ImageIcon className="h-8 w-8 text-blue-500" />;
}
};
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>
);
}
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<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();
};
return (
<div className="space-y-8">
{/* ================= HEADER ================= */}
<div>
<h1 className="text-2xl font-semibold">Media Library</h1>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
{/* ================= UPLOAD AREA ================= */}
<Card className="rounded-2xl overflow-hidden p-0">
<Card className="overflow-hidden rounded-2xl p-0">
<CardContent className="p-0">
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-10 text-center space-y-4 rounded-2xl">
<Upload className="w-10 h-10 mx-auto" />
<h2 className="text-lg font-semibold">Upload Media Files</h2>
<p className="text-sm text-blue-100">
Drag and drop files here, or click to browse
<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.
</p>
<Button className="bg-white text-blue-600 hover:bg-blue-100">
Select Files
<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"
)}
</Button>
<p className="text-xs text-blue-200">
Supports: JPG, PNG, SVG, PDF, MP4 (Max 50MB)
<p className="text-xs text-white/80">
Gambar, video, audio, PDF/DOC/TXT (satu file per request batch di atas)
</p>
</div>
</CardContent>
</Card>
{/* ================= STATS ================= */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<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">
{stats.map((item, i) => (
<Card key={i} className="rounded-xl shadow-sm">
<CardContent className="p-5">
@ -109,48 +303,126 @@ export default function MediaLibrary() {
))}
</div>
{/* ================= SEARCH + FILTER ================= */}
<div className="flex flex-col md:flex-row gap-3 md:items-center md:justify-between">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="relative w-full md:max-w-md">
<Search className="absolute left-3 top-3 w-4 h-4 text-muted-foreground" />
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search media files..."
placeholder="Cari nama file atau URL…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setPage(1);
void load();
}
}}
/>
</div>
<Button variant="outline" className="gap-2">
<Filter className="w-4 h-4" />
Filters
</Button>
<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>
</div>
{/* ================= FILE GRID ================= */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{files.map((file, index) => (
<Card
key={index}
className="rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition"
>
<div className="bg-slate-100 h-32 flex items-center justify-center">
{getFileIcon(file.type)}
</div>
<CardContent className="p-4 space-y-2">
<p className="text-sm font-medium truncate">{file.name}</p>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Badge className={getBadgeStyle(file.type)}>{file.type}</Badge>
<span>{file.size}</span>
{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>
<p className="text-xs text-muted-foreground">{file.date}</p>
</CardContent>
</Card>
))}
</div>
<CardContent className="space-y-2 p-4">
<p className="truncate text-sm font-medium" title={file.original_filename ?? ""}>
{file.original_filename || "—"}
</p>
<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>
<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}
</div>
);
}

78
service/media-library.ts Normal file
View File

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