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:
soroush.asadi
2026-06-11 22:43:25 +03:30
parent 6cf8716d7e
commit c92de06c28
25 changed files with 802 additions and 3 deletions
+55
View File
@@ -187,6 +187,59 @@
"socialLinkedIn": "LinkedIn", "socialLinkedIn": "LinkedIn",
"socialYouTube": "YouTube" "socialYouTube": "YouTube"
}, },
"blog": {
"metaTitle": "FlatRender Blog",
"metaDescription": "Articles, news and guides on making videos and images.",
"pageTitle": "Blog",
"pageDescription": "The latest articles, news and ideas on creating videos and images.",
"readMore": "Read more",
"views": "views",
"empty": "No articles have been published yet."
},
"learn": {
"metaTitle": "Learn FlatRender",
"metaDescription": "Step-by-step tutorials for making professional videos and images.",
"pageTitle": "Learn",
"pageDescription": "Step-by-step tutorials and practical guides for getting the most out of FlatRender.",
"readMore": "View tutorial",
"views": "views",
"empty": "No tutorials have been published yet."
},
"aboutPage": {
"title": "About Us",
"lead": "FlatRender makes professional video and image creation simple for everyone.",
"body": "FlatRender is an online platform for creating videos and images with ready-made templates and smart tools.\n\nOur mission is to let anyone create professional content in minutes — no design or motion-graphics expertise required."
},
"contactPage": {
"title": "Contact Us",
"lead": "We'd love to hear from you.",
"body": "Reach out for support, partnerships, or any questions.\n\nEmail: support@flatrender.com"
},
"careersPage": {
"title": "Careers",
"lead": "Join the FlatRender team.",
"body": "We're always looking for talented, passionate people.\n\nTo learn about open positions, send your resume to jobs@flatrender.com."
},
"privacyPage": {
"title": "Privacy Policy",
"lead": "How we store and use your information.",
"body": "Your privacy matters to us. This page explains how we collect, use and protect your information.\n\nThis is placeholder text and should be completed by an administrator before final publication."
},
"termsPage": {
"title": "Terms of Service",
"lead": "The rules for using FlatRender.",
"body": "By using FlatRender you agree to the following terms.\n\nThis is placeholder text and should be completed by an administrator before final publication."
},
"cookiesPage": {
"title": "Cookie Policy",
"lead": "How we use cookies.",
"body": "We use cookies to improve your experience on the site.\n\nThis is placeholder text and should be completed by an administrator before final publication."
},
"helpPage": {
"title": "Help Center",
"lead": "Answers to common questions and usage guides.",
"body": "Welcome to the FlatRender Help Center.\n\nFor more questions, check the Learn section or contact support."
},
"auth": { "auth": {
"signIn": "Sign In", "signIn": "Sign In",
"signUp": "Sign Up", "signUp": "Sign Up",
@@ -324,6 +377,8 @@
"tags": "Tags", "tags": "Tags",
"fonts": "Fonts", "fonts": "Fonts",
"blogs": "Blog", "blogs": "Blog",
"learn": "Tutorials",
"pages": "Pages",
"slides": "Slides", "slides": "Slides",
"users": "Users", "users": "Users",
"plans": "Plans", "plans": "Plans",
+55
View File
@@ -187,6 +187,59 @@
"socialLinkedIn": "لینکدین", "socialLinkedIn": "لینکدین",
"socialYouTube": "یوتیوب" "socialYouTube": "یوتیوب"
}, },
"blog": {
"metaTitle": "وبلاگ فلت‌رندر",
"metaDescription": "مقاله‌ها، خبرها و راهنماهای ساخت ویدیو و تصویر.",
"pageTitle": "وبلاگ",
"pageDescription": "تازه‌ترین مقاله‌ها، خبرها و ایده‌ها دربارهٔ ساخت ویدیو و تصویر.",
"readMore": "ادامهٔ مطلب",
"views": "بازدید",
"empty": "هنوز مقاله‌ای منتشر نشده است."
},
"learn": {
"metaTitle": "آموزش فلت‌رندر",
"metaDescription": "آموزش‌های گام‌به‌گام برای ساخت ویدیو و تصویر حرفه‌ای.",
"pageTitle": "آموزش",
"pageDescription": "آموزش‌های گام‌به‌گام و راهنماهای کاربردی برای استفاده از فلت‌رندر.",
"readMore": "مشاهدهٔ آموزش",
"views": "بازدید",
"empty": "هنوز آموزشی منتشر نشده است."
},
"aboutPage": {
"title": "دربارهٔ ما",
"lead": "فلت‌رندر، ساخت ویدیو و تصویر حرفه‌ای را برای همه ساده می‌کند.",
"body": "فلت‌رندر یک پلتفرم آنلاین برای ساخت ویدیو و تصویر با کمک قالب‌های آماده و ابزارهای هوشمند است.\n\nمأموریت ما این است که هر کسی، بدون نیاز به دانش تخصصی طراحی یا موشن‌گرافیک، بتواند در چند دقیقه محتوای حرفه‌ای بسازد."
},
"contactPage": {
"title": "تماس با ما",
"lead": "خوشحال می‌شویم از شما بشنویم.",
"body": "برای پشتیبانی، همکاری یا هر پرسشی با ما در تماس باشید.\n\nایمیل: support@flatrender.com"
},
"careersPage": {
"title": "فرصت‌های شغلی",
"lead": "به تیم فلت‌رندر بپیوندید.",
"body": "ما همیشه به دنبال افراد بااستعداد و علاقه‌مند هستیم.\n\nبرای آگاهی از موقعیت‌های شغلی، رزومهٔ خود را به jobs@flatrender.com ارسال کنید."
},
"privacyPage": {
"title": "حریم خصوصی",
"lead": "نحوهٔ نگهداری و استفادهٔ ما از اطلاعات شما.",
"body": "حفظ حریم خصوصی شما برای ما اهمیت دارد. این صفحه نحوهٔ گردآوری، استفاده و محافظت از اطلاعات شما را توضیح می‌دهد.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
},
"termsPage": {
"title": "شرایط استفاده",
"lead": "قوانین استفاده از خدمات فلت‌رندر.",
"body": "با استفاده از فلت‌رندر، شرایط زیر را می‌پذیرید.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
},
"cookiesPage": {
"title": "سیاست کوکی",
"lead": "نحوهٔ استفادهٔ ما از کوکی‌ها.",
"body": "ما از کوکی‌ها برای بهبود تجربهٔ شما در سایت استفاده می‌کنیم.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
},
"helpPage": {
"title": "مرکز راهنما",
"lead": "پاسخ پرسش‌های پرتکرار و راهنمای استفاده.",
"body": "به مرکز راهنمای فلت‌رندر خوش آمدید.\n\nبرای پرسش‌های بیشتر، بخش آموزش را ببینید یا با پشتیبانی تماس بگیرید."
},
"auth": { "auth": {
"signIn": "ورود", "signIn": "ورود",
"signUp": "ثبت‌نام", "signUp": "ثبت‌نام",
@@ -324,6 +377,8 @@
"tags": "برچسب‌ها", "tags": "برچسب‌ها",
"fonts": "فونت‌ها", "fonts": "فونت‌ها",
"blogs": "بلاگ", "blogs": "بلاگ",
"learn": "آموزش‌ها",
"pages": "برگه‌ها",
"slides": "اسلایدها", "slides": "اسلایدها",
"users": "کاربران", "users": "کاربران",
"plans": "پلن‌ها", "plans": "پلن‌ها",
@@ -13,6 +13,6 @@ public enum JustifyKind { LEFT_JUSTIFY, CENTER_JUSTIFY, RIGHT_JUSTIFY, FULL_JUST
public enum AiInputType { None, TitleSuggest, BodySuggest, TranslateRtl, TranslateLtr, RemoveBG, UpscaleImage, TTS } public enum AiInputType { None, TitleSuggest, BodySuggest, TranslateRtl, TranslateLtr, RemoveBG, UpscaleImage, TTS }
public enum RepeatSortStrategy { Manual, Alphabetical, Numerical, InsertOrder } public enum RepeatSortStrategy { Manual, Alphabetical, Numerical, InsertOrder }
public enum AttrValueKind { fill, stroke, tracking, dropshadow } public enum AttrValueKind { fill, stroke, tracking, dropshadow }
public enum BlogKind { Blog, Landing } public enum BlogKind { Blog, Landing, Learn, Page }
public enum SlideType { Hero, Promo, Tutorial, Category, Custom } public enum SlideType { Hero, Promo, Tutorial, Category, Custom }
public enum ContainerFavoriteKind { Container } public enum ContainerFavoriteKind { Container }
@@ -0,0 +1,9 @@
-- Adds the Learn + Page values to the content.blog_kind enum so the Blog entity
-- can power the public Learn (tutorials) section and the static CMS pages
-- (About, Privacy, Terms, …) in addition to Blog/Landing.
--
-- Idempotent. ADD VALUE cannot run inside a transaction on older PG, so apply
-- each statement on its own (psql autocommit):
-- docker exec fr2-postgres sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d flatrender -f -' < 001_blog_kind_learn_page.sql
ALTER TYPE content.blog_kind ADD VALUE IF NOT EXISTS 'Learn';
ALTER TYPE content.blog_kind ADD VALUE IF NOT EXISTS 'Page';
+20
View File
@@ -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>
);
}
+2
View File
@@ -33,6 +33,8 @@ export default async function AdminLayout({
{ href: "/admin/fonts", label: t("fonts") }, { href: "/admin/fonts", label: t("fonts") },
{ href: "/admin/music", label: t("music") }, { href: "/admin/music", label: t("music") },
{ href: "/admin/blogs", label: t("blogs") }, { 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/slides", label: t("slides") },
{ href: "/admin/home-events", label: t("homeEvents") }, { href: "/admin/home-events", label: t("homeEvents") },
{ href: "/admin/routes", label: t("routes") }, { href: "/admin/routes", label: t("routes") },
+8
View File
@@ -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} />;
}
+8
View File
@@ -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} />;
}
+32
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+3 -1
View File
@@ -30,6 +30,7 @@ export interface ResourceConfig {
idKey?: string; // default "id" idKey?: string; // default "id"
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array 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" 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[]; columns: ColumnDef[];
fields?: FieldDef[]; fields?: FieldDef[];
canCreate?: boolean; canCreate?: boolean;
@@ -108,6 +109,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
const openCreate = () => { const openCreate = () => {
const init: Record<string, unknown> = {}; const init: Record<string, unknown> = {};
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : ""))); config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
if (config.fixedValues) Object.assign(init, config.fixedValues);
setForm(init); setForm(init);
setSlugTouched(false); // new record → keep syncing slug from name setSlugTouched(false); // new record → keep syncing slug from name
setCreating(true); setCreating(true);
@@ -137,7 +139,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), { const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
method: isEdit ? "PUT" : "POST", method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(form), body: JSON.stringify({ ...config.fixedValues, ...form }),
}); });
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error ?? "Save failed"); if (!res.ok) throw new Error(data?.error ?? "Save failed");
+55
View File
@@ -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 = { export const slidesConfig: ResourceConfig = {
title: "اسلایدهای صفحه اصلی", title: "اسلایدهای صفحه اصلی",
description: "اسلایدهای هیرو/تبلیغاتی در صفحهٔ اصلی.", description: "اسلایدهای هیرو/تبلیغاتی در صفحهٔ اصلی.",
+26
View File
@@ -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>
);
}
+25
View File
@@ -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")} />;
}
+155
View File
@@ -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>
);
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function Navbar({ user }: { user?: NavUser | null }) {
const learnItems = [ const learnItems = [
{ label: t("learnItems.blog"), href: "/blog" }, { label: t("learnItems.blog"), href: "/blog" },
{ label: t("learnItems.tutorials"), href: "/tutorials" }, { label: t("learnItems.tutorials"), href: "/learn" },
{ label: t("learnItems.help"), href: "/help" }, { label: t("learnItems.help"), href: "/help" },
]; ];
+143
View File
@@ -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;
}