feat(content): public Blog + Learn sections and static CMS pages (full-stack)
Adds the missing public-facing content pages and their admin authoring, all powered by the existing content-svc Blog entity discriminated by `kind`. Backend (content-svc): - BlogKind enum += Learn, Page (reuses Blog CRUD/SEO/slug/publish for all three). - SQL migration services/content/migrations/001_blog_kind_learn_page.sql (ALTER TYPE content.blog_kind ADD VALUE 'Learn','Page'). Frontend (public, Next.js): - lib/content-api.ts: fetchArticles(kind) / fetchArticle(slug) / fetchPage(slug) with safe empty/null fallbacks. - components/content: article-ui (card/list/detail + RTL prose), CmsPageContent, CmsRoute (admin-authored page or localized built-in fallback copy). - Routes: /blog, /blog/[slug], /learn, /learn/[slug] and static pages /about /contact /careers /privacy /terms /cookies /help. - Navbar "tutorials" → /learn; all footer links now resolve. Admin: - AdminResource: new `fixedValues` option (injects kind on create/update). - learnConfig (kind=Learn) + pagesConfig (kind=Page) reuse the /v1/blogs endpoint; /admin/learn + /admin/pages routes + nav items. i18n: blog, learn and 7 *Page namespaces added to both fa.json and en.json (verified key parity); admin nav labels learn/pages. Frontend tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("aboutPage");
|
||||
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/about" });
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<CmsRoute slug="about" ns="aboutPage" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export default async function AdminLayout({
|
||||
{ href: "/admin/fonts", label: t("fonts") },
|
||||
{ href: "/admin/music", label: t("music") },
|
||||
{ href: "/admin/blogs", label: t("blogs") },
|
||||
{ href: "/admin/learn", label: t("learn") },
|
||||
{ href: "/admin/pages", label: t("pages") },
|
||||
{ href: "/admin/slides", label: t("slides") },
|
||||
{ href: "/admin/home-events", label: t("homeEvents") },
|
||||
{ href: "/admin/routes", label: t("routes") },
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AdminResource } from "@/components/admin/AdminResource";
|
||||
import { learnConfig } from "@/components/admin/admin-resources";
|
||||
|
||||
export default function Page() {
|
||||
return <AdminResource config={learnConfig} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AdminResource } from "@/components/admin/AdminResource";
|
||||
import { pagesConfig } from "@/components/admin/admin-resources";
|
||||
|
||||
export default function Page() {
|
||||
return <AdminResource config={pagesConfig} />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ArticleDetailContent } from "@/components/content/article-ui";
|
||||
import { fetchArticle } from "@/lib/content-api";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const a = await fetchArticle(slug);
|
||||
if (!a) return {};
|
||||
return {
|
||||
title: a.metaTitle || a.title,
|
||||
description: a.metaDescription || a.shortDescription || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const a = await fetchArticle(slug);
|
||||
if (!a || !a.isPublished) notFound();
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<ArticleDetailContent section="blog" article={a} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { ArticleListContent } from "@/components/content/article-ui";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
import { fetchArticles } from "@/lib/content-api";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("blog");
|
||||
return createPageMetadata({
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
path: "/blog",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogPage() {
|
||||
const { items } = await fetchArticles("Blog", { pageSize: 24 });
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<ArticleListContent section="blog" articles={items} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("careersPage");
|
||||
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/careers" });
|
||||
}
|
||||
|
||||
export default function CareersPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<CmsRoute slug="careers" ns="careersPage" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("contactPage");
|
||||
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/contact" });
|
||||
}
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<CmsRoute slug="contact" ns="contactPage" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("cookiesPage");
|
||||
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/cookies" });
|
||||
}
|
||||
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<CmsRoute slug="cookies" ns="cookiesPage" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("helpPage");
|
||||
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/help" });
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<CmsRoute slug="help" ns="helpPage" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ArticleDetailContent } from "@/components/content/article-ui";
|
||||
import { fetchArticle } from "@/lib/content-api";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const a = await fetchArticle(slug);
|
||||
if (!a) return {};
|
||||
return {
|
||||
title: a.metaTitle || a.title,
|
||||
description: a.metaDescription || a.shortDescription || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LearnDetailPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const a = await fetchArticle(slug);
|
||||
if (!a || !a.isPublished) notFound();
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<ArticleDetailContent section="learn" article={a} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { ArticleListContent } from "@/components/content/article-ui";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
import { fetchArticles } from "@/lib/content-api";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("learn");
|
||||
return createPageMetadata({
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
path: "/learn",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function LearnPage() {
|
||||
const { items } = await fetchArticles("Learn", { pageSize: 24 });
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<ArticleListContent section="learn" articles={items} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("privacyPage");
|
||||
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/privacy" });
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<CmsRoute slug="privacy" ns="privacyPage" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("termsPage");
|
||||
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/terms" });
|
||||
}
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<CmsRoute slug="terms" ns="termsPage" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface ResourceConfig {
|
||||
idKey?: string; // default "id"
|
||||
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
|
||||
listQuery?: string; // extra query string appended to the list fetch, e.g. "includeInactive=true"
|
||||
fixedValues?: Record<string, string | number | boolean>; // always sent on create/update + seeded into the form (e.g. { kind: "Learn" })
|
||||
columns: ColumnDef[];
|
||||
fields?: FieldDef[];
|
||||
canCreate?: boolean;
|
||||
@@ -108,6 +109,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
const openCreate = () => {
|
||||
const init: Record<string, unknown> = {};
|
||||
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
|
||||
if (config.fixedValues) Object.assign(init, config.fixedValues);
|
||||
setForm(init);
|
||||
setSlugTouched(false); // new record → keep syncing slug from name
|
||||
setCreating(true);
|
||||
@@ -137,7 +139,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
|
||||
method: isEdit ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
body: JSON.stringify({ ...config.fixedValues, ...form }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.error ?? "Save failed");
|
||||
|
||||
@@ -171,6 +171,61 @@ export const blogsConfig: ResourceConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
export const learnConfig: ResourceConfig = {
|
||||
title: "مقالات آموزشی",
|
||||
description: "آموزشها و راهنماهای گامبهگام که در بخش «آموزش» سایت نمایش داده میشوند.",
|
||||
basePath: "blogs",
|
||||
listQuery: "kind=Learn&pageSize=500&page_size=500",
|
||||
listKey: "items",
|
||||
fixedValues: { kind: "Learn" },
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "title", label: "عنوان" },
|
||||
{ key: "slug", label: "اسلاگ" },
|
||||
{ key: "is_published", label: "انتشار", render: (r) => badge(!!r.is_published, "منتشرشده", "پیشنویس") },
|
||||
{ key: "view_count", label: "بازدید" },
|
||||
],
|
||||
fields: [
|
||||
{ key: "title", label: "عنوان", required: true },
|
||||
{ key: "slug", label: "اسلاگ (نشانی)", required: true },
|
||||
{ key: "short_description", label: "توضیح کوتاه", type: "textarea" },
|
||||
{ key: "content", label: "محتوا", type: "richtext", required: true },
|
||||
{ key: "cover", label: "تصویر شاخص", type: "image" },
|
||||
{ key: "meta_title", label: "عنوان متا" },
|
||||
{ key: "meta_description", label: "توضیحات متا", type: "textarea" },
|
||||
{ key: "meta_keywords", label: "کلمات کلیدی متا" },
|
||||
{ key: "is_published", label: "منتشرشده", type: "checkbox", defaultValue: true },
|
||||
{ key: "include_in_site_map", label: "نمایش در نقشهٔ سایت", type: "checkbox", defaultValue: true },
|
||||
],
|
||||
};
|
||||
|
||||
export const pagesConfig: ResourceConfig = {
|
||||
title: "برگههای سایت",
|
||||
description: "برگههای ثابت مانند «دربارهٔ ما»، «حریم خصوصی» و «شرایط استفاده». اسلاگ باید دقیقاً با نشانی برگه یکی باشد: about، contact، careers، privacy، terms، cookies، help.",
|
||||
basePath: "blogs",
|
||||
listQuery: "kind=Page&pageSize=500&page_size=500",
|
||||
listKey: "items",
|
||||
fixedValues: { kind: "Page", is_published: true, include_in_site_map: true },
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "title", label: "عنوان" },
|
||||
{ key: "slug", label: "اسلاگ (نشانی برگه)" },
|
||||
{ key: "is_published", label: "انتشار", render: (r) => badge(!!r.is_published, "منتشرشده", "پیشنویس") },
|
||||
],
|
||||
fields: [
|
||||
{ key: "title", label: "عنوان", required: true },
|
||||
{ key: "slug", label: "اسلاگ (مثلاً about یا privacy)", required: true },
|
||||
{ key: "content", label: "محتوای برگه", type: "richtext", required: true },
|
||||
{ key: "meta_title", label: "عنوان متا" },
|
||||
{ key: "meta_description", label: "توضیحات متا", type: "textarea" },
|
||||
{ key: "is_published", label: "منتشرشده", type: "checkbox", defaultValue: true },
|
||||
],
|
||||
};
|
||||
|
||||
export const slidesConfig: ResourceConfig = {
|
||||
title: "اسلایدهای صفحه اصلی",
|
||||
description: "اسلایدهای هیرو/تبلیغاتی در صفحهٔ اصلی.",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { articleProse } from "@/components/content/article-ui";
|
||||
|
||||
/**
|
||||
* Renders a static CMS page (About, Privacy, Terms, …). The body is admin-authored
|
||||
* HTML when a matching `kind = Page` row exists; otherwise the caller passes
|
||||
* built-in `fallbackHtml` so the page is never blank.
|
||||
*/
|
||||
export function CmsPageContent({
|
||||
title,
|
||||
html,
|
||||
lead,
|
||||
}: {
|
||||
title: string;
|
||||
html: string;
|
||||
lead?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-14 sm:px-6 lg:px-8">
|
||||
<h1 className="font-heading text-3xl font-extrabold tracking-tight text-neutral-900 sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{lead && <p className="mt-4 text-lg leading-relaxed text-neutral-500">{lead}</p>}
|
||||
<div className={`mt-8 ${articleProse}`} dir="auto" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { CmsPageContent } from "@/components/content/CmsPageContent";
|
||||
import { fetchPage } from "@/lib/content-api";
|
||||
|
||||
/** Turn a plain-text fallback (paragraphs split by blank lines) into simple HTML. */
|
||||
function textToHtml(text: string): string {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => `<p>${p.trim().replace(/\n/g, "<br/>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Server component for a static CMS page. Renders the admin-authored `kind = Page`
|
||||
* row for `slug` when present; otherwise falls back to the localized built-in copy
|
||||
* (title / lead / body) from the `ns` message namespace, so the page is never blank.
|
||||
*/
|
||||
export async function CmsRoute({ slug, ns }: { slug: string; ns: string }) {
|
||||
const t = await getTranslations(ns);
|
||||
const page = await fetchPage(slug);
|
||||
const title = page?.title || t("title");
|
||||
const html = page?.content || textToHtml(t("body"));
|
||||
return <CmsPageContent title={title} html={html} lead={page ? undefined : t("lead")} />;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import type { Article } from "@/lib/content-api";
|
||||
|
||||
/** RTL-aware prose styling for admin-authored HTML bodies (no typography plugin). */
|
||||
export const articleProse =
|
||||
"max-w-none text-[15px] leading-8 text-neutral-700 " +
|
||||
"[&_h2]:mb-3 [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:text-neutral-900 " +
|
||||
"[&_h3]:mb-2 [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-neutral-900 " +
|
||||
"[&_p]:mb-4 [&_a]:text-blue-600 [&_a]:underline " +
|
||||
"[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:mb-1 " +
|
||||
"[&_img]:my-6 [&_img]:rounded-xl [&_blockquote]:my-4 [&_blockquote]:border-s-4 [&_blockquote]:border-blue-200 [&_blockquote]:ps-4 [&_blockquote]:text-neutral-500";
|
||||
|
||||
function formatDate(iso: string | undefined, locale: string): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, section }: { article: Article; section: "blog" | "learn" }) {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations(section);
|
||||
const href = `/${section}/${article.slug}`;
|
||||
const img = article.cover || article.image;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="relative aspect-[16/9] w-full overflow-hidden bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
{img ? (
|
||||
<Image
|
||||
src={img}
|
||||
alt={article.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-3xl font-bold text-blue-300">
|
||||
{article.title.slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<h3 className="font-heading text-lg font-bold leading-snug text-neutral-900 group-hover:text-blue-600">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.shortDescription && (
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-relaxed text-neutral-500">
|
||||
{article.shortDescription}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between pt-2 text-xs text-neutral-400">
|
||||
<span>{formatDate(article.publishDate || article.createdAt, locale)}</span>
|
||||
<span className="font-medium text-blue-600 group-hover:underline">{t("readMore")} →</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleListContent({
|
||||
section,
|
||||
articles,
|
||||
}: {
|
||||
section: "blog" | "learn";
|
||||
articles: Article[];
|
||||
}) {
|
||||
const t = useTranslations(section);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8">
|
||||
<header className="mx-auto max-w-2xl text-center">
|
||||
<h1 className="font-heading text-3xl font-extrabold tracking-tight text-neutral-900 sm:text-4xl">
|
||||
{t("pageTitle")}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg leading-relaxed text-neutral-500">{t("pageDescription")}</p>
|
||||
</header>
|
||||
|
||||
{articles.length === 0 ? (
|
||||
<p className="mt-16 text-center text-neutral-400">{t("empty")}</p>
|
||||
) : (
|
||||
<div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{articles.map((a) => (
|
||||
<ArticleCard key={a.id} article={a} section={section} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleDetailContent({
|
||||
section,
|
||||
article,
|
||||
}: {
|
||||
section: "blog" | "learn";
|
||||
article: Article;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations(section);
|
||||
const img = article.cover || article.image;
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-4 py-14 sm:px-6 lg:px-8">
|
||||
<Link
|
||||
href={`/${section}`}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:underline"
|
||||
>
|
||||
← {t("pageTitle")}
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-6 font-heading text-3xl font-extrabold leading-tight tracking-tight text-neutral-900 sm:text-4xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-neutral-400">
|
||||
{article.authorDisplayName && <span>{article.authorDisplayName}</span>}
|
||||
<span>{formatDate(article.publishDate || article.createdAt, locale)}</span>
|
||||
<span>
|
||||
{Number(article.viewCount ?? 0).toLocaleString(locale === "fa" ? "fa-IR" : "en-US")}{" "}
|
||||
{t("views")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{article.shortDescription && (
|
||||
<p className="mt-6 text-lg leading-relaxed text-neutral-600">{article.shortDescription}</p>
|
||||
)}
|
||||
|
||||
{img && (
|
||||
<div className="relative mt-8 aspect-[16/9] w-full overflow-hidden rounded-2xl bg-gray-100">
|
||||
<Image src={img} alt={article.title} fill sizes="(max-width: 768px) 100vw, 768px" className="object-cover" priority />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`mt-8 ${articleProse}`}
|
||||
dir="auto"
|
||||
dangerouslySetInnerHTML={{ __html: article.content || "" }}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export function Navbar({ user }: { user?: NavUser | null }) {
|
||||
|
||||
const learnItems = [
|
||||
{ label: t("learnItems.blog"), href: "/blog" },
|
||||
{ label: t("learnItems.tutorials"), href: "/tutorials" },
|
||||
{ label: t("learnItems.tutorials"), href: "/learn" },
|
||||
{ label: t("learnItems.help"), href: "/help" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Server-side reads for editorial content (Blog, Learn) and CMS pages from the
|
||||
* FlatRender V2 API gateway.
|
||||
*
|
||||
* All three are backed by the content service's `Blog` entity, discriminated by
|
||||
* `kind` (Blog | Learn | Page). Articles (Blog/Learn) are listed + read by slug;
|
||||
* CMS pages (About, Privacy, …) are single rows fetched by their slug.
|
||||
*
|
||||
* Every function returns a safe fallback (empty array / null) when the gateway is
|
||||
* unset or unreachable, so the site still renders standalone.
|
||||
*/
|
||||
|
||||
import { gatewayUrl } from "@/lib/api/gateway";
|
||||
|
||||
export type ArticleKind = "Blog" | "Learn" | "Page";
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
shortDescription?: string;
|
||||
content: string;
|
||||
image?: string;
|
||||
cover?: string;
|
||||
authorDisplayName?: string;
|
||||
isPublished: boolean;
|
||||
publishDate?: string;
|
||||
viewCount: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
metaKeywords?: string;
|
||||
}
|
||||
|
||||
// ── V2 content-service shapes (snake_case JSON) ───────────────────────────────
|
||||
|
||||
interface V2BlogSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
short_description?: string | null;
|
||||
image?: string | null;
|
||||
cover?: string | null;
|
||||
author_display_name?: string | null;
|
||||
is_published: boolean;
|
||||
publish_date?: string | null;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface V2BlogDetail extends V2BlogSummary {
|
||||
content: string;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
meta_keywords?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
interface V2Paged<T> {
|
||||
items: T[];
|
||||
meta?: { page: number; page_size: number; total: number; total_pages: number };
|
||||
}
|
||||
|
||||
function mapArticle(b: V2BlogSummary | V2BlogDetail): Article {
|
||||
const d = b as V2BlogDetail;
|
||||
return {
|
||||
id: b.id,
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
shortDescription: b.short_description ?? undefined,
|
||||
content: d.content ?? "",
|
||||
image: b.image ?? undefined,
|
||||
cover: b.cover ?? undefined,
|
||||
authorDisplayName: b.author_display_name ?? undefined,
|
||||
isPublished: b.is_published,
|
||||
publishDate: b.publish_date ?? undefined,
|
||||
viewCount: b.view_count ?? 0,
|
||||
createdAt: b.created_at,
|
||||
updatedAt: d.updated_at ?? undefined,
|
||||
metaTitle: d.meta_title ?? undefined,
|
||||
metaDescription: d.meta_description ?? undefined,
|
||||
metaKeywords: d.meta_keywords ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeGet<T>(path: string, revalidate = 60): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(gatewayUrl(path), {
|
||||
next: { revalidate },
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArticleListResult {
|
||||
items: Article[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** Published articles of a given kind (Blog or Learn), newest first. */
|
||||
export async function fetchArticles(
|
||||
kind: ArticleKind,
|
||||
opts?: { page?: number; pageSize?: number; search?: string },
|
||||
): Promise<ArticleListResult> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("kind", kind);
|
||||
params.set("isPublished", "true");
|
||||
if (opts?.page) params.set("page", String(opts.page));
|
||||
params.set("pageSize", String(opts?.pageSize ?? 24));
|
||||
if (opts?.search) params.set("search", opts.search);
|
||||
|
||||
const data = await safeGet<V2Paged<V2BlogSummary>>(`/v1/blogs?${params.toString()}`);
|
||||
if (!data?.items) return { items: [], total: 0, page: 1, pageSize: opts?.pageSize ?? 24 };
|
||||
return {
|
||||
items: data.items.map(mapArticle),
|
||||
total: data.meta?.total ?? data.items.length,
|
||||
page: data.meta?.page ?? 1,
|
||||
pageSize: data.meta?.page_size ?? (opts?.pageSize ?? 24),
|
||||
};
|
||||
}
|
||||
|
||||
/** A single article by slug (Blog or Learn). Returns null if not found. */
|
||||
export async function fetchArticle(slug: string): Promise<Article | null> {
|
||||
const data = await safeGet<V2BlogDetail>(`/v1/blogs/${encodeURIComponent(slug)}`);
|
||||
return data ? mapArticle(data) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A CMS page (kind = Page) by its well-known slug, e.g. "about" / "privacy".
|
||||
* Pages are looked up by the same slug endpoint; returns null when unset so the
|
||||
* caller can fall back to built-in default copy.
|
||||
*/
|
||||
export async function fetchPage(slug: string): Promise<Article | null> {
|
||||
const data = await safeGet<V2BlogDetail>(`/v1/blogs/${encodeURIComponent(slug)}`, 300);
|
||||
return data ? mapArticle(data) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user