feat: update news & article in admin & landing page
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
b6fa161efb
commit
c55ac796aa
|
|
@ -0,0 +1,9 @@
|
||||||
|
import CreateArticleForm from "@/components/form/article/create-article-form";
|
||||||
|
|
||||||
|
export default function CreateNewsAudioPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto min-h-full">
|
||||||
|
<CreateArticleForm contentKind="audio" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import NewsArticleList from "@/components/main/news-article-list";
|
||||||
|
import { ARTICLE_TYPE } from "@/constants/article-content-types";
|
||||||
|
|
||||||
|
export default function NewsArticleAudioPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<NewsArticleList kind="audio" typeId={ARTICLE_TYPE.AUDIO} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import EditArticleForm from "@/components/form/article/edit-article-form";
|
||||||
|
|
||||||
|
export default function NewsArticleDetailPage() {
|
||||||
|
return (
|
||||||
|
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
|
||||||
|
<EditArticleForm isDetail={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import CreateImageForm from "@/components/form/article/create-image-form";
|
import CreateArticleForm from "@/components/form/article/create-article-form";
|
||||||
|
|
||||||
export default function CreateNewsImage() {
|
export default function CreateNewsImagePage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto">
|
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto min-h-full">
|
||||||
<CreateImageForm />
|
<CreateArticleForm contentKind="image" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import NewsImage from "@/components/main/news-image";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import NewsArticleList from "@/components/main/news-article-list";
|
||||||
|
import { ARTICLE_TYPE } from "@/constants/article-content-types";
|
||||||
|
|
||||||
export default function ImagePage() {
|
export default function NewsArticleImagePage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50 flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
|
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<NewsImage />
|
<NewsArticleList kind="image" typeId={ARTICLE_TYPE.IMAGE} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import CreateArticleForm from "@/components/form/article/create-article-form";
|
||||||
|
|
||||||
|
export default function CreateNewsTextPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto min-h-full">
|
||||||
|
<CreateArticleForm contentKind="text" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import NewsArticleList from "@/components/main/news-article-list";
|
||||||
|
import { ARTICLE_TYPE } from "@/constants/article-content-types";
|
||||||
|
|
||||||
|
export default function NewsArticleTextPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<NewsArticleList kind="text" typeId={ARTICLE_TYPE.TEXT} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import CreateArticleForm from "@/components/form/article/create-article-form";
|
||||||
|
|
||||||
|
export default function CreateNewsVideoPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto min-h-full">
|
||||||
|
<CreateArticleForm contentKind="video" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import NewsArticleList from "@/components/main/news-article-list";
|
||||||
|
import { ARTICLE_TYPE } from "@/constants/article-content-types";
|
||||||
|
|
||||||
|
export default function NewsArticleVideoPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<NewsArticleList kind="video" typeId={ARTICLE_TYPE.VIDEO} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,85 +1,15 @@
|
||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import ArticleTypeFilterPage from "@/components/content-type/article-type-filter-page";
|
||||||
import AudioCard from "@/components/audio/audio-card";
|
|
||||||
import FilterAudioSidebar from "@/components/audio/filter-sidebar";
|
import FilterAudioSidebar from "@/components/audio/filter-sidebar";
|
||||||
import FloatingMenuNews from "@/components/landing-page/floating-news";
|
import { CONTENT_TYPE_FILTER } from "@/constants/content-type-pages";
|
||||||
import Footer from "@/components/landing-page/footer";
|
|
||||||
import FilterSidebar from "@/components/video/filter-sidebar";
|
|
||||||
import VideoCard from "@/components/video/video-card";
|
|
||||||
import { Menu } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export default function AudioFilterPage() {
|
export default function AudioFilterPage() {
|
||||||
const [openFilter, setOpenFilter] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
|
<Suspense fallback={<div className="min-h-screen bg-white" />}>
|
||||||
<section className=" min-h-screen py-10">
|
<ArticleTypeFilterPage
|
||||||
<div className="container mx-auto px-6">
|
config={CONTENT_TYPE_FILTER.audio}
|
||||||
{/* ===== TOP BAR ===== */}
|
sidebar={<FilterAudioSidebar />}
|
||||||
|
/>
|
||||||
{/* ===== CONTENT ===== */}
|
</Suspense>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-8">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<FilterAudioSidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Sidebar */}
|
|
||||||
{openFilter && (
|
|
||||||
<div className="fixed inset-0 bg-black/40 z-50">
|
|
||||||
<div className="absolute left-0 top-0 h-full w-[280px] bg-white p-6 overflow-y-auto">
|
|
||||||
<FilterAudioSidebar />
|
|
||||||
<button
|
|
||||||
onClick={() => setOpenFilter(false)}
|
|
||||||
className="mt-4 text-sm text-[#966314]"
|
|
||||||
>
|
|
||||||
Tutup
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cards */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Audio >
|
|
||||||
<span className="font-semibold text-black">Lihat Semua</span>
|
|
||||||
<span className="font-semibold text-black ml-3">{"|"}</span>
|
|
||||||
<span className="ml-4 text-gray-400">
|
|
||||||
Terdapat 1636 berita
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm">Urutkan:</span>
|
|
||||||
<select className="border rounded-md px-3 py-2 text-sm mr-20">
|
|
||||||
<option>Terpopuler</option>
|
|
||||||
<option>Terbaru</option>
|
|
||||||
</select>
|
|
||||||
<FloatingMenuNews />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<AudioCard key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== PAGINATION ===== */}
|
|
||||||
<div className="flex justify-center items-center gap-3 mt-12 text-sm">
|
|
||||||
<button className="px-3 py-1 bg-black text-white rounded">1</button>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">2</button>
|
|
||||||
<span>...</span>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">4</button>
|
|
||||||
<button className="ml-4">Selanjutnya ></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,15 @@
|
||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import ArticleTypeFilterPage from "@/components/content-type/article-type-filter-page";
|
||||||
import DocumentCard from "@/components/document/document-card";
|
|
||||||
import FilterDocumentSidebar from "@/components/document/filter-sidebar";
|
import FilterDocumentSidebar from "@/components/document/filter-sidebar";
|
||||||
import FloatingMenuNews from "@/components/landing-page/floating-news";
|
import { CONTENT_TYPE_FILTER } from "@/constants/content-type-pages";
|
||||||
import Footer from "@/components/landing-page/footer";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export default function DocumentFilterPage() {
|
export default function DocumentFilterPage() {
|
||||||
const [openFilter, setOpenFilter] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
|
<Suspense fallback={<div className="min-h-screen bg-white" />}>
|
||||||
<section className=" min-h-screen py-10">
|
<ArticleTypeFilterPage
|
||||||
<div className="container mx-auto px-6">
|
config={CONTENT_TYPE_FILTER.document}
|
||||||
{/* ===== TOP BAR ===== */}
|
sidebar={<FilterDocumentSidebar />}
|
||||||
|
/>
|
||||||
{/* ===== CONTENT ===== */}
|
</Suspense>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-8">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<FilterDocumentSidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Sidebar */}
|
|
||||||
{openFilter && (
|
|
||||||
<div className="fixed inset-0 bg-black/40 z-50">
|
|
||||||
<div className="absolute left-0 top-0 h-full w-[280px] bg-white p-6 overflow-y-auto">
|
|
||||||
<FilterDocumentSidebar />
|
|
||||||
<button
|
|
||||||
onClick={() => setOpenFilter(false)}
|
|
||||||
className="mt-4 text-sm text-[#966314]"
|
|
||||||
>
|
|
||||||
Tutup
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cards */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Document >
|
|
||||||
<span className="font-semibold text-black">Lihat Semua</span>
|
|
||||||
<span className="font-semibold text-black ml-3">{"|"}</span>
|
|
||||||
<span className="ml-4 text-gray-400">
|
|
||||||
Terdapat 1636 berita
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm">Urutkan:</span>
|
|
||||||
<select className="border rounded-md px-3 py-2 text-sm mr-20">
|
|
||||||
<option>Terpopuler</option>
|
|
||||||
<option>Terbaru</option>
|
|
||||||
</select>
|
|
||||||
<FloatingMenuNews />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<DocumentCard key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== PAGINATION ===== */}
|
|
||||||
<div className="flex justify-center items-center gap-3 mt-12 text-sm">
|
|
||||||
<button className="px-3 py-1 bg-black text-white rounded">1</button>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">2</button>
|
|
||||||
<span>...</span>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">4</button>
|
|
||||||
<button className="ml-4">Selanjutnya ></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,15 @@
|
||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import ArticleTypeFilterPage from "@/components/content-type/article-type-filter-page";
|
||||||
import AudioCard from "@/components/audio/audio-card";
|
|
||||||
import FilterAudioSidebar from "@/components/audio/filter-sidebar";
|
|
||||||
import FilterImageSidebar from "@/components/image/filter-sidebar";
|
import FilterImageSidebar from "@/components/image/filter-sidebar";
|
||||||
import ImageCard from "@/components/image/image-card";
|
import { CONTENT_TYPE_FILTER } from "@/constants/content-type-pages";
|
||||||
import FloatingMenuNews from "@/components/landing-page/floating-news";
|
|
||||||
import Footer from "@/components/landing-page/footer";
|
|
||||||
import FilterSidebar from "@/components/video/filter-sidebar";
|
|
||||||
import VideoCard from "@/components/video/video-card";
|
|
||||||
import { Menu } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export default function ImageFilterPage() {
|
export default function ImageFilterPage() {
|
||||||
const [openFilter, setOpenFilter] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
|
<Suspense fallback={<div className="min-h-screen bg-white" />}>
|
||||||
<section className=" min-h-screen py-10">
|
<ArticleTypeFilterPage
|
||||||
<div className="container mx-auto px-6">
|
config={CONTENT_TYPE_FILTER.image}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-8">
|
sidebar={<FilterImageSidebar />}
|
||||||
<div className="hidden lg:block">
|
/>
|
||||||
<FilterImageSidebar />
|
</Suspense>
|
||||||
</div>
|
|
||||||
|
|
||||||
{openFilter && (
|
|
||||||
<div className="fixed inset-0 bg-black/40 z-50">
|
|
||||||
<div className="absolute left-0 top-0 h-full w-[280px] bg-white p-6 overflow-y-auto">
|
|
||||||
<FilterImageSidebar />
|
|
||||||
<button
|
|
||||||
onClick={() => setOpenFilter(false)}
|
|
||||||
className="mt-4 text-sm text-[#966314]"
|
|
||||||
>
|
|
||||||
Tutup
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cards */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Foto >
|
|
||||||
<span className="font-semibold text-black">Lihat Semua</span>
|
|
||||||
<span className="font-semibold text-black ml-3">{"|"}</span>
|
|
||||||
<span className="ml-4 text-gray-400">
|
|
||||||
Terdapat 1636 berita
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm">Urutkan:</span>
|
|
||||||
<select className="border rounded-md px-3 py-2 text-sm mr-20">
|
|
||||||
<option>Terpopuler</option>
|
|
||||||
<option>Terbaru</option>
|
|
||||||
</select>
|
|
||||||
<FloatingMenuNews />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="">
|
|
||||||
{Array.from({ length: 1 }).map((_, i) => (
|
|
||||||
<ImageCard key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center items-center gap-3 mt-12 text-sm">
|
|
||||||
<button className="px-3 py-1 bg-black text-white rounded">1</button>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">2</button>
|
|
||||||
<span>...</span>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">4</button>
|
|
||||||
<button className="ml-4">Selanjutnya ></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,98 @@
|
||||||
import Footer from "@/components/landing-page/footer";
|
import Footer from "@/components/landing-page/footer";
|
||||||
import FloatingMenu from "@/components/landing-page/floating";
|
import FloatingMenuNews from "@/components/landing-page/floating-news";
|
||||||
import NewsAndServicesHeader from "@/components/landing-page/headers-news-services";
|
import NewsAndServicesHeader from "@/components/landing-page/headers-news-services";
|
||||||
import ContentLatest from "@/components/landing-page/content-latest";
|
import ContentLatest from "@/components/landing-page/content-latest";
|
||||||
import ContentPopular from "@/components/landing-page/content-popular";
|
import ContentPopular from "@/components/landing-page/content-popular";
|
||||||
import ContentCategory from "@/components/landing-page/category-content";
|
import ContentCategory from "@/components/landing-page/category-content";
|
||||||
import FloatingMenuNews from "@/components/landing-page/floating-news";
|
import {
|
||||||
|
aggregateTagStats,
|
||||||
|
fetchPublishedArticles,
|
||||||
|
type PublicArticle,
|
||||||
|
} from "@/lib/articles-public";
|
||||||
|
import {
|
||||||
|
NEWS_SERVICES_TAB_ORDER,
|
||||||
|
NEWS_TAB_TO_TYPE_ID,
|
||||||
|
type NewsServicesTab,
|
||||||
|
} from "@/constants/news-services";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
export default function NewsAndServicesPage() {
|
function emptyByTab(): Record<NewsServicesTab, PublicArticle[]> {
|
||||||
|
return {
|
||||||
|
"audio-visual": [],
|
||||||
|
audio: [],
|
||||||
|
foto: [],
|
||||||
|
teks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArticlesForTabs(options: {
|
||||||
|
sortBy: string;
|
||||||
|
sort: string;
|
||||||
|
limit: number;
|
||||||
|
title?: string;
|
||||||
|
}): Promise<Record<NewsServicesTab, PublicArticle[]>> {
|
||||||
|
const out = emptyByTab();
|
||||||
|
await Promise.all(
|
||||||
|
NEWS_SERVICES_TAB_ORDER.map(async (tab) => {
|
||||||
|
const typeId = NEWS_TAB_TO_TYPE_ID[tab];
|
||||||
|
const res = await fetchPublishedArticles({
|
||||||
|
typeId,
|
||||||
|
limit: options.limit,
|
||||||
|
sortBy: options.sortBy,
|
||||||
|
sort: options.sort,
|
||||||
|
title: options.title,
|
||||||
|
});
|
||||||
|
out[tab] = res?.items ?? [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams?: Promise<{ q?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function NewsAndServicesPage({ searchParams }: PageProps) {
|
||||||
|
const sp = searchParams ? await searchParams : {};
|
||||||
|
const q = sp.q?.trim() || undefined;
|
||||||
|
|
||||||
|
const [latestByTab, popularByTab, wideList] = await Promise.all([
|
||||||
|
loadArticlesForTabs({
|
||||||
|
sortBy: "created_at",
|
||||||
|
sort: "desc",
|
||||||
|
limit: 8,
|
||||||
|
title: q,
|
||||||
|
}),
|
||||||
|
loadArticlesForTabs({
|
||||||
|
sortBy: "view_count",
|
||||||
|
sort: "desc",
|
||||||
|
limit: 8,
|
||||||
|
title: q,
|
||||||
|
}),
|
||||||
|
fetchPublishedArticles({
|
||||||
|
limit: 100,
|
||||||
|
page: 1,
|
||||||
|
sortBy: "view_count",
|
||||||
|
sort: "desc",
|
||||||
|
title: q,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const featured = (wideList?.items ?? []).slice(0, 5);
|
||||||
|
const tagStats = aggregateTagStats(wideList?.items ?? [], 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-white">
|
<div className="relative min-h-screen bg-white">
|
||||||
<FloatingMenuNews />
|
<FloatingMenuNews />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<NewsAndServicesHeader />
|
<NewsAndServicesHeader
|
||||||
|
featured={featured}
|
||||||
|
defaultSearch={q ?? ""}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<ContentLatest />
|
<ContentLatest articlesByTab={latestByTab} />
|
||||||
<ContentPopular />
|
<ContentPopular articlesByTab={popularByTab} />
|
||||||
<ContentCategory />
|
<ContentCategory tagStats={tagStats} />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
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 ArticleThumbnail from "@/components/landing-page/article-thumbnail";
|
||||||
|
import { ARTICLE_TYPE } from "@/constants/article-content-types";
|
||||||
|
import { fetchArticlePublic } from "@/lib/articles-public";
|
||||||
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ idSlug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseIdSlug(idSlug: string): { id: number; slug: string } | null {
|
||||||
|
const match = idSlug.match(/^(\d+)-(.*)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const id = parseInt(match[1], 10);
|
||||||
|
if (Number.isNaN(id)) return null;
|
||||||
|
return { id, slug: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { idSlug } = await params;
|
||||||
|
const parsed = parseIdSlug(idSlug);
|
||||||
|
if (!parsed) return { title: "Artikel" };
|
||||||
|
const article = await fetchArticlePublic(parsed.id);
|
||||||
|
if (!article || article.isPublish !== true) {
|
||||||
|
return { title: "Artikel tidak ditemukan" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: article.title,
|
||||||
|
description:
|
||||||
|
article.description?.slice(0, 160) ||
|
||||||
|
article.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewsDetailPage({ params }: Props) {
|
||||||
|
const { idSlug } = await params;
|
||||||
|
const parsed = parseIdSlug(idSlug);
|
||||||
|
if (!parsed) notFound();
|
||||||
|
|
||||||
|
const article = await fetchArticlePublic(parsed.id);
|
||||||
|
if (!article || article.isPublish !== true) notFound();
|
||||||
|
|
||||||
|
if (article.slug !== parsed.slug) {
|
||||||
|
redirect(`/news/detail/${article.id}-${article.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLabel = formatDate(article.publishedAt || article.createdAt);
|
||||||
|
const primaryFile = article.files?.[0];
|
||||||
|
const mediaUrl = primaryFile?.fileUrl?.trim() || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen bg-white">
|
||||||
|
<FloatingMenuNews />
|
||||||
|
<article className="container mx-auto max-w-4xl px-6 py-16">
|
||||||
|
<Link
|
||||||
|
href="/news-services"
|
||||||
|
className="mb-8 inline-block text-sm font-medium text-[#b07c18] hover:underline"
|
||||||
|
>
|
||||||
|
← Kembali ke Berita & Layanan
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
|
{dateLabel}
|
||||||
|
{article.categoryName ? ` · ${article.categoryName}` : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="mb-8 text-3xl font-bold leading-tight md:text-4xl">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="relative mb-10 h-[320px] w-full overflow-hidden rounded-2xl md:h-[420px]">
|
||||||
|
<ArticleThumbnail
|
||||||
|
src={article.thumbnailUrl}
|
||||||
|
alt={article.title}
|
||||||
|
sizes="(max-width: 896px) 100vw, 896px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{article.typeId === ARTICLE_TYPE.VIDEO && mediaUrl ? (
|
||||||
|
<div className="mb-10">
|
||||||
|
<video
|
||||||
|
src={mediaUrl}
|
||||||
|
controls
|
||||||
|
className="w-full max-w-3xl rounded-xl bg-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{article.typeId === ARTICLE_TYPE.AUDIO && mediaUrl ? (
|
||||||
|
<div className="mb-10">
|
||||||
|
<audio src={mediaUrl} controls className="w-full max-w-xl" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{article.description ? (
|
||||||
|
<p className="mb-8 text-lg leading-relaxed text-gray-700">
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{article.htmlDescription ? (
|
||||||
|
<div
|
||||||
|
className="max-w-none space-y-4 text-gray-800 [&_a]:text-[#b07c18] [&_a]:underline [&_img]:max-w-full [&_img]:rounded-lg [&_p]:leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.htmlDescription }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,83 +1,15 @@
|
||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import ArticleTypeFilterPage from "@/components/content-type/article-type-filter-page";
|
||||||
import FloatingMenuNews from "@/components/landing-page/floating-news";
|
import FilterVideoSidebar from "@/components/video/filter-sidebar";
|
||||||
import Footer from "@/components/landing-page/footer";
|
import { CONTENT_TYPE_FILTER } from "@/constants/content-type-pages";
|
||||||
import FilterSidebar from "@/components/video/filter-sidebar";
|
|
||||||
import VideoCard from "@/components/video/video-card";
|
|
||||||
import { Menu } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export default function VideoFilterPage() {
|
export default function VideoFilterPage() {
|
||||||
const [openFilter, setOpenFilter] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
|
<Suspense fallback={<div className="min-h-screen bg-white" />}>
|
||||||
<section className=" min-h-screen py-10">
|
<ArticleTypeFilterPage
|
||||||
<div className="container mx-auto px-6">
|
config={CONTENT_TYPE_FILTER.video}
|
||||||
{/* ===== TOP BAR ===== */}
|
sidebar={<FilterVideoSidebar />}
|
||||||
|
/>
|
||||||
{/* ===== CONTENT ===== */}
|
</Suspense>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-8">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<FilterSidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Sidebar */}
|
|
||||||
{openFilter && (
|
|
||||||
<div className="fixed inset-0 bg-black/40 z-50">
|
|
||||||
<div className="absolute left-0 top-0 h-full w-[280px] bg-white p-6 overflow-y-auto">
|
|
||||||
<FilterSidebar />
|
|
||||||
<button
|
|
||||||
onClick={() => setOpenFilter(false)}
|
|
||||||
className="mt-4 text-sm text-[#966314]"
|
|
||||||
>
|
|
||||||
Tutup
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cards */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Audio Visual >
|
|
||||||
<span className="font-semibold text-black">Lihat Semua</span>
|
|
||||||
<span className="font-semibold text-black ml-3">{"|"}</span>
|
|
||||||
<span className="ml-4 text-gray-400">
|
|
||||||
Terdapat 1636 berita
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm">Urutkan:</span>
|
|
||||||
<select className="border rounded-md px-3 py-2 text-sm mr-20">
|
|
||||||
<option>Terpopuler</option>
|
|
||||||
<option>Terbaru</option>
|
|
||||||
</select>
|
|
||||||
<FloatingMenuNews />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<VideoCard key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== PAGINATION ===== */}
|
|
||||||
<div className="flex justify-center items-center gap-3 mt-12 text-sm">
|
|
||||||
<button className="px-3 py-1 bg-black text-white rounded">1</button>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">2</button>
|
|
||||||
<span>...</span>
|
|
||||||
<button className="px-3 py-1 bg-white border rounded">4</button>
|
|
||||||
<button className="ml-4">Selanjutnya ></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function DocumentCard() {
|
export default function AudioCard() {
|
||||||
const slug = "bharatu-mardi-hadji-gugur-saat-bertugas";
|
const slug = "bharatu-mardi-hadji-gugur-saat-bertugas";
|
||||||
return (
|
return (
|
||||||
<Link href={`/details/${slug}?type=audio`}>
|
<Link href={`/details/${slug}?type=audio`}>
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1 @@
|
||||||
"use client";
|
export { default } from "@/components/content-type/content-type-filter-sidebar";
|
||||||
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
|
||||||
|
|
||||||
export default function FilterAudioSidebar() {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
|
|
||||||
{/* HEADER */}
|
|
||||||
<div className="flex items-center justify-between pb-4 border-b">
|
|
||||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
|
||||||
Filter
|
|
||||||
</h3>
|
|
||||||
<ChevronLeft size={16} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CONTENT */}
|
|
||||||
<div className="space-y-6 mt-6">
|
|
||||||
{/* KATEGORI */}
|
|
||||||
<FilterSection title="Kategori">
|
|
||||||
<Checkbox label="Semua" count={1203} defaultChecked />
|
|
||||||
<Checkbox label="Berita Terhangat" count={123} />
|
|
||||||
<Checkbox label="Tentang Teknologi" count={24} />
|
|
||||||
<Checkbox label="Bersama Pelanggan" count={42} />
|
|
||||||
<Checkbox label="Pembicara Ahli" count={224} />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* JENIS FILE */}
|
|
||||||
<FilterSection title="Jenis File">
|
|
||||||
<Checkbox label="Semua" count={78} />
|
|
||||||
<Checkbox label="Audio Visual" count={120} />
|
|
||||||
<Checkbox label="Audio" count={34} defaultChecked />
|
|
||||||
<Checkbox label="Foto" count={234} />
|
|
||||||
<Checkbox label="Teks" count={9} />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* FORMAT */}
|
|
||||||
<FilterSection title="Format Audio ">
|
|
||||||
<Checkbox label="Semua" count={2} defaultChecked />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
{/* RESET */}
|
|
||||||
<div className="text-center pt-4">
|
|
||||||
<button className="text-sm text-[#966314] font-medium hover:underline">
|
|
||||||
Reset Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== COMPONENTS ===== */
|
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-3">{title}</p>
|
|
||||||
<div className="space-y-2">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Checkbox({
|
|
||||||
label,
|
|
||||||
count,
|
|
||||||
defaultChecked,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
defaultChecked?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex items-center justify-between text-sm cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={defaultChecked}
|
|
||||||
className="h-4 w-4 accent-[#966314]"
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-400">({count})</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Divider() {
|
|
||||||
return <div className="border-t border-gray-200"></div>;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import FloatingMenuNews from "@/components/landing-page/floating-news";
|
||||||
|
import Footer from "@/components/landing-page/footer";
|
||||||
|
import {
|
||||||
|
fetchPublishedArticles,
|
||||||
|
type PublicArticle,
|
||||||
|
} from "@/lib/articles-public";
|
||||||
|
import PublicArticleCard from "@/components/content-type/public-article-card";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
typeId: number;
|
||||||
|
breadcrumb: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
config: Config;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ArticleTypeFilterPage({ config, sidebar }: Props) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const q = searchParams.get("q")?.trim() ?? "";
|
||||||
|
const tag = searchParams.get("tag")?.trim() ?? "";
|
||||||
|
|
||||||
|
const [openFilter, setOpenFilter] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [sort, setSort] = useState<"popular" | "latest">("latest");
|
||||||
|
const [items, setItems] = useState<PublicArticle[]>([]);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const sortBy = sort === "popular" ? "view_count" : "created_at";
|
||||||
|
const res = await fetchPublishedArticles(
|
||||||
|
{
|
||||||
|
typeId: config.typeId,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
page,
|
||||||
|
sortBy,
|
||||||
|
sort: "desc",
|
||||||
|
title: q || undefined,
|
||||||
|
tags: tag || undefined,
|
||||||
|
},
|
||||||
|
{ mode: "client" },
|
||||||
|
);
|
||||||
|
setItems(res?.items ?? []);
|
||||||
|
const meta = res?.meta as
|
||||||
|
| { totalPage?: number; count?: number }
|
||||||
|
| undefined;
|
||||||
|
setTotalPage(Math.max(1, meta?.totalPage ?? 1));
|
||||||
|
setTotalCount(typeof meta?.count === "number" ? meta.count : null);
|
||||||
|
setLoading(false);
|
||||||
|
}, [config.typeId, page, sort, q, tag]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [q, tag, config.typeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen bg-white font-[family-name:var(--font-geist-sans)]">
|
||||||
|
<section className="min-h-screen py-10">
|
||||||
|
<div className="container mx-auto px-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenFilter(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[280px_1fr]">
|
||||||
|
<div className="hidden lg:block">{sidebar}</div>
|
||||||
|
|
||||||
|
{openFilter && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/40">
|
||||||
|
<div className="absolute top-0 left-0 h-full w-[280px] overflow-y-auto bg-white p-6">
|
||||||
|
{sidebar}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenFilter(false)}
|
||||||
|
className="mt-4 text-sm text-[#966314]"
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{config.breadcrumb} >
|
||||||
|
<span className="font-semibold text-black">Lihat Semua</span>
|
||||||
|
<span className="ml-3 font-semibold text-black">{"|"}</span>
|
||||||
|
<span className="ml-4 text-gray-400">
|
||||||
|
{totalCount != null
|
||||||
|
? `Terdapat ${totalCount} konten`
|
||||||
|
: loading
|
||||||
|
? "Memuat…"
|
||||||
|
: `Terdapat ${items.length} konten`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<span className="text-sm">Urutkan:</span>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSort(e.target.value as "popular" | "latest");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="rounded-md border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="latest">Terbaru</option>
|
||||||
|
<option value="popular">Terpopuler</option>
|
||||||
|
</select>
|
||||||
|
<FloatingMenuNews />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-center text-muted-foreground">Memuat…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
Belum ada konten yang dipublikasikan.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{items.map((article) => (
|
||||||
|
<PublicArticleCard key={article.id} article={article} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPage > 1 && !loading && items.length > 0 ? (
|
||||||
|
<div className="mt-12 flex flex-wrap items-center justify-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
className="rounded border bg-white px-3 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Sebelumnya
|
||||||
|
</button>
|
||||||
|
<span className="px-2 text-gray-600">
|
||||||
|
Halaman {page} / {totalPage}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= totalPage}
|
||||||
|
onClick={() =>
|
||||||
|
setPage((p) => Math.min(totalPage, p + 1))
|
||||||
|
}
|
||||||
|
className="rounded border bg-white px-3 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Selanjutnya
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter sisi publik: pencarian judul + filter tag (sesuai CMS).
|
||||||
|
* Kategori artikel tidak dipakai — diganti penjelasan singkat + tag.
|
||||||
|
*/
|
||||||
|
export default function ContentTypeFilterSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const tag = searchParams.get("tag") ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<h3 className="text-sm font-semibold">Filter</h3>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
Kategori tidak digunakan untuk artikel saat ini. Gunakan{" "}
|
||||||
|
<span className="font-medium text-foreground">kata kunci</span> (judul)
|
||||||
|
dan <span className="font-medium text-foreground">tag</span> yang sama
|
||||||
|
seperti di CMS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
key={`${q}-${tag}`}
|
||||||
|
action={pathname}
|
||||||
|
method="get"
|
||||||
|
className="mt-6 space-y-5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="filter-q"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Kata kunci
|
||||||
|
</label>
|
||||||
|
<p className="mb-2 mt-1 text-xs text-muted-foreground">
|
||||||
|
Mencari di judul artikel.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="filter-q"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Cari judul…"
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#966314]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="filter-tag"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Tag
|
||||||
|
</label>
|
||||||
|
<p className="mb-2 mt-1 text-xs text-muted-foreground">
|
||||||
|
Cocokkan teks pada kolom tag (bisa sebagian).
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="filter-tag"
|
||||||
|
name="tag"
|
||||||
|
type="text"
|
||||||
|
defaultValue={tag}
|
||||||
|
placeholder="contoh: nasional"
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#966314]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-lg bg-[#b07c18] py-2.5 text-sm font-medium text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Terapkan
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={pathname}
|
||||||
|
className="block w-full rounded-lg border border-gray-200 py-2.5 text-center text-sm font-medium text-[#966314] transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Reset filter
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import ArticleThumbnail from "@/components/landing-page/article-thumbnail";
|
||||||
|
import type { PublicArticle } from "@/lib/articles-public";
|
||||||
|
|
||||||
|
const BADGE_COLORS = [
|
||||||
|
"bg-red-600",
|
||||||
|
"bg-yellow-500",
|
||||||
|
"bg-yellow-600",
|
||||||
|
"bg-[#0f3b63]",
|
||||||
|
];
|
||||||
|
|
||||||
|
function firstTag(tags: string | undefined): string {
|
||||||
|
if (!tags?.trim()) return "";
|
||||||
|
const t = tags
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)[0];
|
||||||
|
return t ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articleDetailHref(a: PublicArticle) {
|
||||||
|
return `/news/detail/${a.id}-${a.slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
article: PublicArticle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PublicArticleCard({ article }: Props) {
|
||||||
|
const badgeClass = BADGE_COLORS[article.id % BADGE_COLORS.length];
|
||||||
|
const category = article.categoryName?.trim() || "Berita";
|
||||||
|
const tag = firstTag(article.tags);
|
||||||
|
const dateSrc = article.publishedAt || article.createdAt;
|
||||||
|
const dateLabel =
|
||||||
|
typeof dateSrc === "string"
|
||||||
|
? new Date(dateSrc).toLocaleDateString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={articleDetailHref(article)}
|
||||||
|
className="block overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm transition duration-300 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="relative h-[200px] w-full">
|
||||||
|
<ArticleThumbnail
|
||||||
|
src={article.thumbnailUrl}
|
||||||
|
alt={article.title}
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={`rounded-md px-2 py-[3px] font-medium text-white ${badgeClass}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
{tag ? (
|
||||||
|
<span className="uppercase tracking-wide text-gray-500">{tag}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400">{dateLabel}</p>
|
||||||
|
|
||||||
|
<h3 className="line-clamp-2 text-[15px] font-semibold leading-snug transition hover:text-[#966314]">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="line-clamp-2 text-sm leading-relaxed text-gray-500">
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,99 +1 @@
|
||||||
"use client";
|
export { default } from "@/components/content-type/content-type-filter-sidebar";
|
||||||
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
|
||||||
|
|
||||||
export default function FilterDocumentSidebar() {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
|
|
||||||
{/* HEADER */}
|
|
||||||
<div className="flex items-center justify-between pb-4 border-b">
|
|
||||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
|
||||||
Filter
|
|
||||||
</h3>
|
|
||||||
<ChevronLeft size={16} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CONTENT */}
|
|
||||||
<div className="space-y-6 mt-6">
|
|
||||||
{/* KATEGORI */}
|
|
||||||
<FilterSection title="Kategori">
|
|
||||||
<Checkbox label="Semua" count={1203} defaultChecked />
|
|
||||||
<Checkbox label="Berita Terhangat" count={123} />
|
|
||||||
<Checkbox label="Tentang Teknologi" count={24} />
|
|
||||||
<Checkbox label="Bersama Pelanggan" count={42} />
|
|
||||||
<Checkbox label="Pembicara Ahli" count={224} />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* JENIS FILE */}
|
|
||||||
<FilterSection title="Jenis File">
|
|
||||||
<Checkbox label="Semua" count={78} />
|
|
||||||
<Checkbox label="Audio Visual" count={120} />
|
|
||||||
<Checkbox label="Audio" count={34} />
|
|
||||||
<Checkbox label="Foto" count={234} />
|
|
||||||
<Checkbox label="Teks" count={9} defaultChecked />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* FORMAT */}
|
|
||||||
<FilterSection title="Format Document ">
|
|
||||||
<Checkbox label="Semua" count={2} defaultChecked />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
{/* RESET */}
|
|
||||||
<div className="text-center pt-4">
|
|
||||||
<button className="text-sm text-[#966314] font-medium hover:underline">
|
|
||||||
Reset Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== COMPONENTS ===== */
|
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-3">{title}</p>
|
|
||||||
<div className="space-y-2">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Checkbox({
|
|
||||||
label,
|
|
||||||
count,
|
|
||||||
defaultChecked,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
defaultChecked?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex items-center justify-between text-sm cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={defaultChecked}
|
|
||||||
className="h-4 w-4 accent-[#966314]"
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-400">({count})</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Divider() {
|
|
||||||
return <div className="border-t border-gray-200"></div>;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,497 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import withReactContent from "sweetalert2-react-content";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { htmlToString } from "@/utils/global";
|
||||||
|
import { close, error, loading, successToast } from "@/config/swal";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
createArticle,
|
||||||
|
createArticleSchedule,
|
||||||
|
uploadArticleFile,
|
||||||
|
uploadArticleThumbnail,
|
||||||
|
} from "@/service/article";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
type ArticleContentKind,
|
||||||
|
ARTICLE_KIND_LABEL,
|
||||||
|
ARTICLE_KIND_TO_TYPE_ID,
|
||||||
|
articleListPath,
|
||||||
|
} from "@/constants/article-content-types";
|
||||||
|
|
||||||
|
const CustomEditor = dynamic(
|
||||||
|
() => import("@/components/editor/custom-editor"),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FileWithPreview extends File {
|
||||||
|
preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(2, "Title is required"),
|
||||||
|
slug: z.string().min(2, "Slug is required"),
|
||||||
|
description: z.string().min(2, "Description is required"),
|
||||||
|
tags: z.array(z.string()).min(1, "Add at least one tag"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function removeImgTags(htmlString: string) {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(String(htmlString), "text/html");
|
||||||
|
doc.querySelectorAll("img").forEach((img) => img.remove());
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaAcceptForKind(kind: ArticleContentKind): Record<string, string[]> {
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
return { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] };
|
||||||
|
case "video":
|
||||||
|
return { "video/*": [".mp4", ".webm", ".mov"] };
|
||||||
|
case "audio":
|
||||||
|
return { "audio/*": [".mp3", ".wav", ".mpeg", ".ogg"] };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = { contentKind: ArticleContentKind };
|
||||||
|
|
||||||
|
export default function CreateArticleForm({ contentKind }: Props) {
|
||||||
|
const MySwal = withReactContent(Swal);
|
||||||
|
const router = useRouter();
|
||||||
|
const username = Cookies.get("username")?.trim() || "Editor";
|
||||||
|
|
||||||
|
const typeId = ARTICLE_KIND_TO_TYPE_ID[contentKind];
|
||||||
|
const label = ARTICLE_KIND_LABEL[contentKind];
|
||||||
|
const requireMedia = contentKind !== "text";
|
||||||
|
const listHref = articleListPath(contentKind);
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<FileWithPreview[]>([]);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [thumbnailImg, setThumbnailImg] = useState<File[]>([]);
|
||||||
|
const [selectedMainImage, setSelectedMainImage] = useState<number | null>(null);
|
||||||
|
const [filesValidation, setFileValidation] = useState("");
|
||||||
|
const [thumbnailValidation, setThumbnailValidation] = useState("");
|
||||||
|
const [status, setStatus] = useState<"publish" | "draft" | "scheduled">("publish");
|
||||||
|
const [isScheduled, setIsScheduled] = useState(false);
|
||||||
|
const [startDateValue, setStartDateValue] = useState<Date | undefined>();
|
||||||
|
const [startTimeValue, setStartTimeValue] = useState("");
|
||||||
|
|
||||||
|
const dropAccept = mediaAcceptForKind(contentKind);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop: (acceptedFiles) => {
|
||||||
|
setFiles((prev) => [...prev, ...acceptedFiles.map((f) => Object.assign(f))]);
|
||||||
|
},
|
||||||
|
multiple: true,
|
||||||
|
accept: Object.keys(dropAccept).length ? dropAccept : undefined,
|
||||||
|
disabled: !requireMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "", slug: "", description: "", tags: [] },
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const watchTitle = watch("title");
|
||||||
|
useEffect(() => {
|
||||||
|
const slug = watchTitle
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
setValue("slug", slug);
|
||||||
|
}, [watchTitle, setValue]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
if (requireMedia && files.length < 1) {
|
||||||
|
setFileValidation("Upload at least one media file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileValidation("");
|
||||||
|
setThumbnailValidation("");
|
||||||
|
|
||||||
|
const ok = await MySwal.fire({
|
||||||
|
title: "Save article?",
|
||||||
|
icon: "question",
|
||||||
|
showCancelButton: true,
|
||||||
|
});
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
await save(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (values: FormValues) => {
|
||||||
|
loading();
|
||||||
|
try {
|
||||||
|
const plain = htmlToString(removeImgTags(values.description));
|
||||||
|
const html = removeImgTags(values.description);
|
||||||
|
|
||||||
|
const formData: Record<string, unknown> = {
|
||||||
|
title: values.title,
|
||||||
|
typeId,
|
||||||
|
slug: values.slug,
|
||||||
|
categoryIds: "",
|
||||||
|
tags: values.tags.join(","),
|
||||||
|
description: plain,
|
||||||
|
htmlDescription: html,
|
||||||
|
aiArticleId: null,
|
||||||
|
customCreatorName: username,
|
||||||
|
source: "internal",
|
||||||
|
isDraft: status === "draft",
|
||||||
|
isPublish: status === "publish",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await createArticle(formData);
|
||||||
|
if ((response as any)?.error) {
|
||||||
|
error((response as any).message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const articleId = (response as any)?.data?.data?.id as number | undefined;
|
||||||
|
if (!articleId) {
|
||||||
|
error("Could not read new article id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
for (const file of files) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const up = await uploadArticleFile(String(articleId), fd);
|
||||||
|
if ((up as any)?.error) {
|
||||||
|
error((up as any)?.message ?? "File upload failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumbnailImg.length > 0 || (selectedMainImage && files.length >= selectedMainImage)) {
|
||||||
|
const fd = new FormData();
|
||||||
|
if (thumbnailImg.length > 0) {
|
||||||
|
fd.append("files", thumbnailImg[0]);
|
||||||
|
} else if (selectedMainImage) {
|
||||||
|
fd.append("files", files[selectedMainImage - 1]);
|
||||||
|
}
|
||||||
|
const tr = await uploadArticleThumbnail(String(articleId), fd);
|
||||||
|
if ((tr as any)?.error) {
|
||||||
|
error((tr as any)?.message ?? "Thumbnail upload failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "scheduled" && startDateValue) {
|
||||||
|
const [h, m] = startTimeValue ? startTimeValue.split(":").map(Number) : [0, 0];
|
||||||
|
const d = new Date(startDateValue);
|
||||||
|
d.setHours(h, m, 0, 0);
|
||||||
|
const formatted = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||||
|
d.getDate(),
|
||||||
|
).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(
|
||||||
|
2,
|
||||||
|
"0",
|
||||||
|
)}:00`;
|
||||||
|
await createArticleSchedule({ id: articleId, date: formatted });
|
||||||
|
}
|
||||||
|
|
||||||
|
close();
|
||||||
|
const publicUrl = `${window.location.protocol}//${window.location.host}/news/detail/${articleId}-${values.slug}`;
|
||||||
|
MySwal.fire({ title: "Saved", icon: "success" }).then(() => {
|
||||||
|
router.push(listHref);
|
||||||
|
successToast("Article URL", publicUrl);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreview = (file: FileWithPreview) => {
|
||||||
|
if (file.type.startsWith("image")) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
alt={file.name}
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
className="rounded border p-0.5"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded border bg-slate-100 text-[10px] px-1 text-center">
|
||||||
|
{file.name.slice(0, 8)}…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col lg:flex-row gap-8 text-black" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="w-full lg:w-[65%] bg-white rounded-lg p-8 flex flex-col gap-1 shadow-sm">
|
||||||
|
<p className="text-xs font-medium text-blue-600 uppercase tracking-wide">
|
||||||
|
New {label} article
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500 mb-2">
|
||||||
|
Tags are used for organization. Categories and creator type are not used for News & Article.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm">Title</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
placeholder="Article title"
|
||||||
|
className="h-14 px-4 text-xl"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.title && <p className="text-red-500 text-sm">{errors.title.message}</p>}
|
||||||
|
|
||||||
|
<p className="text-sm mt-3">Slug</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input className="w-full border rounded-lg" {...field} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.slug && <p className="text-red-500 text-sm">{errors.slug.message}</p>}
|
||||||
|
|
||||||
|
<p className="text-sm mt-3">Description</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CustomEditor onChange={onChange} initialData={value} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-red-500 text-sm">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{requireMedia && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mt-4 font-medium">Media files ({label})</p>
|
||||||
|
<div {...getRootProps({ className: "dropzone cursor-pointer" })}>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className="w-full text-center border-dashed border-2 rounded-md py-12 flex flex-col items-center border-slate-200">
|
||||||
|
<CloudUploadIcon size={48} className="text-slate-300" />
|
||||||
|
<p className="mt-2 text-slate-700">Drop files here or click to upload</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{contentKind === "image" && "Images: jpg, png, webp…"}
|
||||||
|
{contentKind === "video" && "Video: mp4, webm…"}
|
||||||
|
{contentKind === "audio" && "Audio: mp3, wav…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filesValidation && <p className="text-red-500 text-sm">{filesValidation}</p>}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="space-y-3 mt-2">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={`${file.name}-${index}`}
|
||||||
|
className="flex justify-between border px-3 py-3 rounded-md items-center"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
{renderPreview(file)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">{file.name}</div>
|
||||||
|
{file.type.startsWith("image") && (
|
||||||
|
<label className="flex items-center gap-2 text-xs mt-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="thumb"
|
||||||
|
checked={selectedMainImage === index + 1}
|
||||||
|
onChange={() => setSelectedMainImage(index + 1)}
|
||||||
|
/>
|
||||||
|
Use as thumbnail
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => {
|
||||||
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setSelectedMainImage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimesIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => setFiles([])}>
|
||||||
|
Clear all files
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-[35%] flex flex-col gap-6">
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium">Thumbnail</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{requireMedia
|
||||||
|
? "Optional separate image, or pick a gallery image as thumbnail above."
|
||||||
|
: "Optional cover image for listings."}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="text-sm"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
setThumbnailImg(f ? [f] : []);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(thumbnailImg.length > 0 || (selectedMainImage && files[selectedMainImage - 1])) && (
|
||||||
|
<div className="relative w-40">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
thumbnailImg[0]
|
||||||
|
? URL.createObjectURL(thumbnailImg[0])
|
||||||
|
: URL.createObjectURL(files[selectedMainImage! - 1])
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
className="rounded border w-full"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute -top-2 -right-2"
|
||||||
|
onClick={() => {
|
||||||
|
setThumbnailImg([]);
|
||||||
|
setSelectedMainImage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{thumbnailValidation && <p className="text-red-500 text-xs">{thumbnailValidation}</p>}
|
||||||
|
|
||||||
|
<p className="text-sm font-medium pt-2">Tags</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{value.map((item, index) => (
|
||||||
|
<Badge key={`${item}-${index}`} variant="secondary" className="gap-1">
|
||||||
|
{item}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-red-600"
|
||||||
|
onClick={() => {
|
||||||
|
const next = value.filter((t) => t !== item);
|
||||||
|
if (next.length === 0) {
|
||||||
|
setError("tags", { message: "At least one tag" });
|
||||||
|
} else {
|
||||||
|
clearErrors("tags");
|
||||||
|
setValue("tags", next as [string, ...string[]]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Type a tag and press Enter"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = tagInput.trim();
|
||||||
|
if (t) {
|
||||||
|
setValue("tags", [...value, t]);
|
||||||
|
setTagInput("");
|
||||||
|
clearErrors("tags");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.tags && <p className="text-red-500 text-sm">{errors.tags.message}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="sched"
|
||||||
|
checked={isScheduled}
|
||||||
|
onChange={(e) => setIsScheduled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="sched" className="text-sm">
|
||||||
|
Schedule publish
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{isScheduled && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
onChange={(e) => setStartDateValue(e.target.value ? new Date(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
<Input type="time" value={startTimeValue} onChange={(e) => setStartTimeValue(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-600"
|
||||||
|
onClick={() => (isScheduled ? setStatus("scheduled") : setStatus("publish"))}
|
||||||
|
disabled={isScheduled && !startDateValue}
|
||||||
|
>
|
||||||
|
{isScheduled ? "Schedule" : "Publish"}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="secondary" onClick={() => setStatus("draft")}>
|
||||||
|
Save draft
|
||||||
|
</Button>
|
||||||
|
<Link href={listHref}>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -102,9 +102,7 @@ const createArticleSchema = z.object({
|
||||||
description: z.string().min(2, {
|
description: z.string().min(2, {
|
||||||
message: "Deskripsi harus diisi",
|
message: "Deskripsi harus diisi",
|
||||||
}),
|
}),
|
||||||
category: z.array(categorySchema).nonempty({
|
category: z.array(categorySchema),
|
||||||
message: "Kategori harus memiliki setidaknya satu item",
|
|
||||||
}),
|
|
||||||
tags: z.array(z.string()).nonempty({
|
tags: z.array(z.string()).nonempty({
|
||||||
message: "Minimal 1 tag",
|
message: "Minimal 1 tag",
|
||||||
}),
|
}),
|
||||||
|
|
@ -183,7 +181,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
|
|
||||||
const formOptions = {
|
const formOptions = {
|
||||||
resolver: zodResolver(createArticleSchema),
|
resolver: zodResolver(createArticleSchema),
|
||||||
defaultValues: { title: "", description: "", category: [], tags: [] },
|
defaultValues: { title: "", description: "", category: [], tags: [], slug: "", customCreatorName: "" },
|
||||||
};
|
};
|
||||||
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||||
const {
|
const {
|
||||||
|
|
@ -230,7 +228,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
|
|
||||||
setThumbnail(articleData.thumbnailUrl);
|
setThumbnail(articleData.thumbnailUrl);
|
||||||
setDiseId(articleData.aiArticleId);
|
setDiseId(articleData.aiArticleId);
|
||||||
setupInitCategory(articleData.categories);
|
setupInitCategory(articleData.categories ?? []);
|
||||||
|
|
||||||
const filesRes = await getArticleFiles();
|
const filesRes = await getArticleFiles();
|
||||||
const allFiles = filesRes.data?.data ?? [];
|
const allFiles = filesRes.data?.data ?? [];
|
||||||
|
|
@ -249,13 +247,13 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
|
|
||||||
const setupInitCategory = (data: any) => {
|
const setupInitCategory = (data: any) => {
|
||||||
const temp: CategoryType[] = [];
|
const temp: CategoryType[] = [];
|
||||||
for (let i = 0; i < data?.length; i++) {
|
for (let i = 0; i < (data?.length ?? 0); i++) {
|
||||||
const datas = listCategory.filter((a) => a.id == data[i].id);
|
const datas = listCategory.filter((a) => a.id == data[i].id);
|
||||||
if (datas[0]) {
|
if (datas[0]) {
|
||||||
temp.push(datas[0]);
|
temp.push(datas[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setValue("category", temp as [CategoryType, ...CategoryType[]]);
|
setValue("category", temp.length ? (temp as [CategoryType, ...CategoryType[]]) : []);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -325,9 +323,9 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
id: Number(id),
|
id: Number(id),
|
||||||
isPublish: true,
|
isPublish: true,
|
||||||
title: detailData?.title,
|
title: detailData?.title,
|
||||||
typeId: 1,
|
typeId: detailData?.typeId ?? 1,
|
||||||
slug: detailData?.slug,
|
slug: detailData?.slug,
|
||||||
categoryIds: getValues("category")
|
categoryIds: (getValues("category") ?? [])
|
||||||
.map((val) => val.id)
|
.map((val) => val.id)
|
||||||
.join(","),
|
.join(","),
|
||||||
tags: getValues("tags").join(","),
|
tags: getValues("tags").join(","),
|
||||||
|
|
@ -369,9 +367,9 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
id: Number(id),
|
id: Number(id),
|
||||||
isPublish: false,
|
isPublish: false,
|
||||||
title: detailData?.title,
|
title: detailData?.title,
|
||||||
typeId: 1,
|
typeId: detailData?.typeId ?? 1,
|
||||||
slug: detailData?.slug,
|
slug: detailData?.slug,
|
||||||
categoryIds: getValues("category")
|
categoryIds: (getValues("category") ?? [])
|
||||||
.map((val) => val.id)
|
.map((val) => val.id)
|
||||||
.join(","),
|
.join(","),
|
||||||
tags: getValues("tags").join(","),
|
tags: getValues("tags").join(","),
|
||||||
|
|
@ -406,9 +404,9 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
const formData: any = {
|
const formData: any = {
|
||||||
id: Number(id),
|
id: Number(id),
|
||||||
title: values.title,
|
title: values.title,
|
||||||
typeId: 1,
|
typeId: detailData?.typeId ?? 1,
|
||||||
slug: values.slug,
|
slug: values.slug,
|
||||||
categoryIds: values.category.map((val) => val.id).join(","),
|
categoryIds: (values.category ?? []).map((val) => val.id).join(","),
|
||||||
tags: values.tags.join(","),
|
tags: values.tags.join(","),
|
||||||
description: htmlToString(values.description),
|
description: htmlToString(values.description),
|
||||||
htmlDescription: values.description,
|
htmlDescription: values.description,
|
||||||
|
|
@ -513,9 +511,9 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
id: Number(id),
|
id: Number(id),
|
||||||
isPublish: false,
|
isPublish: false,
|
||||||
title: detailData?.title,
|
title: detailData?.title,
|
||||||
typeId: 1,
|
typeId: detailData?.typeId ?? 1,
|
||||||
slug: detailData?.slug,
|
slug: detailData?.slug,
|
||||||
categoryIds: getValues("category")
|
categoryIds: (getValues("category") ?? [])
|
||||||
.map((val) => val.id)
|
.map((val) => val.id)
|
||||||
.join(","),
|
.join(","),
|
||||||
tags: getValues("tags").join(","),
|
tags: getValues("tags").join(","),
|
||||||
|
|
@ -697,13 +695,15 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Content type (articles.type_id) */}
|
||||||
<div className="">
|
<div className="">
|
||||||
<p className="text-sm text-black mb-2">Category</p>
|
<p className="text-sm text-black mb-2">Content type</p>
|
||||||
<div className="bg-gray-100 rounded-lg px-4 py-3">
|
<div className="bg-gray-100 rounded-lg px-4 py-3">
|
||||||
{detailData?.categories
|
{detailData?.typeId === 1 && "Image"}
|
||||||
?.map((cat: any) => cat.title)
|
{detailData?.typeId === 2 && "Text"}
|
||||||
.join(", ")}
|
{detailData?.typeId === 3 && "Video"}
|
||||||
|
{detailData?.typeId === 4 && "Audio"}
|
||||||
|
{![1, 2, 3, 4].includes(detailData?.typeId) && (detailData?.typeId ?? "—")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -750,16 +750,8 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
||||||
|
|
||||||
{/* ================= RIGHT SIDE ================= */}
|
{/* ================= RIGHT SIDE ================= */}
|
||||||
<div className="w-full lg:w-[30%] space-y-6">
|
<div className="w-full lg:w-[30%] space-y-6">
|
||||||
{/* Creator & Thumbnail Card */}
|
{/* Meta & Thumbnail Card */}
|
||||||
<div className="bg-white rounded-2xl shadow-sm border p-6 space-y-6">
|
<div className="bg-white rounded-2xl shadow-sm border p-6 space-y-6">
|
||||||
{/* Creator */}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-500 mb-2">Creator</p>
|
|
||||||
<div className="bg-gray-100 rounded-lg px-4 py-3 font-medium">
|
|
||||||
{detailData?.customCreatorName || "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mb-2">Thumbnail Image</p>
|
<p className="text-sm text-gray-500 mb-2">Thumbnail Image</p>
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1 @@
|
||||||
"use client";
|
export { default } from "@/components/content-type/content-type-filter-sidebar";
|
||||||
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
|
||||||
|
|
||||||
export default function FilterImageSidebar() {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
|
|
||||||
{/* HEADER */}
|
|
||||||
<div className="flex items-center justify-between pb-4 border-b">
|
|
||||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
|
||||||
Filter
|
|
||||||
</h3>
|
|
||||||
<ChevronLeft size={16} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CONTENT */}
|
|
||||||
<div className="space-y-6 mt-6">
|
|
||||||
{/* KATEGORI */}
|
|
||||||
<FilterSection title="Kategori">
|
|
||||||
<Checkbox label="Semua" count={1203} defaultChecked />
|
|
||||||
<Checkbox label="Berita Terhangat" count={123} />
|
|
||||||
<Checkbox label="Tentang Teknologi" count={24} />
|
|
||||||
<Checkbox label="Bersama Pelanggan" count={42} />
|
|
||||||
<Checkbox label="Pembicara Ahli" count={224} />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* JENIS FILE */}
|
|
||||||
<FilterSection title="Jenis File">
|
|
||||||
<Checkbox label="Semua" count={78} />
|
|
||||||
<Checkbox label="Audio Visual" count={120} />
|
|
||||||
<Checkbox label="Audio" count={34} />
|
|
||||||
<Checkbox label="Foto" count={234} defaultChecked />
|
|
||||||
<Checkbox label="Teks" count={9} />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* FORMAT */}
|
|
||||||
<FilterSection title="Format Foto ">
|
|
||||||
<Checkbox label="Semua" count={2} defaultChecked />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
{/* RESET */}
|
|
||||||
<div className="text-center pt-4">
|
|
||||||
<button className="text-sm text-[#966314] font-medium hover:underline">
|
|
||||||
Reset Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== COMPONENTS ===== */
|
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-3">{title}</p>
|
|
||||||
<div className="space-y-2">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Checkbox({
|
|
||||||
label,
|
|
||||||
count,
|
|
||||||
defaultChecked,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
defaultChecked?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex items-center justify-between text-sm cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={defaultChecked}
|
|
||||||
className="h-4 w-4 accent-[#966314]"
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-400">({count})</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Divider() {
|
|
||||||
return <div className="border-t border-gray-200"></div>;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const FALLBACK = "/dummy/news-2.jpg";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
src?: string | null;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
sizes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Use inside a `relative` container with fixed height; covers area with `object-cover`. */
|
||||||
|
export default function ArticleThumbnail({ src, alt, className, sizes }: Props) {
|
||||||
|
const url =
|
||||||
|
src && String(src).trim().length > 0 ? String(src).trim() : FALLBACK;
|
||||||
|
const isLocal = url.startsWith("/");
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
className={cn("object-cover", className)}
|
||||||
|
sizes={sizes ?? "(max-width: 768px) 100vw, 25vw"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
className={cn("absolute inset-0 h-full w-full object-cover", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,52 +1,47 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
const categories = [
|
type TagStat = { name: string; count: number };
|
||||||
{ name: "Investment", total: 45 },
|
|
||||||
{ name: "Technology", total: 32 },
|
|
||||||
{ name: "Partnership", total: 28 },
|
|
||||||
{ name: "Report", total: 23 },
|
|
||||||
{ name: "Event", total: 19 },
|
|
||||||
{ name: "CSR", total: 15 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ContentCategory() {
|
type Props = {
|
||||||
|
tagStats: TagStat[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContentCategory({ tagStats }: Props) {
|
||||||
return (
|
return (
|
||||||
<section className="py-20 ">
|
<section className="py-20">
|
||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
{/* ===== Title ===== */}
|
<h2 className="mb-12 text-center text-3xl font-bold">Tag Populer</h2>
|
||||||
<h2 className="text-3xl font-bold text-center mb-12">
|
|
||||||
Kategori Konten
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* ===== Card ===== */}
|
{tagStats.length === 0 ? (
|
||||||
<Card className="rounded-2xl shadow-xl border-1 ">
|
<p className="text-center text-muted-foreground">
|
||||||
<CardContent className="p-10 space-y-8">
|
Tag akan muncul setelah ada artikel yang dipublikasikan.
|
||||||
{categories.map((item, index) => (
|
</p>
|
||||||
<div key={index} className="flex items-center justify-between">
|
) : (
|
||||||
{/* Left */}
|
<Card className="border-1 rounded-2xl shadow-xl">
|
||||||
<div className="flex items-center gap-4">
|
<CardContent className="space-y-8 p-10">
|
||||||
{/* Bullet */}
|
{tagStats.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
className={`w-3 h-3 rounded-full ${
|
key={item.name}
|
||||||
index % 2 === 0
|
className="flex items-center justify-between"
|
||||||
? "bg-[#0f3b63]" // biru tua
|
>
|
||||||
: "bg-[#b07c18]" // gold
|
<div className="flex items-center gap-4">
|
||||||
}`}
|
<div
|
||||||
/>
|
className={`h-3 w-3 rounded-full ${
|
||||||
|
index % 2 === 0 ? "bg-[#0f3b63]" : "bg-[#b07c18]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
<span className="text-lg text-[#0f3b63] font-medium">
|
<span className="text-lg font-medium text-[#0f3b63]">
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-lg text-gray-500">{item.count}</span>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
{/* Right total */}
|
</CardContent>
|
||||||
<span className="text-gray-500 text-lg">{item.total}</span>
|
</Card>
|
||||||
</div>
|
)}
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,158 +1,19 @@
|
||||||
"use client";
|
import type { PublicArticle } from "@/lib/articles-public";
|
||||||
|
import type { NewsServicesTab } from "@/constants/news-services";
|
||||||
|
import NewsServicesArticleSection from "@/components/landing-page/news-services-article-section";
|
||||||
|
|
||||||
import Image from "next/image";
|
type Props = {
|
||||||
import { Badge } from "@/components/ui/badge";
|
articlesByTab: Record<NewsServicesTab, PublicArticle[]>;
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
};
|
||||||
|
|
||||||
const data = [
|
export default function ContentLatest({ articlesByTab }: Props) {
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
image: "/image/bharatu.jpg",
|
|
||||||
category: "POLRI",
|
|
||||||
categoryColor: "bg-red-600",
|
|
||||||
tag: "SEPUTAR PRESTASI",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title:
|
|
||||||
"Bharatu Mardi Hadji Gugur Saat Bertugas, Diganjar Kenaikan Pangkat Luar Biasa",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
image: "/image/novita2.png",
|
|
||||||
category: "DPR",
|
|
||||||
categoryColor: "bg-yellow-500",
|
|
||||||
tag: "BERITA KOMISI 7",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title: "Novita Hardini: Jangan Sampai Pariwisata Meminggirkan Warga Lokal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
image: "/dummy/news-3.jpg",
|
|
||||||
category: "MPR",
|
|
||||||
categoryColor: "bg-yellow-600",
|
|
||||||
tag: "KEGIATAN EDUKASI",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title:
|
|
||||||
"Lestari Moerdijat: Butuh Afirmasi dan Edukasi untuk Dorong Perempuan Aktif di Dunia Politik",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
image: "/dummy/news-2.jpg",
|
|
||||||
category: "MAHKAMAH AGUNG",
|
|
||||||
categoryColor: "bg-yellow-700",
|
|
||||||
tag: "HOT NEWS",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title: "SEKRETARIS MAHKAMAH AGUNG LANTIK HAKIM TINGGI PENGAWAS",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ContentLatest() {
|
|
||||||
return (
|
return (
|
||||||
<section className=" py-20">
|
<NewsServicesArticleSection
|
||||||
<div className="container mx-auto px-6">
|
id="konten-terbaru"
|
||||||
{/* ===== HEADER ===== */}
|
title="Konten Terbaru"
|
||||||
<div className="flex flex-col items-center mb-12">
|
articlesByTab={articlesByTab}
|
||||||
<h2 className="text-3xl font-bold mb-6">Konten Terbaru</h2>
|
exploreHref="#konten-terpopuler"
|
||||||
|
exploreLabel="Lihat konten terpopuler"
|
||||||
<Tabs defaultValue="audio-visual" className="w-full">
|
/>
|
||||||
{/* Tabs + Explore */}
|
|
||||||
<div className="relative w-full pb-3 ">
|
|
||||||
{/* Tabs Center */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<TabsList className="bg-transparent p-0 gap-8" variant={"line"}>
|
|
||||||
<TabsTrigger
|
|
||||||
value="audio-visual"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Audio Visual
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="audio"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Audio
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="foto"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Foto
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="teks"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Teks
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Explore Right */}
|
|
||||||
<div className="hidden md:block absolute right-0 top-1/2 -translate-y-1/2 text-sm text-muted-foreground hover:text-black cursor-pointer">
|
|
||||||
Explore more Trending
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== CONTENT ===== */}
|
|
||||||
<TabsContent value="audio-visual" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="audio" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="foto" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="teks" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= CARD GRID ================= */
|
|
||||||
|
|
||||||
function CardGrid() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
|
||||||
{data.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div className="relative h-[220px]">
|
|
||||||
<Image
|
|
||||||
src={item.image}
|
|
||||||
alt={item.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 space-y-3">
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<Badge
|
|
||||||
className={`${item.categoryColor} text-white text-xs px-2 py-1`}
|
|
||||||
>
|
|
||||||
{item.category}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">{item.tag}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{item.date}</p>
|
|
||||||
|
|
||||||
<h3 className="text-sm font-semibold leading-snug line-clamp-3 hover:text-[#b07c18] transition">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,158 +1,20 @@
|
||||||
"use client";
|
import type { PublicArticle } from "@/lib/articles-public";
|
||||||
|
import type { NewsServicesTab } from "@/constants/news-services";
|
||||||
|
import NewsServicesArticleSection from "@/components/landing-page/news-services-article-section";
|
||||||
|
|
||||||
import Image from "next/image";
|
type Props = {
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
articlesByTab: Record<NewsServicesTab, PublicArticle[]>;
|
||||||
import { Badge } from "@/components/ui/badge";
|
};
|
||||||
|
|
||||||
const data = [
|
export default function ContentPopular({ articlesByTab }: Props) {
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
image: "/image/bharatu.jpg",
|
|
||||||
category: "POLRI",
|
|
||||||
categoryColor: "bg-red-600",
|
|
||||||
tag: "SEPUTAR PRESTASI",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title:
|
|
||||||
"Bharatu Mardi Hadji Gugur Saat Bertugas, Diganjar Kenaikan Pangkat Luar Biasa",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
image: "/image/novita2.png",
|
|
||||||
category: "DPR",
|
|
||||||
categoryColor: "bg-yellow-500",
|
|
||||||
tag: "BERITA KOMISI 7",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title: "Novita Hardini: Jangan Sampai Pariwisata Meminggirkan Warga Lokal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
image: "/dummy/news-3.jpg",
|
|
||||||
category: "MPR",
|
|
||||||
categoryColor: "bg-yellow-600",
|
|
||||||
tag: "KEGIATAN EDUKASI",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title:
|
|
||||||
"Lestari Moerdijat: Butuh Afirmasi dan Edukasi untuk Dorong Perempuan Aktif di Dunia Politik",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
image: "/dummy/news-2.jpg",
|
|
||||||
category: "MAHKAMAH AGUNG",
|
|
||||||
categoryColor: "bg-yellow-700",
|
|
||||||
tag: "HOT NEWS",
|
|
||||||
date: "02 Februari 2024",
|
|
||||||
title: "SEKRETARIS MAHKAMAH AGUNG LANTIK HAKIM TINGGI PENGAWAS",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ContentLatest() {
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20">
|
<NewsServicesArticleSection
|
||||||
<div className="container mx-auto px-6">
|
id="konten-terpopuler"
|
||||||
{/* ===== HEADER ===== */}
|
title="Konten Terpopuler"
|
||||||
<div className="flex flex-col items-center mb-12">
|
articlesByTab={articlesByTab}
|
||||||
<h2 className="text-3xl font-bold mb-6">Konten Terpopuler</h2>
|
showViews
|
||||||
|
exploreHref="#konten-terbaru"
|
||||||
<Tabs defaultValue="audio-visual" className="w-full">
|
exploreLabel="Lihat konten terbaru"
|
||||||
{/* Tabs + Explore */}
|
/>
|
||||||
<div className="relative w-full pb-3 ">
|
|
||||||
{/* Tabs Center */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<TabsList className="bg-transparent p-0 gap-8" variant={"line"}>
|
|
||||||
<TabsTrigger
|
|
||||||
value="audio-visual"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Audio Visual
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="audio"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Audio
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="foto"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Foto
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="teks"
|
|
||||||
className="data-[state=active]:text-[#b07c18] px-0 pb-2"
|
|
||||||
>
|
|
||||||
Teks
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Explore Right */}
|
|
||||||
<div className="hidden md:block absolute right-0 top-1/2 -translate-y-1/2 text-sm text-muted-foreground hover:text-black cursor-pointer">
|
|
||||||
Explore more Trending
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== CONTENT ===== */}
|
|
||||||
<TabsContent value="audio-visual" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="audio" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="foto" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="teks" className="mt-12">
|
|
||||||
<CardGrid />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= CARD GRID ================= */
|
|
||||||
|
|
||||||
function CardGrid() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
|
||||||
{data.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div className="relative h-[220px]">
|
|
||||||
<Image
|
|
||||||
src={item.image}
|
|
||||||
alt={item.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 space-y-3">
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<Badge
|
|
||||||
className={`${item.categoryColor} text-white text-xs px-2 py-1`}
|
|
||||||
>
|
|
||||||
{item.category}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">{item.tag}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{item.date}</p>
|
|
||||||
|
|
||||||
<h3 className="text-sm font-semibold leading-snug line-clamp-3 hover:text-[#b07c18] transition">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,223 @@
|
||||||
"use client";
|
"use client";
|
||||||
import Image from "next/image";
|
|
||||||
|
import Link from "next/link";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import type { PublicArticle } from "@/lib/articles-public";
|
||||||
|
import { formatDate } from "@/utils/global";
|
||||||
|
import ArticleThumbnail from "@/components/landing-page/article-thumbnail";
|
||||||
|
|
||||||
const data = [
|
function firstTag(tags: string | undefined): string {
|
||||||
{
|
if (!tags?.trim()) return "";
|
||||||
id: 1,
|
const t = tags
|
||||||
title:
|
.split(",")
|
||||||
"Bharatu Mardi Hadji Gugur Saat Bertugas, Diganjar Kenaikan Pangkat Luar Biasa",
|
.map((s) => s.trim())
|
||||||
image: "/image/bharatu.jpg",
|
.filter(Boolean)[0];
|
||||||
},
|
return t ?? "";
|
||||||
{
|
}
|
||||||
id: 2,
|
|
||||||
title: "Pelayanan Publik Terus Ditingkatkan Demi Kenyamanan Masyarakat",
|
|
||||||
image: "/dummy/news-2.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Inovasi Teknologi Jadi Fokus Pengembangan Layanan",
|
|
||||||
image: "/dummy/news-3.jpg",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const data1 = [
|
function articleHref(a: PublicArticle) {
|
||||||
{
|
return `/news/detail/${a.id}-${a.slug}`;
|
||||||
id: 1,
|
}
|
||||||
title: "Novita Hardini: Jangan Sampai Pariwisata Meminggirkan Warga Lokal",
|
|
||||||
image: "/image/novita2.png",
|
|
||||||
excerpt:
|
|
||||||
"PARLEMENTARIA, Mandalika – Anggota Komisi VII DPR RI, Novita Hardini, menyoroti dampak sosial ekonomi dari pembangunan kawasan pariwisata...",
|
|
||||||
date: "7 November 2024",
|
|
||||||
category: "BERITA KOMISI 7",
|
|
||||||
tag: "DPR",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Pelayanan Publik Terus Ditingkatkan Demi Kenyamanan Masyarakat",
|
|
||||||
image: "/dummy/news-2.jpg",
|
|
||||||
excerpt:
|
|
||||||
"Pelayanan publik terus ditingkatkan untuk menjawab kebutuhan masyarakat...",
|
|
||||||
date: "6 November 2024",
|
|
||||||
category: "BERITA",
|
|
||||||
tag: "NASIONAL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Inovasi Teknologi Jadi Fokus Pengembangan Layanan",
|
|
||||||
image: "/dummy/news-3.jpg",
|
|
||||||
excerpt:
|
|
||||||
"Transformasi digital menjadi fokus utama pengembangan layanan publik...",
|
|
||||||
date: "5 November 2024",
|
|
||||||
category: "TEKNOLOGI",
|
|
||||||
tag: "INOVASI",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NewsAndServicesHeader() {
|
type Props = {
|
||||||
// 🔹 STATE DIPISAH
|
featured: PublicArticle[];
|
||||||
|
defaultSearch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewsAndServicesHeader({
|
||||||
|
featured,
|
||||||
|
defaultSearch = "",
|
||||||
|
}: Props) {
|
||||||
const [activeHeader, setActiveHeader] = useState(0);
|
const [activeHeader, setActiveHeader] = useState(0);
|
||||||
const [activeModal, setActiveModal] = useState(0);
|
const [activeModal, setActiveModal] = useState(0);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const slides = featured.length > 0 ? featured : [];
|
||||||
|
const slideCount = slides.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🔹 AUTO OPEN MODAL
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
const highlight = searchParams.get("highlight");
|
const highlight = searchParams.get("highlight");
|
||||||
if (highlight === "1") {
|
if (highlight === "1" && slideCount > 0) {
|
||||||
setActiveModal(activeHeader); // clone posisi header
|
setActiveModal(activeHeader);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
}, [mounted, searchParams, activeHeader]);
|
}, [mounted, searchParams, activeHeader, slideCount]);
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.replace("/news-services");
|
router.replace("/news-services");
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== HEADER NAV =====
|
const headerPrev = () => {
|
||||||
const headerPrev = () =>
|
if (slideCount === 0) return;
|
||||||
setActiveHeader((p) => (p === 0 ? data.length - 1 : p - 1));
|
setActiveHeader((p) => (p === 0 ? slideCount - 1 : p - 1));
|
||||||
const headerNext = () =>
|
};
|
||||||
setActiveHeader((p) => (p === data.length - 1 ? 0 : p + 1));
|
|
||||||
|
|
||||||
// ===== MODAL NAV =====
|
const headerNext = () => {
|
||||||
const modalPrev = () =>
|
if (slideCount === 0) return;
|
||||||
setActiveModal((p) => (p === 0 ? data.length - 1 : p - 1));
|
setActiveHeader((p) => (p === slideCount - 1 ? 0 : p + 1));
|
||||||
const modalNext = () =>
|
};
|
||||||
setActiveModal((p) => (p === data.length - 1 ? 0 : p + 1));
|
|
||||||
|
const modalPrev = () => {
|
||||||
|
if (slideCount === 0) return;
|
||||||
|
setActiveModal((p) => (p === 0 ? slideCount - 1 : p - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalNext = () => {
|
||||||
|
if (slideCount === 0) return;
|
||||||
|
setActiveModal((p) => (p === slideCount - 1 ? 0 : p + 1));
|
||||||
|
};
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const current = slides[activeHeader];
|
||||||
|
const modalArticle = slides[activeModal];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ================= HEADER ================= */}
|
|
||||||
{/* ================= HEADER ================= */}
|
|
||||||
<section className="relative w-full bg-[#f8f8f8] py-24">
|
<section className="relative w-full bg-[#f8f8f8] py-24">
|
||||||
<div className="container mx-auto px-6 relative">
|
<div className="container relative mx-auto px-6">
|
||||||
{/* ===== OUTER NAVIGATION ===== */}
|
{slideCount > 0 ? (
|
||||||
<button
|
<>
|
||||||
onClick={headerPrev}
|
<button
|
||||||
className="hidden lg:flex absolute -left-6 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white shadow-md items-center justify-center z-10"
|
type="button"
|
||||||
>
|
onClick={headerPrev}
|
||||||
<ChevronLeft />
|
className="absolute top-1/2 -left-6 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow-md lg:flex"
|
||||||
</button>
|
>
|
||||||
|
<ChevronLeft />
|
||||||
<button
|
</button>
|
||||||
onClick={headerNext}
|
|
||||||
className="hidden lg:flex absolute -right-6 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white shadow-md items-center justify-center z-10"
|
|
||||||
>
|
|
||||||
<ChevronRight />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row items-center gap-14">
|
|
||||||
{/* IMAGE */}
|
|
||||||
<div className="relative w-full lg:w-1/2">
|
|
||||||
<div className="relative h-[420px] rounded-3xl overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={data1[activeHeader].image}
|
|
||||||
alt={data1[activeHeader].title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DOTS */}
|
|
||||||
<div className="flex justify-center gap-2 mt-4">
|
|
||||||
{data1.map((_, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={`h-2.5 rounded-full transition-all ${
|
|
||||||
activeHeader === i
|
|
||||||
? "w-8 bg-[#b07c18]"
|
|
||||||
: "w-2.5 bg-gray-300"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CONTENT */}
|
|
||||||
<div className="w-full lg:w-1/2">
|
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold leading-tight mb-6">
|
|
||||||
{data1[activeHeader].title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500 mb-5">
|
|
||||||
<span>{data1[activeHeader].date}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{data1[activeHeader].category}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="px-2 py-0.5 rounded bg-[#f2c94c] text-black text-xs font-semibold">
|
|
||||||
{data1[activeHeader].tag}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-8">
|
|
||||||
{data1[activeHeader].excerpt}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
type="button"
|
||||||
className="inline-flex items-center justify-center rounded-xl bg-[#b07c18] px-7 py-3 text-white font-medium hover:opacity-90 transition"
|
onClick={headerNext}
|
||||||
|
className="absolute top-1/2 -right-6 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow-md lg:flex"
|
||||||
>
|
>
|
||||||
Baca Selengkapnya
|
<ChevronRight />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
|
{slideCount === 0 ? (
|
||||||
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
|
<h1 className="mb-4 text-3xl font-bold lg:text-4xl">
|
||||||
|
Berita & Layanan
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Konten unggulan akan tampil di sini setelah artikel dipublikasikan
|
||||||
|
dari CMS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
current && (
|
||||||
|
<div className="flex flex-col items-center gap-14 lg:flex-row">
|
||||||
|
<div className="relative w-full lg:w-1/2">
|
||||||
|
<div className="relative h-[420px] overflow-hidden rounded-3xl">
|
||||||
|
<ArticleThumbnail
|
||||||
|
src={current.thumbnailUrl}
|
||||||
|
alt={current.title}
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-center gap-2">
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-2.5 rounded-full transition-all ${
|
||||||
|
activeHeader === i
|
||||||
|
? "w-8 bg-[#b07c18]"
|
||||||
|
: "w-2.5 bg-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-1/2">
|
||||||
|
<h1 className="mb-6 text-4xl font-bold leading-tight lg:text-5xl">
|
||||||
|
{current.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||||
|
<span>
|
||||||
|
{formatDate(current.publishedAt || current.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{current.categoryName?.trim() || "Berita"}</span>
|
||||||
|
{firstTag(current.tags) ? (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="rounded bg-[#f2c94c] px-2 py-0.5 text-xs font-semibold text-black">
|
||||||
|
{firstTag(current.tags)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-8 leading-relaxed text-gray-700 line-clamp-5">
|
||||||
|
{current.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href={articleHref(current)}
|
||||||
|
className="inline-flex items-center justify-center rounded-xl bg-[#b07c18] px-7 py-3 font-medium text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Baca Selengkapnya
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex items-center justify-center rounded-xl border border-[#b07c18] px-7 py-3 font-medium text-[#b07c18] transition hover:bg-[#b07c18]/10"
|
||||||
|
>
|
||||||
|
Pratinjau
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ===== SEARCH SECTION ===== */}
|
|
||||||
<div className="mt-20">
|
<div className="mt-20">
|
||||||
<div className="max-w-3xl mx-auto flex items-center bg-white rounded-2xl shadow-md overflow-hidden border">
|
<form
|
||||||
|
action="/news-services"
|
||||||
|
method="get"
|
||||||
|
className="mx-auto flex max-w-3xl items-center overflow-hidden rounded-2xl border bg-white shadow-md"
|
||||||
|
>
|
||||||
<div className="px-4 text-gray-400">🔍</div>
|
<div className="px-4 text-gray-400">🔍</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="search"
|
||||||
|
name="q"
|
||||||
|
defaultValue={defaultSearch}
|
||||||
placeholder="Cari berita, artikel, atau topik..."
|
placeholder="Cari berita, artikel, atau topik..."
|
||||||
className="flex-1 px-4 py-4 outline-none"
|
className="flex-1 px-4 py-4 outline-none"
|
||||||
/>
|
/>
|
||||||
<button className="bg-[#b07c18] text-white px-8 py-4 font-medium">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#b07c18] px-8 py-4 font-medium text-white"
|
||||||
|
>
|
||||||
Cari
|
Cari
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ================= MODAL ================= */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && slideCount > 0 && modalArticle && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|
@ -207,54 +225,63 @@ export default function NewsAndServicesHeader() {
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative w-[90%] max-w-5xl rounded-3xl overflow-hidden bg-[#9c8414]"
|
className="relative w-[90%] max-w-5xl overflow-hidden rounded-3xl bg-[#9c8414]"
|
||||||
initial={{ scale: 0.95, opacity: 0 }}
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.95, opacity: 0 }}
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
className="absolute top-4 right-4 z-10 w-10 h-10 rounded-full bg-black/40 text-white flex items-center justify-center"
|
className="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/40 text-white"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="relative h-[520px]">
|
<div className="relative h-[520px]">
|
||||||
<Image
|
<ArticleThumbnail
|
||||||
src={data[activeModal].image}
|
src={modalArticle.thumbnailUrl}
|
||||||
alt={data[activeModal].title}
|
alt={modalArticle.title}
|
||||||
fill
|
sizes="100vw"
|
||||||
className="object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute bottom-6 left-6 right-6 text-white">
|
<div className="absolute bottom-6 left-6 right-6 text-white">
|
||||||
<h2 className="text-xl md:text-2xl font-semibold">
|
<h2 className="text-xl font-semibold md:text-2xl">
|
||||||
{data[activeModal].title}
|
{modalArticle.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
<Link
|
||||||
|
href={articleHref(modalArticle)}
|
||||||
|
className="mt-3 inline-block text-sm font-medium underline"
|
||||||
|
onClick={closeModal}
|
||||||
|
>
|
||||||
|
Buka halaman artikel
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={modalPrev}
|
onClick={modalPrev}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/40 text-white"
|
className="absolute top-1/2 left-4 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white"
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={modalNext}
|
onClick={modalNext}
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/40 text-white"
|
className="absolute top-1/2 right-4 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white"
|
||||||
>
|
>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center gap-2 py-4">
|
<div className="flex justify-center gap-2 py-4">
|
||||||
{data.map((_, i) => (
|
{slides.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
|
type="button"
|
||||||
onClick={() => setActiveModal(i)}
|
onClick={() => setActiveModal(i)}
|
||||||
className={`w-2.5 h-2.5 rounded-full ${
|
className={`h-2.5 w-2.5 rounded-full ${
|
||||||
activeModal === i ? "bg-white" : "bg-white/40"
|
activeModal === i ? "bg-white" : "bg-white/40"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type { PublicArticle } from "@/lib/articles-public";
|
||||||
|
import {
|
||||||
|
NEWS_SERVICES_TAB_ORDER,
|
||||||
|
NEWS_TAB_LABEL,
|
||||||
|
type NewsServicesTab,
|
||||||
|
} from "@/constants/news-services";
|
||||||
|
import { formatDate } from "@/utils/global";
|
||||||
|
import ArticleThumbnail from "@/components/landing-page/article-thumbnail";
|
||||||
|
|
||||||
|
const BADGE_COLORS = [
|
||||||
|
"bg-red-600",
|
||||||
|
"bg-yellow-500",
|
||||||
|
"bg-yellow-600",
|
||||||
|
"bg-[#0f3b63]",
|
||||||
|
];
|
||||||
|
|
||||||
|
function firstTag(tags: string | undefined): string {
|
||||||
|
if (!tags?.trim()) return "";
|
||||||
|
const t = tags
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)[0];
|
||||||
|
return t ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleHref(a: PublicArticle) {
|
||||||
|
return `/news/detail/${a.id}-${a.slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
articlesByTab: Record<NewsServicesTab, PublicArticle[]>;
|
||||||
|
showViews?: boolean;
|
||||||
|
exploreHref?: string;
|
||||||
|
exploreLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewsServicesArticleSection({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
articlesByTab,
|
||||||
|
showViews,
|
||||||
|
exploreHref = "#konten-terpopuler",
|
||||||
|
exploreLabel = "Lihat konten terpopuler",
|
||||||
|
}: Props) {
|
||||||
|
const defaultTab = NEWS_SERVICES_TAB_ORDER[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="py-20">
|
||||||
|
<div className="container mx-auto px-6">
|
||||||
|
<div className="mb-12 flex flex-col items-center">
|
||||||
|
<h2 className="mb-6 text-3xl font-bold">{title}</h2>
|
||||||
|
|
||||||
|
<Tabs defaultValue={defaultTab} className="w-full">
|
||||||
|
<div className="relative w-full pb-3">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<TabsList
|
||||||
|
className="gap-8 bg-transparent p-0"
|
||||||
|
variant={"line"}
|
||||||
|
>
|
||||||
|
{NEWS_SERVICES_TAB_ORDER.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab}
|
||||||
|
value={tab}
|
||||||
|
className="px-0 pb-2 data-[state=active]:text-[#b07c18]"
|
||||||
|
>
|
||||||
|
{NEWS_TAB_LABEL[tab]}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={exploreHref}
|
||||||
|
className="absolute top-1/2 right-0 hidden -translate-y-1/2 cursor-pointer text-sm text-muted-foreground hover:text-black md:block"
|
||||||
|
>
|
||||||
|
{exploreLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{NEWS_SERVICES_TAB_ORDER.map((tab) => (
|
||||||
|
<TabsContent key={tab} value={tab} className="mt-12">
|
||||||
|
<CardGrid
|
||||||
|
articles={articlesByTab[tab] ?? []}
|
||||||
|
showViews={showViews}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardGrid({
|
||||||
|
articles,
|
||||||
|
showViews,
|
||||||
|
}: {
|
||||||
|
articles: PublicArticle[];
|
||||||
|
showViews?: boolean;
|
||||||
|
}) {
|
||||||
|
if (articles.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
Belum ada konten yang dipublikasikan untuk tab ini.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{articles.map((item) => {
|
||||||
|
const badgeClass = BADGE_COLORS[item.id % BADGE_COLORS.length];
|
||||||
|
const category =
|
||||||
|
item.categoryName?.trim() || "Berita";
|
||||||
|
const tag = firstTag(item.tags);
|
||||||
|
const dateSrc = item.publishedAt || item.createdAt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={articleHref(item)}
|
||||||
|
className="block overflow-hidden rounded-2xl bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="relative h-[220px]">
|
||||||
|
<ArticleThumbnail
|
||||||
|
src={item.thumbnailUrl}
|
||||||
|
alt={item.title}
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
className={`${badgeClass} px-2 py-1 text-xs text-white`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{tag ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{tag}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(dateSrc)}
|
||||||
|
{showViews && item.viewCount != null ? (
|
||||||
|
<span className="ml-2">· {item.viewCount} tayangan</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="line-clamp-3 text-sm font-semibold leading-snug transition hover:text-[#b07c18]">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
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 {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Search, Plus, Eye, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getArticlePagination, deleteArticle } from "@/service/article";
|
||||||
|
import { formatDate } from "@/utils/global";
|
||||||
|
import { close, loading } from "@/config/swal";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import type { ArticleContentKind } from "@/constants/article-content-types";
|
||||||
|
import { ARTICLE_KIND_LABEL, articleListPath } from "@/constants/article-content-types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
kind: ArticleContentKind;
|
||||||
|
typeId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewsArticleList({ kind, typeId }: Props) {
|
||||||
|
const [articles, setArticles] = useState<any[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPage, setTotalPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [levelId, setLevelId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ulne = Cookies.get("ulne");
|
||||||
|
setLevelId(ulne);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSearch(search), 350);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
loading();
|
||||||
|
try {
|
||||||
|
const req = {
|
||||||
|
limit: "10",
|
||||||
|
page,
|
||||||
|
title: debouncedSearch,
|
||||||
|
source: "",
|
||||||
|
categoryId: null,
|
||||||
|
search: "",
|
||||||
|
sort: "desc",
|
||||||
|
sortBy: "created_at",
|
||||||
|
typeId,
|
||||||
|
};
|
||||||
|
const res = await getArticlePagination(req as any);
|
||||||
|
const payload = (res as any)?.data;
|
||||||
|
const data = payload?.data ?? [];
|
||||||
|
setArticles(Array.isArray(data) ? data : []);
|
||||||
|
setTotalPage(payload?.meta?.totalPage ?? 1);
|
||||||
|
} finally {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [page, debouncedSearch, typeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const getStatus = (article: any) => {
|
||||||
|
if (article.isDraft) return "Draft";
|
||||||
|
if (article.publishStatus?.toLowerCase() === "cancel") return "Pending";
|
||||||
|
if (article.isPublish) return "Published";
|
||||||
|
return "Pending";
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClass = (status: string) => {
|
||||||
|
const v = status.toLowerCase();
|
||||||
|
if (v === "published") return "bg-green-100 text-green-700";
|
||||||
|
if (v === "pending") return "bg-yellow-100 text-yellow-700";
|
||||||
|
if (v === "draft") return "bg-gray-200 text-gray-600";
|
||||||
|
return "bg-gray-200 text-gray-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
const ok = await Swal.fire({
|
||||||
|
icon: "warning",
|
||||||
|
title: "Delete this article?",
|
||||||
|
showCancelButton: true,
|
||||||
|
});
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
loading();
|
||||||
|
try {
|
||||||
|
const res = await deleteArticle(String(id));
|
||||||
|
if ((res as any)?.error) {
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "error",
|
||||||
|
title: "Delete failed",
|
||||||
|
text: String((res as any)?.message ?? ""),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchData();
|
||||||
|
await Swal.fire({ icon: "success", title: "Deleted", timer: 1200, showConfirmButton: false });
|
||||||
|
} finally {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = ARTICLE_KIND_LABEL[kind];
|
||||||
|
const basePath = articleListPath(kind);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-800">
|
||||||
|
News & Articles — {label}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
Create and manage {label.toLowerCase()} articles. Organize with tags only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{levelId === "3" && (
|
||||||
|
<Link href={`${basePath}/create`}>
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700 rounded-lg">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New {label} article
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-3 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by title..."
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl border shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="pl-3">Article</TableHead>
|
||||||
|
<TableHead>Tags</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{articles.length > 0 ? (
|
||||||
|
articles.map((article) => (
|
||||||
|
<TableRow key={article.id}>
|
||||||
|
<TableCell className="font-medium max-w-xs pl-3">
|
||||||
|
{article.title}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-slate-600 line-clamp-2">
|
||||||
|
{article.tags || "—"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{(() => {
|
||||||
|
const status = getStatus(article);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 text-xs rounded-full font-medium ${statusClass(
|
||||||
|
status,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(article.createdAt)}</TableCell>
|
||||||
|
<TableCell className="text-right space-x-1">
|
||||||
|
<Link href={`/admin/news-article/detail/${article.id}`}>
|
||||||
|
<Button size="icon" variant="ghost" type="button">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/admin/news-article/detail/${article.id}`}>
|
||||||
|
<Button size="icon" variant="ghost" type="button">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{levelId === "3" && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(article.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-slate-500">
|
||||||
|
No articles yet. Create one to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 border-t text-sm text-slate-500">
|
||||||
|
<p>
|
||||||
|
Page {page} of {totalPage}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="bg-blue-600">
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === totalPage}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,99 +1 @@
|
||||||
"use client";
|
export { default } from "@/components/content-type/content-type-filter-sidebar";
|
||||||
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
|
||||||
|
|
||||||
export default function FilterSidebar() {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
|
|
||||||
{/* HEADER */}
|
|
||||||
<div className="flex items-center justify-between pb-4 border-b">
|
|
||||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
|
||||||
Filter
|
|
||||||
</h3>
|
|
||||||
<ChevronLeft size={16} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CONTENT */}
|
|
||||||
<div className="space-y-6 mt-6">
|
|
||||||
{/* KATEGORI */}
|
|
||||||
<FilterSection title="Kategori">
|
|
||||||
<Checkbox label="Semua" count={1203} defaultChecked />
|
|
||||||
<Checkbox label="Berita Terhangat" count={123} />
|
|
||||||
<Checkbox label="Tentang Teknologi" count={24} />
|
|
||||||
<Checkbox label="Bersama Pelanggan" count={42} />
|
|
||||||
<Checkbox label="Pembicara Ahli" count={224} />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* JENIS FILE */}
|
|
||||||
<FilterSection title="Jenis File">
|
|
||||||
<Checkbox label="Semua" count={78} />
|
|
||||||
<Checkbox label="Audio Visual" count={120} defaultChecked />
|
|
||||||
<Checkbox label="Audio" count={34} />
|
|
||||||
<Checkbox label="Foto" count={234} />
|
|
||||||
<Checkbox label="Teks" count={9} />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* FORMAT */}
|
|
||||||
<FilterSection title="Format Audio Visual">
|
|
||||||
<Checkbox label="Semua" count={2} defaultChecked />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
{/* RESET */}
|
|
||||||
<div className="text-center pt-4">
|
|
||||||
<button className="text-sm text-[#966314] font-medium hover:underline">
|
|
||||||
Reset Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== COMPONENTS ===== */
|
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-3">{title}</p>
|
|
||||||
<div className="space-y-2">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Checkbox({
|
|
||||||
label,
|
|
||||||
count,
|
|
||||||
defaultChecked,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
defaultChecked?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex items-center justify-between text-sm cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={defaultChecked}
|
|
||||||
className="h-4 w-4 accent-[#966314]"
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-400">({count})</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Divider() {
|
|
||||||
return <div className="border-t border-gray-200"></div>;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/** Maps to `articles.type_id` in the database. Image historically used 1. */
|
||||||
|
export const ARTICLE_TYPE = {
|
||||||
|
IMAGE: 1,
|
||||||
|
TEXT: 2,
|
||||||
|
VIDEO: 3,
|
||||||
|
AUDIO: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ArticleContentKind = "text" | "image" | "video" | "audio";
|
||||||
|
|
||||||
|
export const ARTICLE_KIND_TO_TYPE_ID: Record<ArticleContentKind, number> = {
|
||||||
|
image: ARTICLE_TYPE.IMAGE,
|
||||||
|
text: ARTICLE_TYPE.TEXT,
|
||||||
|
video: ARTICLE_TYPE.VIDEO,
|
||||||
|
audio: ARTICLE_TYPE.AUDIO,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ARTICLE_KIND_LABEL: Record<ArticleContentKind, string> = {
|
||||||
|
text: "Text",
|
||||||
|
image: "Image",
|
||||||
|
video: "Video",
|
||||||
|
audio: "Audio",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function articleListPath(kind: ArticleContentKind): string {
|
||||||
|
return `/admin/news-article/${kind}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ARTICLE_TYPE } from "@/constants/article-content-types";
|
||||||
|
|
||||||
|
/** Public `/…/filter` routes — `document` maps to teks (type_id = 2). */
|
||||||
|
export const CONTENT_TYPE_FILTER = {
|
||||||
|
document: {
|
||||||
|
typeId: ARTICLE_TYPE.TEXT,
|
||||||
|
breadcrumb: "Dokumen",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
typeId: ARTICLE_TYPE.IMAGE,
|
||||||
|
breadcrumb: "Foto",
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
typeId: ARTICLE_TYPE.AUDIO,
|
||||||
|
breadcrumb: "Audio",
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
typeId: ARTICLE_TYPE.VIDEO,
|
||||||
|
breadcrumb: "Audio Visual",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ContentTypeFilterKey = keyof typeof CONTENT_TYPE_FILTER;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { ARTICLE_TYPE } from "@/constants/article-content-types";
|
||||||
|
|
||||||
|
/** Tabs on News & Services landing — maps to `articles.type_id`. */
|
||||||
|
export type NewsServicesTab = "audio-visual" | "audio" | "foto" | "teks";
|
||||||
|
|
||||||
|
export const NEWS_SERVICES_TAB_ORDER: NewsServicesTab[] = [
|
||||||
|
"audio-visual",
|
||||||
|
"audio",
|
||||||
|
"foto",
|
||||||
|
"teks",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Audio Visual → Video, Foto → Image, etc. */
|
||||||
|
export const NEWS_TAB_TO_TYPE_ID: Record<NewsServicesTab, number> = {
|
||||||
|
"audio-visual": ARTICLE_TYPE.VIDEO,
|
||||||
|
audio: ARTICLE_TYPE.AUDIO,
|
||||||
|
foto: ARTICLE_TYPE.IMAGE,
|
||||||
|
teks: ARTICLE_TYPE.TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NEWS_TAB_LABEL: Record<NewsServicesTab, string> = {
|
||||||
|
"audio-visual": "Audio Visual",
|
||||||
|
audio: "Audio",
|
||||||
|
foto: "Foto",
|
||||||
|
teks: "Teks",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
const DEFAULT_CLIENT_KEY = "9ca7f706-a8b0-4520-b467-5e8321df36fb";
|
||||||
|
|
||||||
|
function clientKey() {
|
||||||
|
return process.env.NEXT_PUBLIC_X_CLIENT_KEY ?? DEFAULT_CLIENT_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "");
|
||||||
|
return base ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Article shape returned by public GET /articles and GET /articles/:id (camelCase JSON). */
|
||||||
|
export type PublicArticle = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
htmlDescription?: string;
|
||||||
|
categoryName?: string;
|
||||||
|
typeId: number;
|
||||||
|
tags?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
viewCount?: number | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
isPublish?: boolean | null;
|
||||||
|
files?: Array<{
|
||||||
|
fileUrl?: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArticlesListResult = {
|
||||||
|
items: PublicArticle[];
|
||||||
|
meta?: { totalPage?: number; count?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchMode = "server" | "client";
|
||||||
|
|
||||||
|
async function articlesFetchJson(
|
||||||
|
path: string,
|
||||||
|
mode: FetchMode = "server",
|
||||||
|
): Promise<{
|
||||||
|
data: unknown;
|
||||||
|
meta?: ArticlesListResult["meta"];
|
||||||
|
} | null> {
|
||||||
|
const base = apiBase();
|
||||||
|
if (!base) return null;
|
||||||
|
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Client-Key": clientKey(),
|
||||||
|
},
|
||||||
|
...(mode === "server"
|
||||||
|
? { next: { revalidate: 60 } }
|
||||||
|
: { cache: "no-store" as RequestCache }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
data?: unknown;
|
||||||
|
meta?: ArticlesListResult["meta"];
|
||||||
|
};
|
||||||
|
return { data: json.data, meta: json.meta };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublishedArticles(
|
||||||
|
params: {
|
||||||
|
typeId?: number;
|
||||||
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sort?: string;
|
||||||
|
title?: string;
|
||||||
|
/** Partial match on `articles.tags` (comma-separated string in DB). */
|
||||||
|
tags?: string;
|
||||||
|
},
|
||||||
|
options?: { mode?: FetchMode },
|
||||||
|
): Promise<ArticlesListResult | null> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
sp.set("limit", String(params.limit ?? 8));
|
||||||
|
sp.set("page", String(params.page ?? 1));
|
||||||
|
sp.set("isPublish", "true");
|
||||||
|
sp.set("sortBy", params.sortBy ?? "created_at");
|
||||||
|
sp.set("sort", params.sort ?? "desc");
|
||||||
|
if (params.typeId != null) sp.set("typeId", String(params.typeId));
|
||||||
|
if (params.title) sp.set("title", params.title);
|
||||||
|
if (params.tags) sp.set("tags", params.tags);
|
||||||
|
const mode = options?.mode ?? "server";
|
||||||
|
const raw = await articlesFetchJson(`/articles?${sp.toString()}`, mode);
|
||||||
|
if (!raw) return null;
|
||||||
|
const items = Array.isArray(raw.data)
|
||||||
|
? (raw.data as PublicArticle[])
|
||||||
|
: [];
|
||||||
|
return { items, meta: raw.meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArticlePublic(
|
||||||
|
id: number,
|
||||||
|
options?: { mode?: FetchMode },
|
||||||
|
): Promise<PublicArticle | null> {
|
||||||
|
const mode = options?.mode ?? "server";
|
||||||
|
const raw = await articlesFetchJson(`/articles/${id}`, mode);
|
||||||
|
if (raw?.data == null || typeof raw.data !== "object") return null;
|
||||||
|
return raw.data as PublicArticle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count tags (comma-separated on articles) for a “popular tags” list. */
|
||||||
|
export function aggregateTagStats(
|
||||||
|
articles: PublicArticle[],
|
||||||
|
topN = 8,
|
||||||
|
): { name: string; count: number }[] {
|
||||||
|
const counts = new Map<string, { display: string; count: number }>();
|
||||||
|
for (const a of articles) {
|
||||||
|
if (!a.tags?.trim()) continue;
|
||||||
|
for (const raw of a.tags.split(",")) {
|
||||||
|
const display = raw.trim();
|
||||||
|
if (!display) continue;
|
||||||
|
const key = display.toLowerCase();
|
||||||
|
const cur = counts.get(key);
|
||||||
|
if (cur) cur.count += 1;
|
||||||
|
else counts.set(key, { display, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...counts.values()]
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, topN)
|
||||||
|
.map(({ display, count }) => ({ name: display, count }));
|
||||||
|
}
|
||||||
|
|
@ -47,8 +47,12 @@ export async function getArticlePagination(props: PaginationRequest) {
|
||||||
isBanner,
|
isBanner,
|
||||||
isPublish,
|
isPublish,
|
||||||
source,
|
source,
|
||||||
|
typeId,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const typeParam =
|
||||||
|
typeId !== undefined && typeId !== null ? `&typeId=${typeId}` : "";
|
||||||
|
|
||||||
return await httpGet(
|
return await httpGet(
|
||||||
`/articles?limit=${limit}&page=${page}&title=${title || ""}&startDate=${
|
`/articles?limit=${limit}&page=${page}&title=${title || ""}&startDate=${
|
||||||
startDate || ""
|
startDate || ""
|
||||||
|
|
@ -58,7 +62,7 @@ export async function getArticlePagination(props: PaginationRequest) {
|
||||||
sortBy || "created_at"
|
sortBy || "created_at"
|
||||||
}&sort=${sort || "asc"}&category=${categorySlug || ""}&isBanner=${
|
}&sort=${sort || "asc"}&category=${categorySlug || ""}&isBanner=${
|
||||||
isBanner || ""
|
isBanner || ""
|
||||||
}`,
|
}${typeParam}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -315,4 +315,6 @@ export type PaginationRequest = {
|
||||||
source?: string;
|
source?: string;
|
||||||
categorySlug?: string;
|
categorySlug?: string;
|
||||||
isBanner?: boolean;
|
isBanner?: boolean;
|
||||||
|
/** Filter by `articles.type_id` (text/image/video/audio). */
|
||||||
|
typeId?: number;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/** Safe for Server Components — keep separate from `global.tsx` (`"use client"`). */
|
||||||
|
export function formatDate(date: Date | string | null) {
|
||||||
|
if (!date) return "";
|
||||||
|
|
||||||
|
const parsedDate = typeof date === "string" ? new Date(date) : date;
|
||||||
|
|
||||||
|
if (isNaN(parsedDate.getTime())) return "";
|
||||||
|
|
||||||
|
const year = parsedDate.getFullYear();
|
||||||
|
const month = String(parsedDate.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(parsedDate.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
@ -165,16 +165,4 @@ export function convertDateFormatNoTime(date: Date): string {
|
||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date | string | null) {
|
export { formatDate } from "./format-date";
|
||||||
if (!date) return "";
|
|
||||||
|
|
||||||
const parsedDate = typeof date === "string" ? new Date(date) : date;
|
|
||||||
|
|
||||||
if (isNaN(parsedDate.getTime())) return "";
|
|
||||||
|
|
||||||
const year = parsedDate.getFullYear();
|
|
||||||
const month = String(parsedDate.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(parsedDate.getDate()).padStart(2, "0");
|
|
||||||
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue