diff --git a/app/(admin)/admin/news-article/audio/create/page.tsx b/app/(admin)/admin/news-article/audio/create/page.tsx new file mode 100644 index 0000000..8a8604b --- /dev/null +++ b/app/(admin)/admin/news-article/audio/create/page.tsx @@ -0,0 +1,9 @@ +import CreateArticleForm from "@/components/form/article/create-article-form"; + +export default function CreateNewsAudioPage() { + return ( +
+ +
+ ); +} diff --git a/app/(admin)/admin/news-article/audio/page.tsx b/app/(admin)/admin/news-article/audio/page.tsx new file mode 100644 index 0000000..0c13086 --- /dev/null +++ b/app/(admin)/admin/news-article/audio/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 NewsArticleAudioPage() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + if (!mounted) { + return ( +
+
+
+ ); + } + return ( + +
+ +
+
+ ); +} diff --git a/app/(admin)/admin/news-article/detail/[id]/page.tsx b/app/(admin)/admin/news-article/detail/[id]/page.tsx new file mode 100644 index 0000000..9f84cc0 --- /dev/null +++ b/app/(admin)/admin/news-article/detail/[id]/page.tsx @@ -0,0 +1,9 @@ +import EditArticleForm from "@/components/form/article/edit-article-form"; + +export default function NewsArticleDetailPage() { + return ( +
+ +
+ ); +} diff --git a/app/(admin)/admin/news-article/image/create/page.tsx b/app/(admin)/admin/news-article/image/create/page.tsx index 65f6566..127c165 100644 --- a/app/(admin)/admin/news-article/image/create/page.tsx +++ b/app/(admin)/admin/news-article/image/create/page.tsx @@ -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 ( -
- +
+
); } 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 ( -
-

{title}

-
{children}
-
- ); -} - -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. +

+
+ +
+
+ +

+ Mencari di judul artikel. +

+ +
+ +
+ +

+ Cocokkan teks pada kolom tag (bisa sebagian). +

+ +
+ +
+ + + Reset filter + +
+
+
+ ); +} 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 ( -
-

{title}

-
{children}
-
- ); -} - -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 ( + {file.name} + ); + } + return ( +
+ {file.name.slice(0, 8)}… +
+ ); + }; + + return ( +
+
+

+ New {label} article +

+

+ Tags are used for organization. Categories and creator type are not used for News & Article. +

+ +

Title

+ ( + + )} + /> + {errors.title &&

{errors.title.message}

} + +

Slug

+ ( + + )} + /> + {errors.slug &&

{errors.slug.message}

} + +

Description

+ ( + + )} + /> + {errors.description && ( +

{errors.description.message}

+ )} + + {requireMedia && ( + <> +

Media files ({label})

+
+ +
+ +

Drop files here or click to upload

+

+ {contentKind === "image" && "Images: jpg, png, webp…"} + {contentKind === "video" && "Video: mp4, webm…"} + {contentKind === "audio" && "Audio: mp3, wav…"} +

+
+
+ {filesValidation &&

{filesValidation}

} + + {files.length > 0 && ( +
+ {files.map((file, index) => ( +
+
+ {renderPreview(file)} +
+
{file.name}
+ {file.type.startsWith("image") && ( + + )} +
+
+ +
+ ))} + +
+ )} + + )} +
+ +
+
+

Thumbnail

+

+ {requireMedia + ? "Optional separate image, or pick a gallery image as thumbnail above." + : "Optional cover image for listings."} +

+ { + const f = e.target.files?.[0]; + setThumbnailImg(f ? [f] : []); + e.target.value = ""; + }} + /> + {(thumbnailImg.length > 0 || (selectedMainImage && files[selectedMainImage - 1])) && ( +
+ + +
+ )} + {thumbnailValidation &&

{thumbnailValidation}

} + +

Tags

+ ( +
+
+ {value.map((item, index) => ( + + {item} + + + ))} +
+