Poradnik

Jak stworzyc blok listy postow

Zbuduj blok listy postów we własnej aplikacji Next.js: serwerowy loader pobierający posty z delivery API, plus wyszukiwanie, paginacja i okładki. Pełny przegląd kodu.

Z
Zespol Cmssy
15 min read

Jak stworzyc blok listy postow

Zbuduj blok we wlasnej aplikacji Next.js ktory pobiera i listuje posty z Cmssy przez delivery API, z wyszukiwaniem i paginacja.

Co budujemy

Blok listingu wpisów blogowych: siatka lub lista podglądów postów, z wyszukiwarką i paginacją. Cmssy to headless CMS z wizualnym edytorem - Twoja treść żyje w panelu Cmssy, a komponenty bloków żyją we własnej aplikacji Next.js, którą sam wdrażasz. To dokładnie ten blok, który renderuje listing, z którego trafiłeś na ten wpis.

Posty to tutaj strony: każdy post jest stroną podrzędną rodzica /blog, o typie strony post. Blok pyta delivery API o dzieci tego rodzica, więc dodanie posta to po prostu opublikowanie strony.

  • Posty ładowane na serwerze, zanim strona się wyrenderuje
  • Obrazek okładki i zajawka z pól własnych posta
  • Wyszukiwanie i filtrowanie po kategorii po stronie klienta
  • Paginacja (załaduj więcej)
  • Tryby siatki i listy

Jak działają bloki w headless

Blok to komponent React plus schemat propsów, oba w Twoim repo. Nie ma CLI ani kroku publikacji. Przebieg:

  1. Zadeklaruj edytowalne pola i komponent w blocks/blog-posts/block.ts przez defineBlock
  2. Napisz komponent React w blocks/blog-posts/src/
  3. Dodaj blok do tablicy w cmssy/blocks.ts
  4. Odpal pnpm dev i blok pojawia się w edytorze
  5. Wdróż aplikację Next.js - tak trafia blok na produkcję

Edytor osadza Twoją wdrożoną (lub lokalną) stronę i uczy się schematu każdego bloku przez most SDK, więc nie ma czego osobno wgrywać.

Krok 1: Zdefiniuj blok

Utwórz blocks/blog-posts/block.ts. Zwróć uwagę, że komponent jest częścią definicji, a edytowalne pola idą pod props - każde budowane helperem fields.*, i to właśnie dzięki temu typ treści da się wywnioskować:

import { defineBlock, fields } from "@cmssy/react";
import BlogPosts from "./src/BlogPosts";

export const blogPostsProps = {
  badge: fields.text({ label: "Badge", defaultValue: "Latest Posts" }),
  heading: fields.text({ label: "Heading", defaultValue: "From the Blog" }),
  description: fields.textarea({ label: "Description" }),
  parentPage: fields.pageSelector({ label: "Parent Page", multiple: false }),
  postsPerPage: fields.select({
    label: "Posts per page",
    defaultValue: "9",
    options: ["3", "6", "9", "12"],
  }),
  showSearch: fields.boolean({ label: "Show Search", defaultValue: true }),
  layout: fields.select({
    label: "Layout",
    defaultValue: "grid",
    options: ["grid", "list"],
    tab: "style",
  }),
};

export const blogPostsBlock = defineBlock({
  type: "blog-posts",
  category: "Blog",
  label: "Blog Posts",
  description:
    "Grid or list of blog post previews; for a blog index or a 'latest posts' section.",
  component: BlogPosts,
  props: blogPostsProps,
});

Dostępne buildery pól: fields.text, fields.textarea, fields.richText, fields.markdown, fields.number, fields.date, fields.datetime, fields.boolean, fields.color, fields.link, fields.url, fields.email, fields.table, fields.json, fields.form, fields.pageSelector, fields.select, fields.radio, fields.multiselect, fields.media, fields.repeater i fields.relation. Tutaj edytor tylko konfiguruje blok - same posty to strony pobierane w czasie renderowania.

Krok 2: Zrozum propsy komponentu

Otypuj komponent przez BlockProps<typeof yourProps>, a schemat stanie się jedynym miejscem, gdzie pole ma nazwę - zmień nazwę pola, a komponent przestanie się kompilować, zamiast po cichu renderować pustkę:

import type { BlockProps } from "@cmssy/react";
import type { blogPostsProps, BlogPostsData } from "../block";

export default function BlogPosts({
  content,
  context,
  data,
}: BlockProps<typeof blogPostsProps, BlogPostsData | null>) {
  const locale = context?.locale.current;
  const isPreview = context?.isPreview ?? false;
  // ...
}

content trzyma wartości pól, data trzyma to, co zwrócił serwerowy loader bloku, a context opisuje środowisko renderowania:

interface CmssyBlockContext {
  locale: {
    current: string;   // np. "pl"
    default: string;   // domyślny język workspace'u
    enabled: string[]; // wszystkie włączone języki
  };
  isPreview: boolean;  // true wewnątrz edytora
  page?: {             // brak, gdy strona nie ma sluga
    id: string;
    slug: string;
    pageType: string | null;
  };
}

Używaj context.locale.current, aby wybrać zlokalizowane wartości, i context.isPreview, aby zmienić zachowanie w edytorze - na przykład pominąć nieskończone przewijanie, gdy ktoś edytuje.

Krok 3: Załaduj posty na serwerze

Klient SDK to bramka GraphQL, a nie zestaw wrapperów: cokolwiek da się wyrazić zapytaniem, jest Twoim zapytaniem. Umieść je w blocks/blog-posts/load-posts.ts i użyj queryScoped, które samo rozwiązuje i wstrzykuje id workspace'u:

import { print } from "graphql";
import { createCmssyClient } from "@cmssy/react";
import type { PageItem } from "@cmssy/types";
import { cmssy } from "@/cmssy/config";
import { PublicPagesByTypeDocument } from "@/graphql/generated/graphql";

const client = createCmssyClient(cmssy);
const PUBLIC_PAGES_QUERY = print(PublicPagesByTypeDocument);

export type PostsResult = { items: PageItem[]; hasMore: boolean };

export async function loadPosts(vars: {
  parentSlug: string;
  limit: number;
  offset?: number;
}): Promise<PostsResult | null> {
  const data = await client.queryScoped<{
    public?: { page?: { byType?: PostsResult | null } | null } | null;
  }>(PUBLIC_PAGES_QUERY, vars);

  const result = data?.public?.page?.byType;
  return result
    ? { items: result.items ?? [], hasMore: !!result.hasMore }
    : null;
}

Teraz podepnij to do bloku przez loader. Uruchamia się podczas SSR, a jego wynik trafia do komponentu jako prop data, więc pierwsza strona postów jest w HTML-u, a nie dociągana po hydratacji:

export const blogPostsBlock = defineBlock({
  type: "blog-posts",
  component: BlogPosts,
  props: blogPostsProps,
  loader: async ({ content }): Promise<BlogPostsData | null> => {
    const parentPage = content.parentPage;
    const parentSlug = Array.isArray(parentPage)
      ? (parentPage[0] as { slug?: string } | undefined)?.slug
      : typeof parentPage === "string"
        ? parentPage
        : undefined;
    if (!parentSlug) return null;

    const limit = Number(content.postsPerPage) || 9;
    const { loadPosts } = await import("./load-posts");
    return loadPosts({ parentSlug, limit, offset: 0 });
  },
});

Wynik loadera przekracza granicę serwer-klient, więc musi być serializowalny dla RSC: zwykłe obiekty, tablice i prymitywy. Loader nie uruchamia się w edytorze - tam komponent dostaje data: undefined, i po to właśnie jest isPreview.

Krok 4: Zbuduj komponent

Skoro pierwsza strona jest już w data, komponent sięga po sieć dopiero, gdy czytelnik szuka albo prosi o więcej. Trzymaj to w hooku, żeby komponent został prezentacyjny:

"use client";

import type { BlockProps } from "@cmssy/react";
import type { blogPostsProps, BlogPostsData } from "../block";
import { PostCard } from "./PostCard";
import { useBlogPosts } from "./useBlogPosts";

export default function BlogPosts({
  content,
  context,
  data,
}: BlockProps<typeof blogPostsProps, BlogPostsData | null>) {
  const { heading, description, showSearch = true, layout = "grid" } = content;
  const { filteredItems, search, setSearch, hasMore, loadMore } = useBlogPosts(
    content,
    context,
    data,
  );

  return (
    <section className="py-24">
      <div className="max-w-6xl mx-auto px-6">
        {heading && <h2 className="text-3xl font-semibold">{heading}</h2>}
        {description && (
          <p className="mt-4 text-muted-foreground">{description}</p>
        )}

        {showSearch && (
          <input
            type="text"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            className="w-full sm:w-80 mt-8 px-4 py-2.5 border rounded-lg"
          />
        )}

        <div
          className={`mt-8 grid grid-cols-1 gap-8 ${
            layout === "grid" ? "md:grid-cols-2 lg:grid-cols-3" : ""
          }`}
        >
          {filteredItems.map((post) => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>

        {hasMore && (
          <button onClick={loadMore} className="mt-10">
            Load more
          </button>
        )}
      </div>
    </section>
  );
}

Zwróć uwagę, czego tu nie ma: żadnego domyślnego nagłówka, żadnych zastępczych tekstów. Blok renderuje to, co da mu CMS, i nic więcej - brak wartości oznacza, że element się nie renderuje, a nigdy że zahardkodowany angielski string wycieknie na polską stronę.

Krok 5: Zarejestruj blok

Dodaj go do tablicy w cmssy/blocks.ts. Tę tablicę podajesz do createCmssyPage i to całe podpięcie:

import { blogPostsBlock } from "@/blocks/blog-posts/block";
// ...pozostałe bloki

export const blocks = [blogPostsBlock];

Bez wgrywania, bez komendy publikacji - edytor czyta każdy schemat z Twojej działającej aplikacji przez most SDK.

Krok 6: Paginacja

Aby doładować więcej, trzymaj offset i wołaj ten sam loader przez route handler lub server action, dopisując wyniki:

const [offset, setOffset] = useState(0);

async function loadMore() {
  const next = offset + limit;
  const res = await fetch(
    `/api/posts?parent=${parentSlug}&limit=${limit}&offset=${next}`,
  ).then((r) => r.json());

  setItems((prev) => [...prev, ...res.items]);
  setHasMore(res.hasMore);
  setOffset(next);
}

Trzymaj poświadczenia delivery na serwerze. loadPosts działa wyłącznie po stronie serwera, więc przeglądarka rozmawia z route handlerem.

Uruchom lokalnie

Odpal aplikację, a blok od razu pojawi się w edytorze:

pnpm dev

Otwórz swój workspace w panelu Cmssy, upuść blok Blog Posts na stronę, a edytor osadzi Twoją lokalną stronę. Wybierz stronę-rodzica, ustaw nagłówek i lista renderuje się na żywo.

Wdróż

Nie ma osobnego kroku wdrożenia bloku. Gdy wdrażasz aplikację Next.js (na Vercel lub gdziekolwiek), nowy blok jedzie razem z nią, a edytor celuje w Twój wdrożony URL do edycji wizualnej.

Konfiguracja treści

Zanim blok będzie miał co listować, ustaw stronę treści w panelu Cmssy:

  1. Utwórz typ strony post z polami własnymi, których potrzebuje karta - zwykle cover_image (Media), author i publish_date
  2. Utwórz stronę-rodzica pod /blog
  3. Twórz każdy post jako stronę podrzędną /blog o typie post
  4. W ustawieniach bloku wskaż Parent Page na /blog

Następne kroki