From c92de06c28a0466eb7747986f685b3e07acdbcc8 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 11 Jun 2026 22:43:25 +0330 Subject: [PATCH] feat(content): public Blog + Learn sections and static CMS pages (full-stack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/en.json | 55 +++++++ messages/fa.json | 55 +++++++ .../Domain/Enums/Enums.cs | 2 +- .../migrations/001_blog_kind_learn_page.sql | 9 + src/app/[locale]/about/page.tsx | 20 +++ src/app/[locale]/admin/layout.tsx | 2 + src/app/[locale]/admin/learn/page.tsx | 8 + src/app/[locale]/admin/pages/page.tsx | 8 + src/app/[locale]/blog/[slug]/page.tsx | 32 ++++ src/app/[locale]/blog/page.tsx | 26 +++ src/app/[locale]/careers/page.tsx | 20 +++ src/app/[locale]/contact/page.tsx | 20 +++ src/app/[locale]/cookies/page.tsx | 20 +++ src/app/[locale]/help/page.tsx | 20 +++ src/app/[locale]/learn/[slug]/page.tsx | 32 ++++ src/app/[locale]/learn/page.tsx | 26 +++ src/app/[locale]/privacy/page.tsx | 20 +++ src/app/[locale]/terms/page.tsx | 20 +++ src/components/admin/AdminResource.tsx | 4 +- src/components/admin/admin-resources.tsx | 55 +++++++ src/components/content/CmsPageContent.tsx | 26 +++ src/components/content/CmsRoute.tsx | 25 +++ src/components/content/article-ui.tsx | 155 ++++++++++++++++++ src/components/layout/Navbar.tsx | 2 +- src/lib/content-api.ts | 143 ++++++++++++++++ 25 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 services/content/migrations/001_blog_kind_learn_page.sql create mode 100644 src/app/[locale]/about/page.tsx create mode 100644 src/app/[locale]/admin/learn/page.tsx create mode 100644 src/app/[locale]/admin/pages/page.tsx create mode 100644 src/app/[locale]/blog/[slug]/page.tsx create mode 100644 src/app/[locale]/blog/page.tsx create mode 100644 src/app/[locale]/careers/page.tsx create mode 100644 src/app/[locale]/contact/page.tsx create mode 100644 src/app/[locale]/cookies/page.tsx create mode 100644 src/app/[locale]/help/page.tsx create mode 100644 src/app/[locale]/learn/[slug]/page.tsx create mode 100644 src/app/[locale]/learn/page.tsx create mode 100644 src/app/[locale]/privacy/page.tsx create mode 100644 src/app/[locale]/terms/page.tsx create mode 100644 src/components/content/CmsPageContent.tsx create mode 100644 src/components/content/CmsRoute.tsx create mode 100644 src/components/content/article-ui.tsx create mode 100644 src/lib/content-api.ts diff --git a/messages/en.json b/messages/en.json index 75cfe09..2ab30b5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -187,6 +187,59 @@ "socialLinkedIn": "LinkedIn", "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": { "signIn": "Sign In", "signUp": "Sign Up", @@ -324,6 +377,8 @@ "tags": "Tags", "fonts": "Fonts", "blogs": "Blog", + "learn": "Tutorials", + "pages": "Pages", "slides": "Slides", "users": "Users", "plans": "Plans", diff --git a/messages/fa.json b/messages/fa.json index 4584778..407ac1a 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -187,6 +187,59 @@ "socialLinkedIn": "لینکدین", "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": { "signIn": "ورود", "signUp": "ثبت‌نام", @@ -324,6 +377,8 @@ "tags": "برچسب‌ها", "fonts": "فونت‌ها", "blogs": "بلاگ", + "learn": "آموزش‌ها", + "pages": "برگه‌ها", "slides": "اسلایدها", "users": "کاربران", "plans": "پلن‌ها", diff --git a/services/content/FlatRender.ContentSvc/Domain/Enums/Enums.cs b/services/content/FlatRender.ContentSvc/Domain/Enums/Enums.cs index 65192bf..c8a8705 100644 --- a/services/content/FlatRender.ContentSvc/Domain/Enums/Enums.cs +++ b/services/content/FlatRender.ContentSvc/Domain/Enums/Enums.cs @@ -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 RepeatSortStrategy { Manual, Alphabetical, Numerical, InsertOrder } 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 ContainerFavoriteKind { Container } diff --git a/services/content/migrations/001_blog_kind_learn_page.sql b/services/content/migrations/001_blog_kind_learn_page.sql new file mode 100644 index 0000000..fe6f4e8 --- /dev/null +++ b/services/content/migrations/001_blog_kind_learn_page.sql @@ -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'; diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx new file mode 100644 index 0000000..f4871f8 --- /dev/null +++ b/src/app/[locale]/about/page.tsx @@ -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 { + const t = await getTranslations("aboutPage"); + return createPageMetadata({ title: t("title"), description: t("lead"), path: "/about" }); +} + +export default function AboutPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index c5a5685..a319798 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -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") }, diff --git a/src/app/[locale]/admin/learn/page.tsx b/src/app/[locale]/admin/learn/page.tsx new file mode 100644 index 0000000..849eb83 --- /dev/null +++ b/src/app/[locale]/admin/learn/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { learnConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/pages/page.tsx b/src/app/[locale]/admin/pages/page.tsx new file mode 100644 index 0000000..3173d92 --- /dev/null +++ b/src/app/[locale]/admin/pages/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { pagesConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/blog/[slug]/page.tsx b/src/app/[locale]/blog/[slug]/page.tsx new file mode 100644 index 0000000..3fd1158 --- /dev/null +++ b/src/app/[locale]/blog/[slug]/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} diff --git a/src/app/[locale]/blog/page.tsx b/src/app/[locale]/blog/page.tsx new file mode 100644 index 0000000..c1b16f1 --- /dev/null +++ b/src/app/[locale]/blog/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} diff --git a/src/app/[locale]/careers/page.tsx b/src/app/[locale]/careers/page.tsx new file mode 100644 index 0000000..f2e4569 --- /dev/null +++ b/src/app/[locale]/careers/page.tsx @@ -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 { + const t = await getTranslations("careersPage"); + return createPageMetadata({ title: t("title"), description: t("lead"), path: "/careers" }); +} + +export default function CareersPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/contact/page.tsx b/src/app/[locale]/contact/page.tsx new file mode 100644 index 0000000..fd87dbb --- /dev/null +++ b/src/app/[locale]/contact/page.tsx @@ -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 { + const t = await getTranslations("contactPage"); + return createPageMetadata({ title: t("title"), description: t("lead"), path: "/contact" }); +} + +export default function ContactPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/cookies/page.tsx b/src/app/[locale]/cookies/page.tsx new file mode 100644 index 0000000..2e076f2 --- /dev/null +++ b/src/app/[locale]/cookies/page.tsx @@ -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 { + const t = await getTranslations("cookiesPage"); + return createPageMetadata({ title: t("title"), description: t("lead"), path: "/cookies" }); +} + +export default function CookiesPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/help/page.tsx b/src/app/[locale]/help/page.tsx new file mode 100644 index 0000000..e389233 --- /dev/null +++ b/src/app/[locale]/help/page.tsx @@ -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 { + const t = await getTranslations("helpPage"); + return createPageMetadata({ title: t("title"), description: t("lead"), path: "/help" }); +} + +export default function HelpPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/learn/[slug]/page.tsx b/src/app/[locale]/learn/[slug]/page.tsx new file mode 100644 index 0000000..aa1eb62 --- /dev/null +++ b/src/app/[locale]/learn/[slug]/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} diff --git a/src/app/[locale]/learn/page.tsx b/src/app/[locale]/learn/page.tsx new file mode 100644 index 0000000..0929646 --- /dev/null +++ b/src/app/[locale]/learn/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} diff --git a/src/app/[locale]/privacy/page.tsx b/src/app/[locale]/privacy/page.tsx new file mode 100644 index 0000000..5e61cad --- /dev/null +++ b/src/app/[locale]/privacy/page.tsx @@ -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 { + const t = await getTranslations("privacyPage"); + return createPageMetadata({ title: t("title"), description: t("lead"), path: "/privacy" }); +} + +export default function PrivacyPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/terms/page.tsx b/src/app/[locale]/terms/page.tsx new file mode 100644 index 0000000..6f3297d --- /dev/null +++ b/src/app/[locale]/terms/page.tsx @@ -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 { + const t = await getTranslations("termsPage"); + return createPageMetadata({ title: t("title"), description: t("lead"), path: "/terms" }); +} + +export default function TermsPage() { + return ( +
+ +
+ ); +} diff --git a/src/components/admin/AdminResource.tsx b/src/components/admin/AdminResource.tsx index 1bfbf4a..a1b31b4 100644 --- a/src/components/admin/AdminResource.tsx +++ b/src/components/admin/AdminResource.tsx @@ -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; // 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 = {}; 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"); diff --git a/src/components/admin/admin-resources.tsx b/src/components/admin/admin-resources.tsx index d8c46a4..42719a0 100644 --- a/src/components/admin/admin-resources.tsx +++ b/src/components/admin/admin-resources.tsx @@ -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: "اسلایدهای هیرو/تبلیغاتی در صفحهٔ اصلی.", diff --git a/src/components/content/CmsPageContent.tsx b/src/components/content/CmsPageContent.tsx new file mode 100644 index 0000000..157a5b8 --- /dev/null +++ b/src/components/content/CmsPageContent.tsx @@ -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 ( +
+

+ {title} +

+ {lead &&

{lead}

} +
+
+ ); +} diff --git a/src/components/content/CmsRoute.tsx b/src/components/content/CmsRoute.tsx new file mode 100644 index 0000000..e6c81b8 --- /dev/null +++ b/src/components/content/CmsRoute.tsx @@ -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.trim().replace(/\n/g, "
")}

`) + .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 ; +} diff --git a/src/components/content/article-ui.tsx b/src/components/content/article-ui.tsx new file mode 100644 index 0000000..56cf1b0 --- /dev/null +++ b/src/components/content/article-ui.tsx @@ -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 ( + +
+ {img ? ( + {article.title} + ) : ( +
+ {article.title.slice(0, 1)} +
+ )} +
+
+

+ {article.title} +

+ {article.shortDescription && ( +

+ {article.shortDescription} +

+ )} +
+ {formatDate(article.publishDate || article.createdAt, locale)} + {t("readMore")} → +
+
+ + ); +} + +export function ArticleListContent({ + section, + articles, +}: { + section: "blog" | "learn"; + articles: Article[]; +}) { + const t = useTranslations(section); + + return ( +
+
+

+ {t("pageTitle")} +

+

{t("pageDescription")}

+
+ + {articles.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ {articles.map((a) => ( + + ))} +
+ )} +
+ ); +} + +export function ArticleDetailContent({ + section, + article, +}: { + section: "blog" | "learn"; + article: Article; +}) { + const locale = useLocale(); + const t = useTranslations(section); + const img = article.cover || article.image; + + return ( +
+ + ← {t("pageTitle")} + + +

+ {article.title} +

+ +
+ {article.authorDisplayName && {article.authorDisplayName}} + {formatDate(article.publishDate || article.createdAt, locale)} + + {Number(article.viewCount ?? 0).toLocaleString(locale === "fa" ? "fa-IR" : "en-US")}{" "} + {t("views")} + +
+ + {article.shortDescription && ( +

{article.shortDescription}

+ )} + + {img && ( +
+ {article.title} +
+ )} + +
+
+ ); +} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 1b0d1b0..fd0bf09 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -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" }, ]; diff --git a/src/lib/content-api.ts b/src/lib/content-api.ts new file mode 100644 index 0000000..0ef3f83 --- /dev/null +++ b/src/lib/content-api.ts @@ -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 { + 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(path: string, revalidate = 60): Promise { + 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 { + 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>(`/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
{ + const data = await safeGet(`/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
{ + const data = await safeGet(`/v1/blogs/${encodeURIComponent(slug)}`, 300); + return data ? mapArticle(data) : null; +}