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