Compare commits
2 Commits
abad08e12b
...
7515f55e88
| Author | SHA1 | Date |
|---|---|---|
|
|
7515f55e88 | |
|
|
9a91a4ff7b |
4
.env
4
.env
|
|
@ -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
|
||||
|
|
@ -33,7 +33,11 @@ export default function ContentWebsitePage() {
|
|||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="p-6">
|
||||
{levelId === "2" ? <ApproverContentWebsite /> : <ContentWebsite />}
|
||||
{levelId === "3" ? (
|
||||
<ApproverContentWebsite />
|
||||
) : (
|
||||
<ContentWebsite contributorMode={levelId === "2"} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,149 +1,250 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Pencil, Trash2, Filter } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Filter, Loader2 } from "lucide-react";
|
||||
import {
|
||||
approveCmsContentSubmission,
|
||||
listCmsContentSubmissions,
|
||||
rejectCmsContentSubmission,
|
||||
} from "@/service/cms-content-submissions";
|
||||
import { apiPayload } from "@/service/cms-landing";
|
||||
import { formatDate } from "@/utils/global";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
const DOMAIN_LABEL: Record<string, string> = {
|
||||
hero: "Hero",
|
||||
about: "About Us",
|
||||
product: "Product",
|
||||
service: "Service",
|
||||
partner: "Partner",
|
||||
popup: "Pop Up",
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
status: string;
|
||||
submitter_name: string;
|
||||
submitted_by_id: number;
|
||||
payload: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default function ApproverContentWebsite() {
|
||||
const tabs = [
|
||||
"Hero Section",
|
||||
"About Us",
|
||||
"Our Products",
|
||||
"Our Services",
|
||||
"Technology Partners",
|
||||
"Pop Up",
|
||||
];
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [actingId, setActingId] = useState<string | null>(null);
|
||||
|
||||
const data = [
|
||||
{
|
||||
title: "Beyond Expectations to Build Reputation.",
|
||||
subtitle: "-",
|
||||
author: "John Kontributor",
|
||||
status: "Published",
|
||||
date: "2024-01-15",
|
||||
},
|
||||
{
|
||||
title: "Manajemen Reputasi untuk Institusi",
|
||||
subtitle: "-",
|
||||
author: "Sarah Kontributor",
|
||||
status: "Pending",
|
||||
date: "2024-01-14",
|
||||
},
|
||||
];
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listCmsContentSubmissions({
|
||||
status: "pending",
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
const raw = apiPayload<Row[]>(res);
|
||||
setRows(Array.isArray(raw) ? raw : []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = rows.filter((r) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
r.title.toLowerCase().includes(q) ||
|
||||
(r.submitter_name ?? "").toLowerCase().includes(q) ||
|
||||
(DOMAIN_LABEL[r.domain] ?? r.domain).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
async function onApprove(id: string) {
|
||||
const ok = await Swal.fire({
|
||||
icon: "question",
|
||||
title: "Terapkan perubahan ke website?",
|
||||
showCancelButton: true,
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
setActingId(id);
|
||||
try {
|
||||
const res = await approveCmsContentSubmission(id);
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Disetujui",
|
||||
timer: 1600,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
await load();
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onReject(id: string) {
|
||||
const note = await Swal.fire({
|
||||
icon: "warning",
|
||||
title: "Tolak pengajuan?",
|
||||
input: "textarea",
|
||||
inputPlaceholder: "Catatan (opsional)",
|
||||
showCancelButton: true,
|
||||
});
|
||||
if (!note.isConfirmed) return;
|
||||
setActingId(id);
|
||||
try {
|
||||
const res = await rejectCmsContentSubmission(
|
||||
id,
|
||||
typeof note.value === "string" ? note.value : "",
|
||||
);
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Ditolak",
|
||||
timer: 1400,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
await load();
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Content Website</h1>
|
||||
<p className="text-slate-500">
|
||||
Update homepage content, products, services, and partners
|
||||
Tinjau pengajuan perubahan dari kontributor dan terapkan ke konten live.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TABS */}
|
||||
<div className="bg-white rounded-2xl shadow border p-2 flex flex-wrap gap-2">
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition ${
|
||||
i === 0
|
||||
? "bg-blue-600 text-white"
|
||||
: "hover:bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
placeholder="Cari judul, domain, atau nama pengaju…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2 shrink-0"
|
||||
onClick={() => load()}
|
||||
disabled={loading}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SEARCH & FILTER */}
|
||||
<div className="flex gap-4">
|
||||
<Input placeholder="Search Hero Section by title, author, or content..." />
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<Filter size={16} /> Filters
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Filter className="h-4 w-4" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* TABLE */}
|
||||
<div className="bg-white rounded-2xl shadow border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-4">Main Title</th>
|
||||
<th className="text-left px-6 py-4">Subtitle</th>
|
||||
<th className="text-left px-6 py-4">Author</th>
|
||||
<th className="text-left px-6 py-4">Status</th>
|
||||
<th className="text-left px-6 py-4">Date</th>
|
||||
<th className="text-left px-6 py-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{data.map((item, i) => (
|
||||
<tr key={i} className="border-t hover:bg-slate-50 transition">
|
||||
<td className="px-6 py-4 font-medium text-slate-800">
|
||||
<Card className="rounded-2xl border shadow-sm overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-[#966314]" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50">
|
||||
<TableHead>Judul</TableHead>
|
||||
<TableHead>Bagian</TableHead>
|
||||
<TableHead>Pengaju</TableHead>
|
||||
<TableHead>Tanggal</TableHead>
|
||||
<TableHead className="text-right">Aksi</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-slate-500 py-10">
|
||||
Tidak ada pengajuan tertunda.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium max-w-xs">
|
||||
{item.title}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">{item.subtitle}</td>
|
||||
|
||||
<td className="px-6 py-4 text-slate-600">{item.author}</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`text-xs font-medium px-3 py-1 rounded-full ${
|
||||
item.status === "Published"
|
||||
? "bg-green-100 text-green-600"
|
||||
: "bg-yellow-100 text-yellow-600"
|
||||
}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{DOMAIN_LABEL[item.domain] ?? item.domain}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600">
|
||||
{item.submitter_name || `User #${item.submitted_by_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600">
|
||||
{formatDate(item.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={actingId === item.id}
|
||||
onClick={() => onApprove(item.id)}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-slate-600">{item.date}</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-3 text-slate-500">
|
||||
<Eye
|
||||
size={18}
|
||||
className="cursor-pointer hover:text-blue-600"
|
||||
/>
|
||||
<Pencil
|
||||
size={18}
|
||||
className="cursor-pointer hover:text-green-600"
|
||||
/>
|
||||
<Trash2
|
||||
size={18}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div className="flex justify-between items-center px-6 py-4 border-t bg-slate-50">
|
||||
<p className="text-sm text-slate-500">Showing 1 to 2 of 2 items</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button className="px-4 py-2 border rounded-lg text-sm">
|
||||
Previous
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm">
|
||||
1
|
||||
</button>
|
||||
<button className="px-4 py-2 border rounded-lg text-sm">2</button>
|
||||
<button className="px-4 py-2 border rounded-lg text-sm">3</button>
|
||||
<button className="px-4 py-2 border rounded-lg text-sm">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Setujui
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-200"
|
||||
disabled={actingId === item.id}
|
||||
onClick={() => onReject(item.id)}
|
||||
>
|
||||
Tolak
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
updatePopupNews,
|
||||
uploadPartnerLogo,
|
||||
} from "@/service/cms-landing";
|
||||
import { submitCmsContentSubmission } from "@/service/cms-content-submissions";
|
||||
|
||||
function revokeBlobRef(ref: MutableRefObject<string | null>) {
|
||||
if (ref.current) {
|
||||
|
|
@ -87,10 +88,19 @@ function setPickedFile(
|
|||
setter(file);
|
||||
}
|
||||
|
||||
export default function ContentWebsite() {
|
||||
type ContentWebsiteProps = {
|
||||
/** User level 2: changes go through approval instead of live CMS APIs. */
|
||||
contributorMode?: boolean;
|
||||
};
|
||||
|
||||
export default function ContentWebsite({
|
||||
contributorMode = false,
|
||||
}: ContentWebsiteProps) {
|
||||
const [activeTab, setActiveTab] = useState("hero");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editMode, setEditMode] = useState(!contributorMode);
|
||||
const canInteract = !contributorMode || editMode;
|
||||
|
||||
const [heroId, setHeroId] = useState<string | null>(null);
|
||||
const [heroImageId, setHeroImageId] = useState<string | null>(null);
|
||||
|
|
@ -257,6 +267,51 @@ export default function ContentWebsite() {
|
|||
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
||||
return;
|
||||
}
|
||||
if (contributorMode && editMode) {
|
||||
if (heroPendingFile) {
|
||||
await Swal.fire({
|
||||
icon: "info",
|
||||
title: "Gunakan URL gambar",
|
||||
text: "Sebagai kontributor, unggah file tidak didukung. Salin URL dari Media Library ke bidang Image URL.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "hero",
|
||||
title: `Hero: ${heroPrimary.slice(0, 80)}`,
|
||||
payload: {
|
||||
hero_id: heroId ?? "",
|
||||
hero_image_id: heroImageId ?? "",
|
||||
primary_title: heroPrimary,
|
||||
secondary_title: heroSecondary,
|
||||
description: heroDesc,
|
||||
primary_cta: heroCta1,
|
||||
secondary_cta_text: heroCta2,
|
||||
image_url: (heroRemoteUrl ?? "").trim(),
|
||||
},
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Diajukan untuk persetujuan",
|
||||
text: "Perubahan Hero ada di My Content.",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
|
|
@ -316,6 +371,50 @@ export default function ContentWebsite() {
|
|||
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
||||
return;
|
||||
}
|
||||
if (contributorMode && editMode) {
|
||||
if (aboutPendingFile) {
|
||||
await Swal.fire({
|
||||
icon: "info",
|
||||
title: "Gunakan URL media",
|
||||
text: "Sebagai kontributor, unggah file tidak didukung. Gunakan URL dari Media Library.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "about",
|
||||
title: `About: ${aboutPrimary.slice(0, 80)}`,
|
||||
payload: {
|
||||
about_id: aboutId ?? undefined,
|
||||
about_media_image_id: aboutMediaImageId ?? undefined,
|
||||
primary_title: aboutPrimary,
|
||||
secondary_title: aboutSecondary,
|
||||
description: aboutDesc,
|
||||
primary_cta: aboutCta1,
|
||||
secondary_cta_text: aboutCta2,
|
||||
media_url: (aboutRemoteMediaUrl ?? "").trim(),
|
||||
},
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Diajukan untuk persetujuan",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
|
|
@ -410,6 +509,51 @@ export default function ContentWebsite() {
|
|||
await Swal.fire({ icon: "warning", title: "Product title is required" });
|
||||
return;
|
||||
}
|
||||
if (contributorMode && editMode) {
|
||||
if (productPendingFile) {
|
||||
await Swal.fire({
|
||||
icon: "info",
|
||||
title: "Gunakan URL gambar",
|
||||
text: "Sebagai kontributor, gunakan URL dari Media Library untuk gambar produk.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "product",
|
||||
title: `Product: ${productPrimary.slice(0, 80)}`,
|
||||
payload: {
|
||||
product_id: productEditId ?? "",
|
||||
product_image_id: productImageId ?? "",
|
||||
primary_title: productPrimary,
|
||||
secondary_title: productSecondary,
|
||||
description: productDesc,
|
||||
link_url: productLinkUrl,
|
||||
image_url: (productRemoteUrl ?? "").trim(),
|
||||
},
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Diajukan untuk persetujuan",
|
||||
timer: 1800,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
beginEditProduct(null);
|
||||
setProductModalOpen(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
|
|
@ -471,6 +615,38 @@ export default function ContentWebsite() {
|
|||
showCancelButton: true,
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
if (contributorMode && editMode) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "product",
|
||||
title: `Delete product ${id}`,
|
||||
payload: { action: "delete", product_id: id },
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Penghapusan diajukan",
|
||||
timer: 1600,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
if (productEditId === id) {
|
||||
beginEditProduct(null);
|
||||
setProductModalOpen(false);
|
||||
}
|
||||
await loadAll();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteOurProductContent(id);
|
||||
|
|
@ -524,6 +700,51 @@ export default function ContentWebsite() {
|
|||
await Swal.fire({ icon: "warning", title: "Service title is required" });
|
||||
return;
|
||||
}
|
||||
if (contributorMode && editMode) {
|
||||
if (servicePendingFile) {
|
||||
await Swal.fire({
|
||||
icon: "info",
|
||||
title: "Gunakan URL gambar",
|
||||
text: "Sebagai kontributor, gunakan URL dari Media Library untuk gambar layanan.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "service",
|
||||
title: `Service: ${servicePrimary.slice(0, 80)}`,
|
||||
payload: {
|
||||
service_id: serviceEditId ?? undefined,
|
||||
service_image_id: serviceImageId ?? "",
|
||||
primary_title: servicePrimary,
|
||||
secondary_title: serviceSecondary,
|
||||
description: serviceDesc,
|
||||
link_url: serviceLinkUrl,
|
||||
image_url: (serviceRemoteUrl ?? "").trim(),
|
||||
},
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Diajukan untuk persetujuan",
|
||||
timer: 1800,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
beginEditService(null);
|
||||
setServiceModalOpen(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
|
|
@ -584,6 +805,38 @@ export default function ContentWebsite() {
|
|||
showCancelButton: true,
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
if (contributorMode && editMode) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "service",
|
||||
title: `Delete service ${id}`,
|
||||
payload: { action: "delete", service_id: id },
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Penghapusan diajukan",
|
||||
timer: 1600,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
if (serviceEditId === id) {
|
||||
beginEditService(null);
|
||||
setServiceModalOpen(false);
|
||||
}
|
||||
await loadAll();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteOurServiceContent(id);
|
||||
|
|
@ -630,6 +883,48 @@ export default function ContentWebsite() {
|
|||
await Swal.fire({ icon: "warning", title: "Partner name is required" });
|
||||
return;
|
||||
}
|
||||
if (contributorMode && editMode) {
|
||||
if (partnerPendingFile) {
|
||||
await Swal.fire({
|
||||
icon: "info",
|
||||
title: "Gunakan URL logo",
|
||||
text: "Sebagai kontributor, gunakan URL logo dari Media Library (isi URL gambar).",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "partner",
|
||||
title: `Partner: ${partnerTitle.slice(0, 80)}`,
|
||||
payload: {
|
||||
partner_id: editingPartnerId ?? "",
|
||||
primary_title: partnerTitle.trim(),
|
||||
image_path: partnerStoredPath,
|
||||
image_url: (partnerRemoteUrl ?? "").trim(),
|
||||
},
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Diajukan untuk persetujuan",
|
||||
timer: 1800,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
beginEditPartner(null);
|
||||
setPartnerModalOpen(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
|
|
@ -686,6 +981,38 @@ export default function ContentWebsite() {
|
|||
showCancelButton: true,
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
if (contributorMode && editMode) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "partner",
|
||||
title: `Delete partner ${id}`,
|
||||
payload: { action: "delete", partner_id: id },
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Penghapusan diajukan",
|
||||
timer: 1600,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
if (editingPartnerId === id) {
|
||||
beginEditPartner(null);
|
||||
setPartnerModalOpen(false);
|
||||
}
|
||||
await loadAll();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await deletePartnerContent(id);
|
||||
|
|
@ -738,6 +1065,51 @@ export default function ContentWebsite() {
|
|||
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
||||
return;
|
||||
}
|
||||
if (contributorMode && editMode) {
|
||||
if (popupPendingFile) {
|
||||
await Swal.fire({
|
||||
icon: "info",
|
||||
title: "Gunakan URL gambar",
|
||||
text: "Sebagai kontributor, gunakan URL banner dari Media Library.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "popup",
|
||||
title: `Pop-up: ${popupPrimary.slice(0, 80)}`,
|
||||
payload: {
|
||||
popup_id: popupEditId ?? undefined,
|
||||
primary_title: popupPrimary,
|
||||
secondary_title: popupSecondary,
|
||||
description: popupDesc,
|
||||
primary_cta: popupCta1,
|
||||
secondary_cta_text: popupCta2,
|
||||
media_url: (popupRemoteUrl ?? "").trim(),
|
||||
},
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Diajukan untuk persetujuan",
|
||||
timer: 1800,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
beginEditPopup(null);
|
||||
setPopupModalOpen(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
|
|
@ -803,6 +1175,38 @@ export default function ContentWebsite() {
|
|||
showCancelButton: true,
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
if (contributorMode && editMode) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await submitCmsContentSubmission({
|
||||
domain: "popup",
|
||||
title: `Delete popup ${id}`,
|
||||
payload: { action: "delete", popup_id: id },
|
||||
});
|
||||
if ((res as { error?: boolean })?.error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: "Pengajuan gagal",
|
||||
text: String((res as { message?: unknown })?.message ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "Penghapusan diajukan",
|
||||
timer: 1600,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
if (popupEditId === id) {
|
||||
beginEditPopup(null);
|
||||
setPopupModalOpen(false);
|
||||
}
|
||||
await loadAll();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await deletePopupNews(id);
|
||||
|
|
@ -852,13 +1256,32 @@ export default function ContentWebsite() {
|
|||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button type="button" className="rounded-lg bg-blue-600 hover:bg-blue-700" disabled>
|
||||
{contributorMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
className={`rounded-lg ${editMode ? "bg-amber-600 hover:bg-amber-700" : "bg-blue-600 hover:bg-blue-700"}`}
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit Mode
|
||||
{editMode ? "Exit Edit Mode" : "Edit Mode"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contributorMode && !editMode ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
Aktifkan <strong>Edit Mode</strong> untuk mengusulkan perubahan. Perubahan akan masuk ke{" "}
|
||||
<strong>My Content</strong> menunggu persetujuan approver. Unggah file gambar tidak tersedia sebagai kontributor;
|
||||
gunakan URL dari Media Library pada bidang yang disediakan.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={
|
||||
canInteract ? "" : "pointer-events-none select-none opacity-50"
|
||||
}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="h-auto flex-wrap rounded-xl border bg-white p-1">
|
||||
<TabsTrigger value="hero" className="rounded-lg">
|
||||
|
|
@ -922,8 +1345,19 @@ export default function ContentWebsite() {
|
|||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Hero image</label>
|
||||
<p className="mb-2 text-xs text-slate-500">
|
||||
Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero.
|
||||
{contributorMode
|
||||
? "Tempel URL gambar dari Media Library (kontributor tidak dapat mengunggah file langsung)."
|
||||
: "Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero."}
|
||||
</p>
|
||||
{contributorMode ? (
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
value={heroRemoteUrl}
|
||||
onChange={(e) => setHeroRemoteUrl(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="mt-1 cursor-pointer"
|
||||
type="file"
|
||||
|
|
@ -934,6 +1368,7 @@ export default function ContentWebsite() {
|
|||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{heroBlobUrlRef.current || heroRemoteUrl ? (
|
||||
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
|
@ -1015,8 +1450,19 @@ export default function ContentWebsite() {
|
|||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Media (image or video)</label>
|
||||
<p className="mb-2 text-xs text-slate-500">
|
||||
Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page.
|
||||
{contributorMode
|
||||
? "Tempel URL gambar atau video dari Media Library."
|
||||
: "Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page."}
|
||||
</p>
|
||||
{contributorMode ? (
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
value={aboutRemoteMediaUrl}
|
||||
onChange={(e) => setAboutRemoteMediaUrl(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="mt-1 cursor-pointer"
|
||||
type="file"
|
||||
|
|
@ -1027,6 +1473,7 @@ export default function ContentWebsite() {
|
|||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{aboutBlobUrlRef.current || aboutRemoteMediaUrl ? (
|
||||
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
||||
{aboutPendingFile?.type.startsWith("video/") ||
|
||||
|
|
@ -1253,6 +1700,15 @@ export default function ContentWebsite() {
|
|||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Card image</label>
|
||||
{contributorMode ? (
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="url"
|
||||
placeholder="Image URL from Media Library"
|
||||
value={productRemoteUrl}
|
||||
onChange={(e) => setProductRemoteUrl(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="mt-2 cursor-pointer"
|
||||
type="file"
|
||||
|
|
@ -1263,6 +1719,7 @@ export default function ContentWebsite() {
|
|||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{productBlobUrlRef.current || productRemoteUrl ? (
|
||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
|
@ -1433,6 +1890,15 @@ export default function ContentWebsite() {
|
|||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Banner image</label>
|
||||
{contributorMode ? (
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="url"
|
||||
placeholder="Image URL from Media Library"
|
||||
value={serviceRemoteUrl}
|
||||
onChange={(e) => setServiceRemoteUrl(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="mt-2 cursor-pointer"
|
||||
type="file"
|
||||
|
|
@ -1443,6 +1909,7 @@ export default function ContentWebsite() {
|
|||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{serviceBlobUrlRef.current || serviceRemoteUrl ? (
|
||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
|
@ -1583,6 +2050,15 @@ export default function ContentWebsite() {
|
|||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Logo image</label>
|
||||
{contributorMode ? (
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="url"
|
||||
placeholder="Logo URL from Media Library"
|
||||
value={partnerRemoteUrl}
|
||||
onChange={(e) => setPartnerRemoteUrl(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="mt-2 cursor-pointer"
|
||||
type="file"
|
||||
|
|
@ -1593,6 +2069,7 @@ export default function ContentWebsite() {
|
|||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{partnerBlobUrlRef.current || partnerRemoteUrl ? (
|
||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
|
@ -1766,6 +2243,15 @@ export default function ContentWebsite() {
|
|||
<label className="text-sm font-medium text-slate-700">
|
||||
Banner image (optional)
|
||||
</label>
|
||||
{contributorMode ? (
|
||||
<Input
|
||||
className="mt-2"
|
||||
type="url"
|
||||
placeholder="Image URL from Media Library"
|
||||
value={popupRemoteUrl}
|
||||
onChange={(e) => setPopupRemoteUrl(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="mt-2 cursor-pointer"
|
||||
type="file"
|
||||
|
|
@ -1776,6 +2262,7 @@ export default function ContentWebsite() {
|
|||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{popupBlobUrlRef.current || popupRemoteUrl ? (
|
||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
|
@ -1811,5 +2298,6 @@ export default function ContentWebsite() {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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>
|
||||
|
||||
{/* ================= 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>
|
||||
{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>
|
||||
|
||||
<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.type)}>{file.type}</Badge>
|
||||
<span>{file.size}</span>
|
||||
<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.date}</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,95 +4,283 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, Filter } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { Search, Filter, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { formatDate } from "@/utils/global";
|
||||
import { listCmsContentSubmissions } from "@/service/cms-content-submissions";
|
||||
import { getArticlesForMyContent } from "@/service/article";
|
||||
import { apiPayload } from "@/service/cms-landing";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const stats = [
|
||||
{ title: "Total Content", value: 24, color: "bg-blue-500" },
|
||||
{ title: "Drafts", value: 8, color: "bg-slate-600" },
|
||||
{ title: "Pending", value: 10, color: "bg-yellow-500" },
|
||||
{ title: "Approved", value: 10, color: "bg-green-600" },
|
||||
{ title: "Revision/Rejected", value: 6, color: "bg-red-600" },
|
||||
];
|
||||
const PLACEHOLDER_IMG =
|
||||
"https://placehold.co/400x240/f1f5f9/64748b?text=Content";
|
||||
|
||||
const contents = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Bharatu Mardi Hadji Gugur Saat Bertugas...",
|
||||
image: "/image/bharatu.jpg",
|
||||
status: "Pending",
|
||||
category: "News",
|
||||
date: "2024-01-20",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Novita Hardini: Jangan Sampai Pariwisata...",
|
||||
image: "/image/novita2.png",
|
||||
status: "Approved",
|
||||
category: "News",
|
||||
date: "2024-01-20",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Lestari Moerdijat: Butuh Afirmasi...",
|
||||
image: "/image/lestari2.png",
|
||||
status: "Rejected",
|
||||
category: "News",
|
||||
date: "2024-01-20",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Lestari Moerdijat: Butuh Afirmasi...",
|
||||
image: "/image/lestari2.png",
|
||||
status: "Draft",
|
||||
category: "News",
|
||||
date: "2024-01-20",
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case "Pending Approval":
|
||||
return "bg-yellow-100 text-yellow-700 border border-yellow-200";
|
||||
|
||||
case "Approved":
|
||||
return "bg-green-100 text-green-700 border border-green-200";
|
||||
|
||||
case "Rejected":
|
||||
return "bg-red-100 text-red-700 border border-red-200";
|
||||
|
||||
case "Draft":
|
||||
return "bg-slate-100 text-slate-600 border border-slate-200";
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
type CmsRow = {
|
||||
id: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
status: string;
|
||||
submitter_name: string;
|
||||
submitted_by_id: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type ArticleRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
thumbnailUrl?: string;
|
||||
isDraft?: boolean;
|
||||
isPublish?: boolean;
|
||||
publishStatus?: string;
|
||||
statusId?: number | null;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
createdByName?: string;
|
||||
};
|
||||
|
||||
type UnifiedStatus = "draft" | "pending" | "approved" | "rejected";
|
||||
|
||||
type UnifiedItem = {
|
||||
key: string;
|
||||
source: "website" | "news";
|
||||
title: string;
|
||||
thumb: string;
|
||||
status: UnifiedStatus;
|
||||
statusLabel: string;
|
||||
date: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
function cmsToUnified(r: CmsRow): UnifiedItem {
|
||||
const st = (r.status || "").toLowerCase();
|
||||
let status: UnifiedStatus = "pending";
|
||||
let statusLabel = "Pending Approval";
|
||||
if (st === "approved") {
|
||||
status = "approved";
|
||||
statusLabel = "Approved";
|
||||
} else if (st === "rejected") {
|
||||
status = "rejected";
|
||||
statusLabel = "Rejected";
|
||||
} else if (st === "pending") {
|
||||
status = "pending";
|
||||
statusLabel = "Pending Approval";
|
||||
}
|
||||
return {
|
||||
key: `cms-${r.id}`,
|
||||
source: "website",
|
||||
title: r.title,
|
||||
thumb: PLACEHOLDER_IMG,
|
||||
status,
|
||||
statusLabel,
|
||||
date: r.created_at,
|
||||
href: "/admin/content-website",
|
||||
};
|
||||
}
|
||||
|
||||
function articleHistoryStatus(a: ArticleRow): UnifiedStatus {
|
||||
if (a.isDraft) return "draft";
|
||||
if (a.isPublish) return "approved";
|
||||
const ps = (a.publishStatus || "").toLowerCase();
|
||||
if (ps.includes("reject")) return "rejected";
|
||||
if (a.statusId === 3) return "rejected";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function articleStatusLabel(s: UnifiedStatus): string {
|
||||
switch (s) {
|
||||
case "draft":
|
||||
return "Draft";
|
||||
case "pending":
|
||||
return "Pending Approval";
|
||||
case "approved":
|
||||
return "Approved";
|
||||
case "rejected":
|
||||
return "Rejected";
|
||||
default:
|
||||
return "Pending Approval";
|
||||
}
|
||||
}
|
||||
|
||||
function articleToUnified(r: ArticleRow): UnifiedItem {
|
||||
const status = articleHistoryStatus(r);
|
||||
const rawDate = r.createdAt ?? r.created_at ?? "";
|
||||
return {
|
||||
key: `art-${r.id}`,
|
||||
source: "news",
|
||||
title: r.title || "Untitled",
|
||||
thumb: r.thumbnailUrl?.trim() || PLACEHOLDER_IMG,
|
||||
status,
|
||||
statusLabel: articleStatusLabel(status),
|
||||
date: rawDate,
|
||||
href: `/admin/news-article/detail/${r.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: UnifiedStatus): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
||||
case "approved":
|
||||
return "bg-green-100 text-green-800 border-green-200";
|
||||
case "rejected":
|
||||
return "bg-red-100 text-red-800 border-red-200";
|
||||
case "draft":
|
||||
default:
|
||||
return "bg-slate-100 text-slate-700 border-slate-200";
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
export default function MyContent() {
|
||||
const [levelId, setLevelId] = useState<string | undefined>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState<"all" | "news" | "website">(
|
||||
"all",
|
||||
);
|
||||
const [statusFilter, setStatusFilter] = useState<
|
||||
"all" | UnifiedStatus
|
||||
>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cmsRows, setCmsRows] = useState<CmsRow[]>([]);
|
||||
const [articleRows, setArticleRows] = useState<ArticleRow[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const isApprover = levelId === "3";
|
||||
const isContributor = levelId === "2";
|
||||
|
||||
useEffect(() => {
|
||||
setLevelId(Cookies.get("ulne"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (levelId === undefined) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const cmsMine = isContributor;
|
||||
const cmsRes = await listCmsContentSubmissions({
|
||||
status: "all",
|
||||
mine: cmsMine,
|
||||
page: 1,
|
||||
limit: 500,
|
||||
});
|
||||
const cmsData = apiPayload<CmsRow[]>(cmsRes);
|
||||
setCmsRows(Array.isArray(cmsData) ? cmsData : []);
|
||||
|
||||
const artMode = isApprover ? "approver" : "own";
|
||||
const artRes = await getArticlesForMyContent({
|
||||
mode: artMode,
|
||||
page: 1,
|
||||
limit: 500,
|
||||
});
|
||||
const artData = apiPayload<ArticleRow[]>(artRes);
|
||||
setArticleRows(Array.isArray(artData) ? artData : []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [levelId, isContributor, isApprover]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const mergedAll = useMemo(() => {
|
||||
const cms = cmsRows.map(cmsToUnified);
|
||||
const arts = articleRows.map(articleToUnified);
|
||||
let list = [...cms, ...arts];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(x) =>
|
||||
x.title.toLowerCase().includes(q) ||
|
||||
x.statusLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (sourceFilter === "news") list = list.filter((x) => x.source === "news");
|
||||
if (sourceFilter === "website")
|
||||
list = list.filter((x) => x.source === "website");
|
||||
if (statusFilter !== "all")
|
||||
list = list.filter((x) => x.status === statusFilter);
|
||||
list.sort((a, b) => (a.date < b.date ? 1 : -1));
|
||||
return list;
|
||||
}, [cmsRows, articleRows, debouncedSearch, sourceFilter, statusFilter]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const draft = mergedAll.filter((x) => x.status === "draft").length;
|
||||
const pending = mergedAll.filter((x) => x.status === "pending").length;
|
||||
const approved = mergedAll.filter((x) => x.status === "approved").length;
|
||||
const rejected = mergedAll.filter((x) => x.status === "rejected").length;
|
||||
return {
|
||||
total: mergedAll.length,
|
||||
draft,
|
||||
pending,
|
||||
approved,
|
||||
rejected,
|
||||
};
|
||||
}, [mergedAll]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(mergedAll.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageItems = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return mergedAll.slice(start, start + PAGE_SIZE);
|
||||
}, [mergedAll, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [sourceFilter, statusFilter, debouncedSearch, levelId]);
|
||||
|
||||
if (levelId === undefined) {
|
||||
return (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">My Content</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Track all your content submissions and drafts
|
||||
<h1 className="text-2xl font-semibold text-slate-900">My Content</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Track all your content submissions and drafts.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2 max-w-3xl">
|
||||
{isContributor
|
||||
? "Riwayat konten Anda: perubahan Content Website (setelah diajukan) dan artikel. Buka item untuk mengedit atau melanjutkan persetujuan di halaman masing-masing."
|
||||
: isApprover
|
||||
? "Riwayat dari kontributor: Content Website dan News & Articles (tanpa draft). Persetujuan dilakukan di halaman Content Website atau detail artikel."
|
||||
: "Ringkasan konten terkait akun Anda."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{stats.map((item, index) => (
|
||||
<Card key={index} className="rounded-2xl shadow-sm">
|
||||
{[
|
||||
{ title: "Total Content", value: stats.total, color: "bg-blue-500" },
|
||||
{ title: "Drafts", value: stats.draft, color: "bg-slate-600" },
|
||||
{ title: "Pending", value: stats.pending, color: "bg-yellow-500" },
|
||||
{ title: "Approved", value: stats.approved, color: "bg-green-600" },
|
||||
{
|
||||
title: "Revision/Rejected",
|
||||
value: stats.rejected,
|
||||
color: "bg-red-600",
|
||||
},
|
||||
].map((item) => (
|
||||
<Card key={item.title} className="rounded-2xl shadow-sm">
|
||||
<CardContent className="p-5 flex items-center gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center text-white ${item.color}`}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shrink-0 ${item.color}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-semibold">{item.value}</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{item.value}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.title}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -104,83 +292,136 @@ export default function MyContent() {
|
|||
<div className="relative w-full md:max-w-md">
|
||||
<Search className="absolute left-3 top-3 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search media files..."
|
||||
placeholder="Search by title…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white px-3 py-2">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<select
|
||||
className="text-sm bg-transparent border-none outline-none"
|
||||
value={sourceFilter}
|
||||
onChange={(e) =>
|
||||
setSourceFilter(e.target.value as typeof sourceFilter)
|
||||
}
|
||||
>
|
||||
<option value="all">All sources</option>
|
||||
<option value="news">News & Articles</option>
|
||||
<option value="website">Content Website</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white px-3 py-2">
|
||||
<select
|
||||
className="text-sm bg-transparent border-none outline-none"
|
||||
value={statusFilter}
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value as typeof statusFilter)
|
||||
}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending">Pending Approval</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => load()}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-[#966314]" />
|
||||
</div>
|
||||
) : pageItems.length === 0 ? (
|
||||
<p className="text-center text-slate-500 py-16">No content found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{contents.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className="rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 bg-white"
|
||||
{pageItems.map((item) => (
|
||||
<Link key={item.key} href={item.href} className="block group">
|
||||
<Card className="rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 bg-white h-full overflow-hidden">
|
||||
<div className="flex items-center gap-1 flex-wrap px-3 pt-3">
|
||||
<Badge
|
||||
className={`${statusBadgeClass(item.status)} border font-medium`}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-3">
|
||||
<Badge className={getStatusStyle(item.status)}>
|
||||
{item.status}
|
||||
{item.statusLabel}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-blue-600 border-blue-200 bg-blue-50"
|
||||
>
|
||||
{item.category}
|
||||
{item.source === "news"
|
||||
? "News & Articles"
|
||||
: "Content Website"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-50 overflow-hidden">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
<div className="relative w-full aspect-[4/3] mt-2 bg-slate-100">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.thumb}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-4">
|
||||
{/* TITLE */}
|
||||
<h3 className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
<CardContent className="px-4 pb-4 pt-3">
|
||||
<h3 className="text-sm font-semibold leading-snug line-clamp-2 text-slate-900 group-hover:text-blue-700">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-slate-500">{item.date}</p>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
{item.date ? formatDate(item.date) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<Button variant="outline" size="sm" className="rounded-lg">
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-3 pt-6">
|
||||
<p className="text-sm text-slate-500">
|
||||
Showing {(currentPage - 1) * PAGE_SIZE + 1} to{" "}
|
||||
{Math.min(currentPage * PAGE_SIZE, mergedAll.length)} of{" "}
|
||||
{mergedAll.length} items
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-slate-600 px-2">
|
||||
Page {currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="rounded-lg">
|
||||
2
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="rounded-lg">
|
||||
3
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="rounded-lg">
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,44 @@ export async function getArticlePagination(props: PaginationRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Articles in the approval queue for the current user level (requires auth). */
|
||||
export async function getArticlesPendingApproval(props: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
typeId?: number;
|
||||
}) {
|
||||
const page = props.page ?? 1;
|
||||
const limit = props.limit ?? 20;
|
||||
const typeParam =
|
||||
props.typeId !== undefined && props.typeId !== null
|
||||
? `&typeId=${props.typeId}`
|
||||
: "";
|
||||
return await httpGetInterceptor(
|
||||
`/articles/pending-approval?page=${page}&limit=${limit}${typeParam}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* My Content history: own = all articles by current user; approver = non-draft
|
||||
* articles created by contributors (user level 2). Requires auth.
|
||||
*/
|
||||
export async function getArticlesForMyContent(props: {
|
||||
mode: "own" | "approver";
|
||||
page?: number;
|
||||
limit?: number;
|
||||
title?: string;
|
||||
}) {
|
||||
const page = props.page ?? 1;
|
||||
const limit = props.limit ?? 200;
|
||||
const titleQ =
|
||||
props.title && props.title.trim()
|
||||
? `&title=${encodeURIComponent(props.title.trim())}`
|
||||
: "";
|
||||
return await httpGetInterceptor(
|
||||
`/articles?myContentMode=${props.mode}&page=${page}&limit=${limit}&sort=desc&sortBy=created_at${titleQ}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTopArticles(props: PaginationRequest) {
|
||||
const { page, limit, search, startDate, endDate, isPublish, category } =
|
||||
props;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
httpGetInterceptor,
|
||||
httpPostInterceptor,
|
||||
} from "./http-config/http-interceptor-services";
|
||||
|
||||
export async function submitCmsContentSubmission(body: {
|
||||
domain: string;
|
||||
title: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
return httpPostInterceptor("/cms-content-submissions", body);
|
||||
}
|
||||
|
||||
export async function listCmsContentSubmissions(params?: {
|
||||
/** Omit or `all` for every status (history). */
|
||||
status?: string;
|
||||
mine?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.status != null && params.status !== "") {
|
||||
q.set("status", params.status);
|
||||
}
|
||||
if (params?.mine) q.set("mine", "1");
|
||||
if (params?.page) q.set("page", String(params.page));
|
||||
if (params?.limit) q.set("limit", String(params.limit));
|
||||
const qs = q.toString();
|
||||
return httpGetInterceptor(
|
||||
`/cms-content-submissions${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function approveCmsContentSubmission(id: string) {
|
||||
return httpPostInterceptor(`/cms-content-submissions/${id}/approve`, {});
|
||||
}
|
||||
|
||||
export async function rejectCmsContentSubmission(id: string, note?: string) {
|
||||
return httpPostInterceptor(`/cms-content-submissions/${id}/reject`, {
|
||||
note: note ?? "",
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
Loading…
Reference in New Issue