feat: update news & article in admin & landing page
continuous-integration/drone/push Build is passing Details

This commit is contained in:
hanif salafi 2026-04-11 03:03:46 +07:00
parent b6fa161efb
commit c55ac796aa
40 changed files with 2201 additions and 1278 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &nbsp; &gt; &nbsp;
<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 &gt;</button>
</div>
</div>
</section>
<Footer />
</div>
); );
} }

View File

@ -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 &nbsp; &gt; &nbsp;
<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 &gt;</button>
</div>
</div>
</section>
<Footer />
</div>
); );
} }

View File

@ -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 &nbsp; &gt; &nbsp;
<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 &gt;</button>
</div>
</div>
</section>
<Footer />
</div>
); );
} }

View File

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

View File

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

View File

@ -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 &nbsp; &gt; &nbsp;
<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 &gt;</button>
</div>
</div>
</section>
<Footer />
</div>
); );
} }

View File

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

View File

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

View File

@ -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} &nbsp; &gt; &nbsp;
<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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -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> </div>
{/* Right total */} <span className="text-lg text-gray-500">{item.count}</span>
<span className="text-gray-500 text-lg">{item.total}</span>
</div> </div>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
)}
</div> </div>
</section> </section>
); );

View File

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

View File

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

View File

@ -1,144 +1,139 @@
"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 <button
type="button"
onClick={headerPrev} onClick={headerPrev}
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" 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"
> >
<ChevronLeft /> <ChevronLeft />
</button> </button>
<button <button
type="button"
onClick={headerNext} 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" 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"
> >
<ChevronRight /> <ChevronRight />
</button> </button>
</>
) : null}
<div className="flex flex-col lg:flex-row items-center gap-14"> {slideCount === 0 ? (
{/* IMAGE */} <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 w-full lg:w-1/2">
<div className="relative h-[420px] rounded-3xl overflow-hidden"> <div className="relative h-[420px] overflow-hidden rounded-3xl">
<Image <ArticleThumbnail
src={data1[activeHeader].image} src={current.thumbnailUrl}
alt={data1[activeHeader].title} alt={current.title}
fill sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
priority
/> />
</div> </div>
{/* DOTS */} <div className="mt-4 flex justify-center gap-2">
<div className="flex justify-center gap-2 mt-4"> {slides.map((_, i) => (
{data1.map((_, i) => (
<span <span
key={i} key={i}
className={`h-2.5 rounded-full transition-all ${ className={`h-2.5 rounded-full transition-all ${
@ -151,55 +146,78 @@ export default function NewsAndServicesHeader() {
</div> </div>
</div> </div>
{/* CONTENT */}
<div className="w-full lg:w-1/2"> <div className="w-full lg:w-1/2">
<h1 className="text-4xl lg:text-5xl font-bold leading-tight mb-6"> <h1 className="mb-6 text-4xl font-bold leading-tight lg:text-5xl">
{data1[activeHeader].title} {current.title}
</h1> </h1>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500 mb-5"> <div className="mb-5 flex flex-wrap items-center gap-3 text-sm text-gray-500">
<span>{data1[activeHeader].date}</span> <span>
<span></span> {formatDate(current.publishedAt || current.createdAt)}
<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> </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> </div>
<p className="text-gray-700 leading-relaxed mb-8"> <p className="mb-8 leading-relaxed text-gray-700 line-clamp-5">
{data1[activeHeader].excerpt} {current.description}
</p> </p>
<button <div className="flex flex-wrap gap-3">
onClick={() => setOpen(true)} <Link
className="inline-flex items-center justify-center rounded-xl bg-[#b07c18] px-7 py-3 text-white font-medium hover:opacity-90 transition" 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 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> </button>
</div> </div>
</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"
}`} }`}
/> />

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

@ -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",
};

133
lib/articles-public.ts Normal file
View File

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

View File

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

View File

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

14
utils/format-date.ts Normal file
View File

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

View File

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