);
}
diff --git a/app/(admin)/admin/news-article/image/page.tsx b/app/(admin)/admin/news-article/image/page.tsx
index e5ed094..8f5382f 100644
--- a/app/(admin)/admin/news-article/image/page.tsx
+++ b/app/(admin)/admin/news-article/image/page.tsx
@@ -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 (
-
-
+
);
}
-
return (
-
+
);
diff --git a/app/(admin)/admin/news-article/text/create/page.tsx b/app/(admin)/admin/news-article/text/create/page.tsx
new file mode 100644
index 0000000..94d4ec5
--- /dev/null
+++ b/app/(admin)/admin/news-article/text/create/page.tsx
@@ -0,0 +1,9 @@
+import CreateArticleForm from "@/components/form/article/create-article-form";
+
+export default function CreateNewsTextPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/(admin)/admin/news-article/text/page.tsx b/app/(admin)/admin/news-article/text/page.tsx
new file mode 100644
index 0000000..4dc1a2a
--- /dev/null
+++ b/app/(admin)/admin/news-article/text/page.tsx
@@ -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 (
+
+ );
+ }
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/(admin)/admin/news-article/video/create/page.tsx b/app/(admin)/admin/news-article/video/create/page.tsx
new file mode 100644
index 0000000..4b91217
--- /dev/null
+++ b/app/(admin)/admin/news-article/video/create/page.tsx
@@ -0,0 +1,9 @@
+import CreateArticleForm from "@/components/form/article/create-article-form";
+
+export default function CreateNewsVideoPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/(admin)/admin/news-article/video/page.tsx b/app/(admin)/admin/news-article/video/page.tsx
new file mode 100644
index 0000000..dec7f92
--- /dev/null
+++ b/app/(admin)/admin/news-article/video/page.tsx
@@ -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 (
+
+ );
+ }
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/audio/filter/page.tsx b/app/audio/filter/page.tsx
index 1be7bc6..2cb3aef 100644
--- a/app/audio/filter/page.tsx
+++ b/app/audio/filter/page.tsx
@@ -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 (
-
-
-
- {/* ===== TOP BAR ===== */}
-
- {/* ===== CONTENT ===== */}
-
- {/* Sidebar */}
-
-
-
-
- {/* Mobile Sidebar */}
- {openFilter && (
-
-
-
-
-
-
- )}
-
- {/* Cards */}
-
-
-
- Audio >
- Lihat Semua
- {"|"}
-
- Terdapat 1636 berita
-
-
-
-
- Urutkan:
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
-
-
- {/* ===== PAGINATION ===== */}
-
-
-
- ...
-
-
-
-
-
-
-
+
}>
+
}
+ />
+
);
}
diff --git a/app/document/filter/page.tsx b/app/document/filter/page.tsx
index 724a7d4..3fd1bfb 100644
--- a/app/document/filter/page.tsx
+++ b/app/document/filter/page.tsx
@@ -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 (
-
-
-
- {/* ===== TOP BAR ===== */}
-
- {/* ===== CONTENT ===== */}
-
- {/* Sidebar */}
-
-
-
-
- {/* Mobile Sidebar */}
- {openFilter && (
-
-
-
-
-
-
- )}
-
- {/* Cards */}
-
-
-
- Document >
- Lihat Semua
- {"|"}
-
- Terdapat 1636 berita
-
-
-
-
- Urutkan:
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
-
-
- {/* ===== PAGINATION ===== */}
-
-
-
- ...
-
-
-
-
-
-
-
+
}>
+
}
+ />
+
);
}
diff --git a/app/image/filter/page.tsx b/app/image/filter/page.tsx
index 274bb77..7d91722 100644
--- a/app/image/filter/page.tsx
+++ b/app/image/filter/page.tsx
@@ -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 (
-
-
-
-
-
-
-
-
- {openFilter && (
-
-
-
-
-
-
- )}
-
- {/* Cards */}
-
-
-
- Foto >
- Lihat Semua
- {"|"}
-
- Terdapat 1636 berita
-
-
-
-
- Urutkan:
-
-
-
-
-
- {Array.from({ length: 1 }).map((_, i) => (
-
- ))}
-
-
-
-
-
-
-
- ...
-
-
-
-
-
-
-
+
}>
+
}
+ />
+
);
}
diff --git a/app/news-services/page.tsx b/app/news-services/page.tsx
index 758747d..cd338a3 100644
--- a/app/news-services/page.tsx
+++ b/app/news-services/page.tsx
@@ -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
{
+ return {
+ "audio-visual": [],
+ audio: [],
+ foto: [],
+ teks: [],
+ };
+}
+
+async function loadArticlesForTabs(options: {
+ sortBy: string;
+ sort: string;
+ limit: number;
+ title?: string;
+}): Promise> {
+ 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 (
-
+
-
-
-
+
+
+
);
diff --git a/app/news/detail/[idSlug]/page.tsx b/app/news/detail/[idSlug]/page.tsx
new file mode 100644
index 0000000..a9a46e5
--- /dev/null
+++ b/app/news/detail/[idSlug]/page.tsx
@@ -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 {
+ 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 (
+
+
+
+
+ ← Kembali ke Berita & Layanan
+
+
+
+ {dateLabel}
+ {article.categoryName ? ` · ${article.categoryName}` : ""}
+
+
+
+ {article.title}
+
+
+
+
+ {article.typeId === ARTICLE_TYPE.VIDEO && mediaUrl ? (
+
+
+
+ ) : null}
+
+ {article.typeId === ARTICLE_TYPE.AUDIO && mediaUrl ? (
+
+ ) : null}
+
+ {article.description ? (
+
+ {article.description}
+
+ ) : null}
+
+ {article.htmlDescription ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/video/filter/page.tsx b/app/video/filter/page.tsx
index 2dbf37a..65a3199 100644
--- a/app/video/filter/page.tsx
+++ b/app/video/filter/page.tsx
@@ -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 (
-
-
-
- {/* ===== TOP BAR ===== */}
-
- {/* ===== CONTENT ===== */}
-
- {/* Sidebar */}
-
-
-
-
- {/* Mobile Sidebar */}
- {openFilter && (
-
-
-
-
-
-
- )}
-
- {/* Cards */}
-
-
-
- Audio Visual >
- Lihat Semua
- {"|"}
-
- Terdapat 1636 berita
-
-
-
-
- Urutkan:
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
-
-
- {/* ===== PAGINATION ===== */}
-
-
-
- ...
-
-
-
-
-
-
-
+ }>
+ }
+ />
+
);
}
diff --git a/components/audio/audio-card.tsx b/components/audio/audio-card.tsx
index bee72c9..36209d1 100644
--- a/components/audio/audio-card.tsx
+++ b/components/audio/audio-card.tsx
@@ -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 (
diff --git a/components/audio/filter-sidebar.tsx b/components/audio/filter-sidebar.tsx
index 202f459..82022ba 100644
--- a/components/audio/filter-sidebar.tsx
+++ b/components/audio/filter-sidebar.tsx
@@ -1,99 +1 @@
-"use client";
-
-import { ChevronLeft } from "lucide-react";
-
-export default function FilterAudioSidebar() {
- return (
-
- {/* HEADER */}
-
-
- Filter
-
-
-
-
- {/* CONTENT */}
-
- {/* KATEGORI */}
-
-
-
-
-
-
-
-
-
-
- {/* JENIS FILE */}
-
-
-
-
-
-
-
-
-
-
- {/* FORMAT */}
-
-
-
-
- {/* RESET */}
-
-
-
-
-
- );
-}
-
-/* ===== COMPONENTS ===== */
-
-function FilterSection({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
- );
-}
-
-function Checkbox({
- label,
- count,
- defaultChecked,
-}: {
- label: string;
- count: number;
- defaultChecked?: boolean;
-}) {
- return (
-
- );
-}
-
-function Divider() {
- return ;
-}
+export { default } from "@/components/content-type/content-type-filter-sidebar";
diff --git a/components/content-type/article-type-filter-page.tsx b/components/content-type/article-type-filter-page.tsx
new file mode 100644
index 0000000..871e2b7
--- /dev/null
+++ b/components/content-type/article-type-filter-page.tsx
@@ -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([]);
+ const [totalPage, setTotalPage] = useState(1);
+ const [totalCount, setTotalCount] = useState(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 (
+
+
+
+
+
+
+
+
+
{sidebar}
+
+ {openFilter && (
+
+
+ {sidebar}
+
+
+
+ )}
+
+
+
+
+ {config.breadcrumb} >
+ Lihat Semua
+ {"|"}
+
+ {totalCount != null
+ ? `Terdapat ${totalCount} konten`
+ : loading
+ ? "Memuat…"
+ : `Terdapat ${items.length} konten`}
+
+
+
+
+ Urutkan:
+
+
+
+
+
+ {loading ? (
+
Memuat…
+ ) : items.length === 0 ? (
+
+ Belum ada konten yang dipublikasikan.
+
+ ) : (
+
+ {items.map((article) => (
+
+ ))}
+
+ )}
+
+ {totalPage > 1 && !loading && items.length > 0 ? (
+
+
+
+ Halaman {page} / {totalPage}
+
+
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/components/content-type/content-type-filter-sidebar.tsx b/components/content-type/content-type-filter-sidebar.tsx
new file mode 100644
index 0000000..ebc854c
--- /dev/null
+++ b/components/content-type/content-type-filter-sidebar.tsx
@@ -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 (
+
+
+
Filter
+
+ Kategori tidak digunakan untuk artikel saat ini. Gunakan{" "}
+ kata kunci (judul)
+ dan tag yang sama
+ seperti di CMS.
+
+
+
+
+
+ );
+}
diff --git a/components/content-type/public-article-card.tsx b/components/content-type/public-article-card.tsx
new file mode 100644
index 0000000..2ff4245
--- /dev/null
+++ b/components/content-type/public-article-card.tsx
@@ -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 (
+
+
+
+
+
+
+ {category}
+
+ {tag ? (
+ {tag}
+ ) : null}
+
+
+
{dateLabel}
+
+
+ {article.title}
+
+
+
+ {article.description}
+
+
+
+ );
+}
diff --git a/components/document/filter-sidebar.tsx b/components/document/filter-sidebar.tsx
index 858e17d..82022ba 100644
--- a/components/document/filter-sidebar.tsx
+++ b/components/document/filter-sidebar.tsx
@@ -1,99 +1 @@
-"use client";
-
-import { ChevronLeft } from "lucide-react";
-
-export default function FilterDocumentSidebar() {
- return (
-
- {/* HEADER */}
-
-
- Filter
-
-
-
-
- {/* CONTENT */}
-
- {/* KATEGORI */}
-
-
-
-
-
-
-
-
-
-
- {/* JENIS FILE */}
-
-
-
-
-
-
-
-
-
-
- {/* FORMAT */}
-
-
-
-
- {/* RESET */}
-
-
-
-
-
- );
-}
-
-/* ===== COMPONENTS ===== */
-
-function FilterSection({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
- );
-}
-
-function Checkbox({
- label,
- count,
- defaultChecked,
-}: {
- label: string;
- count: number;
- defaultChecked?: boolean;
-}) {
- return (
-
- );
-}
-
-function Divider() {
- return ;
-}
+export { default } from "@/components/content-type/content-type-filter-sidebar";
diff --git a/components/form/article/create-article-form.tsx b/components/form/article/create-article-form.tsx
new file mode 100644
index 0000000..b077122
--- /dev/null
+++ b/components/form/article/create-article-form.tsx
@@ -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;
+
+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 {
+ 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([]);
+ const [tagInput, setTagInput] = useState("");
+ const [thumbnailImg, setThumbnailImg] = useState([]);
+ const [selectedMainImage, setSelectedMainImage] = useState(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();
+ 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({
+ 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 = {
+ 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 (
+
+ );
+ }
+ return (
+
+ {file.name.slice(0, 8)}…
+
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/components/form/article/edit-article-form.tsx b/components/form/article/edit-article-form.tsx
index b22d3b6..5982a48 100644
--- a/components/form/article/edit-article-form.tsx
+++ b/components/form/article/edit-article-form.tsx
@@ -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;
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 }) {