diff --git a/packages/create/src/create-app.ts b/packages/create/src/create-app.ts index c2ab351a..eb2134fe 100644 --- a/packages/create/src/create-app.ts +++ b/packages/create/src/create-app.ts @@ -234,16 +234,26 @@ async function runCommandsAndInstallDependencies( function report(environment: Environment, options: Options) { const warnings: Array = [] + // TODO: nextSteps displays post-creation instructions for add-ons that require additional setup + // (e.g., starting a sibling server). Decide if this belongs in core or if add-ons should use README instead. + const nextSteps: Array = [] for (const addOn of options.chosenAddOns) { if (addOn.warning) { warnings.push(addOn.warning) } + if (addOn.nextSteps) { + nextSteps.push(`${addOn.name}:\n${addOn.nextSteps}`) + } } if (warnings.length > 0) { environment.warn('Warnings', warnings.join('\n')) } + if (nextSteps.length > 0) { + environment.info('Next Steps', nextSteps.join('\n\n')) + } + // Format errors let errorStatement = '' if (environment.getErrors().length) { diff --git a/packages/create/src/frameworks/react/add-ons/strapi/README.md b/packages/create/src/frameworks/react/add-ons/strapi/README.md index 22e5e3d2..708b100f 100644 --- a/packages/create/src/frameworks/react/add-ons/strapi/README.md +++ b/packages/create/src/frameworks/react/add-ons/strapi/README.md @@ -1,14 +1,158 @@ -## Setting up Strapi +## Strapi CMS Integration -The current setup shows an example of how to use Strapi with an articles collection which is part of the example structure & data. +This add-on integrates Strapi CMS with your TanStack Start application using the official Strapi Client SDK. The Strapi server is created as a sibling directory during setup. -- Create a local running copy of the strapi admin +### Features + +- Article listing with search and pagination +- Article detail pages with dynamic block rendering +- Rich text, quotes, media, and image slider blocks +- Markdown content rendering with GitHub Flavored Markdown +- Responsive image handling with error fallbacks +- URL-based search and pagination (shareable/bookmarkable) +- Graceful error handling with helpful setup instructions + +### Project Structure + +``` +parent/ +├── client/ # TanStack Start frontend (your project name) +│ ├── src/ +│ │ ├── components/ +│ │ │ ├── blocks/ # Block rendering components +│ │ │ ├── markdown-content.tsx +│ │ │ ├── pagination.tsx +│ │ │ ├── search.tsx +│ │ │ └── strapi-image.tsx +│ │ ├── data/ +│ │ │ ├── loaders/ # Server functions +│ │ │ └── strapi-sdk.ts +│ │ ├── lib/ +│ │ │ └── strapi-utils.ts +│ │ ├── routes/demo/ +│ │ │ ├── strapi.tsx # Articles list +│ │ │ └── strapi_.$articleId.tsx # Article detail +│ │ └── types/ +│ │ └── strapi.ts +│ ├── .env.local +│ └── package.json +└── server/ # Strapi CMS backend (auto-created) + ├── src/api/ # Content types + ├── config/ # Strapi configuration + └── package.json +``` + +### Quick Start + +The Strapi server is automatically cloned from the official [Strapi Cloud Template Blog](https://github.com/strapi/strapi-cloud-template-blog). + +**1. Install Strapi dependencies:** + +```bash +cd ../server +npm install # or pnpm install / yarn install +``` + +**2. Start the Strapi server:** ```bash -pnpm dlx create-strapi@latest my-strapi-project -cd my-strapi-project -pnpm dev +npm run develop # Starts at http://localhost:1337 ``` -- Login and publish the example articles to see them on the strapi demo page. -- Set the `VITE_STRAPI_URL` environment variable in your `.env.local`. (For local it should be http://localhost:1337/api) +**3. Create an admin account:** + +Open http://localhost:1337/admin and create your first admin user. + +**4. Create content:** + +In the Strapi admin panel, go to Content Manager > Article and create some articles. + +**5. Start your TanStack app (in another terminal):** + +```bash +cd ../client # or your project name +npm run dev # Starts at http://localhost:3000 +``` + +**6. View the demo:** + +Navigate to http://localhost:3000/demo/strapi to see your articles. + +### Environment Variables + +The following environment variable is pre-configured in `.env.local`: + +```bash +VITE_STRAPI_URL="http://localhost:1337" +``` + +For production, update this to your deployed Strapi URL. + +### Demo Pages + +| URL | Description | +|-----|-------------| +| `/demo/strapi` | Articles list with search and pagination | +| `/demo/strapi/:articleId` | Article detail with block rendering | + +### Search and Pagination + +- **Search**: Type in the search box to filter articles by title or description +- **Pagination**: Navigate between pages using the pagination controls +- **URL State**: Search and page are stored in the URL (`?query=term&page=2`) + +### Block Types Supported + +| Block | Component | Description | +|-------|-----------|-------------| +| `shared.rich-text` | RichText | Markdown content | +| `shared.quote` | Quote | Blockquote with author | +| `shared.media` | Media | Single image/video | +| `shared.slider` | Slider | Image gallery grid | + +### Dependencies + +| Package | Purpose | +|---------|---------| +| `@strapi/client` | Official Strapi SDK | +| `react-markdown` | Markdown rendering | +| `remark-gfm` | GitHub Flavored Markdown | +| `use-debounce` | Debounced search input | + +### Running Both Servers + +Open two terminal windows from the parent directory: + +**Terminal 1 - Strapi:** +```bash +cd server && npm run develop +``` + +**Terminal 2 - TanStack Start:** +```bash +cd client && npm run dev # or your project name +``` + +### Customization + +**Change page size:** +Edit `src/data/loaders/articles.ts` and modify `PAGE_SIZE`. + +**Add new block types:** +1. Create component in `src/components/blocks/` +2. Export from `src/components/blocks/index.ts` +3. Add case to `block-renderer.tsx` switch statement +4. Update populate in articles loader + +**Add new content types:** +1. Add types to `src/types/strapi.ts` +2. Create loader in `src/data/loaders/` +3. Create route in `src/routes/demo/` + +### Learn More + +- [Strapi Documentation](https://docs.strapi.io/) +- [Strapi Client SDK](https://www.npmjs.com/package/@strapi/client) +- [Strapi Cloud Template Blog](https://github.com/strapi/strapi-cloud-template-blog) +- [TanStack Start Documentation](https://tanstack.com/start/latest) +- [TanStack Router Search Params](https://tanstack.com/router/latest/docs/framework/react/guide/search-params) diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/_dot_env.local.append b/packages/create/src/frameworks/react/add-ons/strapi/assets/_dot_env.local.append index d9791ecc..b7185238 100644 --- a/packages/create/src/frameworks/react/add-ons/strapi/assets/_dot_env.local.append +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/_dot_env.local.append @@ -1,2 +1,2 @@ # Strapi configuration -VITE_STRAPI_URL="http://localhost:1337/api" \ No newline at end of file +VITE_STRAPI_URL="http://localhost:1337" \ No newline at end of file diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/block-renderer.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/block-renderer.tsx new file mode 100644 index 00000000..0346cb5f --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/block-renderer.tsx @@ -0,0 +1,55 @@ +import { RichText } from "./rich-text"; +import { Quote } from "./quote"; +import { Media } from "./media"; +import { Slider } from "./slider"; + +import type { IRichText } from "./rich-text"; +import type { IQuote } from "./quote"; +import type { IMedia } from "./media"; +import type { ISlider } from "./slider"; + +// Union type of all block types +export type Block = IRichText | IQuote | IMedia | ISlider; + +interface BlockRendererProps { + blocks: Array; +} + +/** + * BlockRenderer - Renders dynamic content blocks from Strapi + * + * Usage: + * ```tsx + * + * ``` + */ +export function BlockRenderer({ blocks }: Readonly) { + if (!blocks || blocks.length === 0) return null; + + const renderBlock = (block: Block) => { + switch (block.__component) { + case "shared.rich-text": + return ; + case "shared.quote": + return ; + case "shared.media": + return ; + case "shared.slider": + return ; + default: + // Log unknown block types in development + console.warn("Unknown block type:", (block as any).__component); + return null; + } + }; + + return ( +
+ {blocks.map((block, index) => ( +
+ {renderBlock(block)} +
+ ))} +
+ ); +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/index.ts b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/index.ts new file mode 100644 index 00000000..41af91e4 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/index.ts @@ -0,0 +1,14 @@ +export { BlockRenderer } from "./block-renderer"; +export type { Block } from "./block-renderer"; + +export { RichText } from "./rich-text"; +export type { IRichText } from "./rich-text"; + +export { Quote } from "./quote"; +export type { IQuote } from "./quote"; + +export { Media } from "./media"; +export type { IMedia } from "./media"; + +export { Slider } from "./slider"; +export type { ISlider } from "./slider"; diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/media.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/media.tsx new file mode 100644 index 00000000..e0b0930b --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/media.tsx @@ -0,0 +1,27 @@ +import { StrapiImage } from "@/components/strapi-image"; +import type { TImage } from "@/types/strapi"; + +export interface IMedia { + __component: "shared.media"; + id: number; + file?: TImage; +} + +export function Media({ file }: Readonly) { + if (!file) return null; + + return ( +
+ + {file.alternativeText && ( +
+ {file.alternativeText} +
+ )} +
+ ); +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/quote.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/quote.tsx new file mode 100644 index 00000000..b73c1df0 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/quote.tsx @@ -0,0 +1,19 @@ +export interface IQuote { + __component: "shared.quote"; + id: number; + body: string; + title?: string; +} + +export function Quote({ body, title }: Readonly) { + return ( +
+

{body}

+ {title && ( + + — {title} + + )} +
+ ); +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/rich-text.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/rich-text.tsx new file mode 100644 index 00000000..5fce0d69 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/rich-text.tsx @@ -0,0 +1,11 @@ +import { MarkdownContent } from "@/components/markdown-content"; + +export interface IRichText { + __component: "shared.rich-text"; + id: number; + body: string; +} + +export function RichText({ body }: Readonly) { + return ; +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/slider.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/slider.tsx new file mode 100644 index 00000000..26ce8116 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/blocks/slider.tsx @@ -0,0 +1,28 @@ +import { StrapiImage } from "@/components/strapi-image"; +import type { TImage } from "@/types/strapi"; + +export interface ISlider { + __component: "shared.slider"; + id: number; + files?: Array; +} + +export function Slider({ files }: Readonly) { + if (!files || files.length === 0) return null; + + return ( +
+
+ {files.map((file, index) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/markdown-content.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/markdown-content.tsx new file mode 100644 index 00000000..452d6674 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/markdown-content.tsx @@ -0,0 +1,74 @@ +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface MarkdownContentProps { + content: string | undefined | null; + className?: string; +} + +const styles = { + h1: "text-3xl font-bold mb-6 text-white", + h2: "text-2xl font-bold mb-4 text-white", + h3: "text-xl font-bold mb-3 text-white", + p: "mb-4 leading-relaxed text-gray-300", + a: "text-cyan-400 hover:underline", + ul: "list-disc pl-6 mb-4 space-y-2 text-gray-300", + ol: "list-decimal pl-6 mb-4 space-y-2 text-gray-300", + li: "leading-relaxed", + blockquote: "border-l-4 border-cyan-400 pl-4 italic text-gray-400 my-4", + code: "bg-slate-800 px-2 py-1 rounded text-cyan-400 text-sm font-mono", + pre: "bg-slate-800 p-4 rounded-lg overflow-x-auto mb-4", + table: "w-full border-collapse mb-4", + th: "border border-slate-700 p-2 bg-slate-800 text-left text-white", + td: "border border-slate-700 p-2 text-gray-300", + img: "max-w-full h-auto rounded-lg my-4", + hr: "border-slate-700 my-8", + strong: "text-white font-semibold", +}; + +export function MarkdownContent({ content, className = "" }: MarkdownContentProps) { + if (!content) return null; + + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) =>
    {children}
    , + code: ({ className, children }) => { + const isCodeBlock = className?.includes("language-"); + if (isCodeBlock) { + return ( +
    +                  {children}
    +                
    + ); + } + return {children}; + }, + pre: ({ children }) => <>{children}, + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children}, + img: ({ src, alt }) => {alt, + hr: () =>
    , + strong: ({ children }) => {children}, + }} + > + {content} +
    +
    + ); +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/pagination.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/pagination.tsx new file mode 100644 index 00000000..ede7e9cd --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/pagination.tsx @@ -0,0 +1,120 @@ +import { useRouter, useSearch } from '@tanstack/react-router' +import { ChevronLeft, ChevronRight } from 'lucide-react' + +interface PaginationProps { + pageCount: number + className?: string +} + +export function Pagination({ pageCount, className = '' }: PaginationProps) { + const router = useRouter() + const search = useSearch({ strict: false }) + const currentPage = Number((search as any)?.page) || 1 + + const handlePageChange = (page: number) => { + router.navigate({ + to: '.', + search: (prev) => ({ ...prev, page }), + replace: true, + }) + } + + // Generate page numbers to display + const getPageNumbers = () => { + const pages: Array = [] + const showEllipsis = pageCount > 7 + + if (showEllipsis) { + pages.push(1) + + if (currentPage > 3) { + pages.push('ellipsis') + } + + const start = Math.max(2, currentPage - 1) + const end = Math.min(pageCount - 1, currentPage + 1) + + for (let i = start; i <= end; i++) { + pages.push(i) + } + + if (currentPage < pageCount - 2) { + pages.push('ellipsis') + } + + if (pageCount > 1) { + pages.push(pageCount) + } + } else { + for (let i = 1; i <= pageCount; i++) { + pages.push(i) + } + } + + return pages + } + + const pageNumbers = getPageNumbers() + + if (pageCount <= 1) return null + + return ( + + ) +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/search.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/search.tsx new file mode 100644 index 00000000..0f7785a8 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/search.tsx @@ -0,0 +1,35 @@ +import { useRouter, useSearch } from "@tanstack/react-router"; +import { useDebouncedCallback } from "use-debounce"; + +interface SearchProps { + readonly className?: string; +} + +export function Search({ className = "" }: SearchProps) { + const search = useSearch({ strict: false }); + const router = useRouter(); + + const handleSearch = useDebouncedCallback((term: string) => { + router.navigate({ + to: ".", + search: (prev) => ({ + ...prev, + page: 1, + query: term || undefined, + }), + replace: true, + }); + }, 300); + + return ( + ) => + handleSearch(e.target.value) + } + defaultValue={(search as any)?.query || ""} + className={`w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-colors ${className}`} + /> + ); +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/strapi-image.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/strapi-image.tsx new file mode 100644 index 00000000..99ec27a9 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/components/strapi-image.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { getStrapiMedia } from "@/lib/strapi-utils"; + +interface StrapiImageProps { + src: string | undefined | null; + alt?: string | null; + className?: string; + width?: number | string; + height?: number | string; +} + +export function StrapiImage({ + src, + alt, + className = "", + width, + height, +}: StrapiImageProps) { + const [hasError, setHasError] = useState(false); + + if (!src) return null; + + const imageUrl = getStrapiMedia(src); + + if (hasError) { + return ( +
    + Image not available +
    + ); + } + + return ( + {alt setHasError(true)} + /> + ); +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/loaders/articles.ts b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/loaders/articles.ts new file mode 100644 index 00000000..81c4e2ca --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/loaders/articles.ts @@ -0,0 +1,106 @@ +import { createServerFn } from "@tanstack/react-start"; +import { sdk } from "@/data/strapi-sdk"; +import type { TArticle, TStrapiResponseCollection, TStrapiResponseSingle } from "@/types/strapi"; + +const PAGE_SIZE = 3; + +const articles = sdk.collection("articles"); + +/** + * Fetch articles with optional filtering, search, and pagination + */ +const getArticles = async ( + page?: number, + category?: string, + query?: string +) => { + const filterConditions: Array> = []; + + // Add search query filter + if (query) { + filterConditions.push({ + $or: [ + { title: { $containsi: query } }, + { description: { $containsi: query } }, + ], + }); + } + + // Add category filter + if (category) { + filterConditions.push({ + category: { + slug: { $eq: category }, + }, + }); + } + + const filters = + filterConditions.length === 0 + ? undefined + : filterConditions.length === 1 + ? filterConditions[0] + : { $and: filterConditions }; + + return articles.find({ + sort: ["createdAt:desc"], + pagination: { + page: page || 1, + pageSize: PAGE_SIZE, + }, + populate: ["cover", "author", "category"], + filters, + }) as Promise>; +}; + +/** + * Fetch a single article by documentId + */ +const getArticleById = async (documentId: string) => { + return articles.findOne(documentId, { + populate: ["cover", "author", "category", "blocks.file", "blocks.files"], + }) as Promise>; +}; + +/** + * Fetch a single article by slug + */ +const getArticleBySlug = async (slug: string) => { + return articles.find({ + filters: { + slug: { $eq: slug }, + }, + populate: ["cover", "author", "category", "blocks.file", "blocks.files"], + }) as Promise>; +}; + +// Server Functions - these run on the server and can be called from components + +export const getArticlesData = createServerFn({ + method: "GET", +}) + .inputValidator( + (input?: { page?: number; category?: string; query?: string }) => input + ) + .handler(async ({ data }): Promise> => { + const response = await getArticles(data?.page, data?.category, data?.query); + return response; + }); + +export const getArticleByIdData = createServerFn({ + method: "GET", +}) + .inputValidator((documentId: string) => documentId) + .handler(async ({ data: documentId }): Promise> => { + const response = await getArticleById(documentId); + return response; + }); + +export const getArticleBySlugData = createServerFn({ + method: "GET", +}) + .inputValidator((slug: string) => slug) + .handler(async ({ data: slug }): Promise> => { + const response = await getArticleBySlug(slug); + return response; + }); diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/loaders/index.ts b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/loaders/index.ts new file mode 100644 index 00000000..7f59e7d5 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/loaders/index.ts @@ -0,0 +1,28 @@ +import { + getArticlesData, + getArticleByIdData, + getArticleBySlugData, +} from "./articles"; + +/** + * Strapi API - Server functions for fetching data from Strapi + * + * Usage in route loaders: + * ```ts + * import { strapiApi } from "@/data/loaders"; + * + * export const Route = createFileRoute("/articles")({ + * loader: async () => { + * const { data, meta } = await strapiApi.articles.getArticlesData(); + * return data; + * }, + * }); + * ``` + */ +export const strapiApi = { + articles: { + getArticlesData, + getArticleByIdData, + getArticleBySlugData, + }, +}; diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/strapi-sdk.ts b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/strapi-sdk.ts new file mode 100644 index 00000000..cdcfcee1 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/data/strapi-sdk.ts @@ -0,0 +1,9 @@ +import { strapi } from "@strapi/client"; + +// Strapi base URL (without /api) +const STRAPI_BASE = import.meta.env.VITE_STRAPI_URL ?? "http://localhost:1337"; + +// Initialize the Strapi SDK with /api endpoint +const sdk = strapi({ baseURL: new URL("/api", STRAPI_BASE).href }); + +export { sdk }; diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/lib/strapi-utils.ts b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/lib/strapi-utils.ts new file mode 100644 index 00000000..def9c460 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/lib/strapi-utils.ts @@ -0,0 +1,25 @@ +/** + * Strapi URL helpers + */ + +const DEFAULT_STRAPI_URL = "http://localhost:1337"; + +// Base Strapi URL (without /api) +export function getStrapiURL(): string { + // Handle SSR where import.meta.env might not be fully available + if (typeof import.meta !== "undefined" && import.meta.env?.VITE_STRAPI_URL) { + return import.meta.env.VITE_STRAPI_URL; + } + return DEFAULT_STRAPI_URL; +} + +// Get full URL for media assets +export function getStrapiMedia(url: string | undefined | null): string { + if (!url) return ""; + if (url.startsWith("data:") || url.startsWith("http") || url.startsWith("//")) { + return url; + } + // Ensure we always have a valid base URL + const baseUrl = getStrapiURL() || DEFAULT_STRAPI_URL; + return `${baseUrl}${url.startsWith("/") ? "" : "/"}${url}`; +} diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/lib/strapiClient.ts b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/lib/strapiClient.ts deleted file mode 100644 index 0fd74d6b..00000000 --- a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/lib/strapiClient.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { strapi } from "@strapi/client"; - -export const strapiClient = strapi({ - baseURL: import.meta.env.VITE_STRAPI_URL, -}); - -export const articles = strapiClient.collection("articles"); diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi.tsx index bb9fc896..fd6db4ef 100644 --- a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi.tsx +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi.tsx @@ -1,64 +1,290 @@ -import { articles } from '@/lib/strapiClient' import { createFileRoute, Link } from '@tanstack/react-router' +import { z } from 'zod' +import { strapiApi } from '@/data/loaders' +import { StrapiImage } from '@/components/strapi-image' +import { Search } from '@/components/search' +import { Pagination } from '@/components/pagination' +import type { TArticle } from '@/types/strapi' + +type LoaderResult = { + status: 'success' | 'empty' | 'error' + articles: TArticle[] + meta?: { pagination?: { page: number; pageCount: number; total: number } } + error?: string + query?: string +} + +const searchSchema = z.object({ + query: z.string().optional(), + page: z.number().default(1), +}) export const Route = createFileRoute('/demo/strapi')({ component: RouteComponent, - loader: async () => { - const { data: strapiArticles } = await articles.find() - return strapiArticles + validateSearch: searchSchema, + loaderDeps: ({ search }) => ({ search }), + loader: async ({ deps }): Promise => { + const { query, page } = deps.search + try { + const response = await strapiApi.articles.getArticlesData({ + data: { query, page }, + }) + + // Check if we got data + if (!response || !response.data) { + return { + status: 'empty', + articles: [], + meta: response?.meta, + query, + } + } + + // Check if data array is empty + if (response.data.length === 0) { + return { + status: 'empty', + articles: [], + meta: response.meta, + query, + } + } + + return { + status: 'success', + articles: response.data, + meta: response.meta, + query, + } + } catch (error) { + console.error('Strapi fetch error:', error) + return { + status: 'error', + articles: [], + error: + error instanceof Error + ? error.message + : 'Failed to connect to Strapi', + query, + } + } }, }) +function StrapiServerInstructions() { + return ( +
    +

    + Start the Strapi Server +

    +
    +

    + $ cd ../server +

    +

    + $ npm install +

    +

    + $ npm run develop +

    +
    +

    + Then create an admin at{' '} + + http://localhost:1337/admin + +

    +
    + ) +} + +function ConnectionError({ error }: { error?: string }) { + return ( +
    +
    +
    ⚠️
    +
    +

    + Cannot Connect to Strapi +

    +

    + Make sure your Strapi server is running at{' '} + + http://localhost:1337 + +

    + {error && ( +

    Error: {error}

    + )} + +
    +
    +
    + ) +} + +function NoArticlesFound({ query }: { query?: string }) { + if (query) { + return ( +
    +
    +
    🔍
    +

    + No Results Found +

    +

    + No articles match your search for "{query}". Try adjusting your + search terms. +

    +
    +
    + ) + } + + return ( +
    +
    +
    📝
    +

    + No Articles Yet +

    +

    + Your Strapi server is running, but there are no published articles. + Create and publish your first article to see it here. +

    + +
    +

    + How to add articles: +

    +
      +
    1. + 1. + + Open{' '} + + Strapi Admin Panel + + +
    2. +
    3. + 2. + + Go to Content Manager →{' '} + Article + +
    4. +
    5. + 3. + + Click Create new entry + +
    6. +
    7. + 4. + + Fill in the details and click{' '} + Publish + +
    8. +
    +
    +
    +
    + ) +} + function RouteComponent() { - const strapiArticles = Route.useLoaderData() + const { status, articles, meta, error, query } = Route.useLoaderData() return (
    -

    +

    Strapi {' '} Articles

    - {strapiArticles && strapiArticles.length > 0 ? ( -
    - {strapiArticles.map((article) => ( - -
    -

    - {article.title || 'Untitled'} -

    - - {article.description && ( -

    - {article.description} -

    - )} - - {article.content && ( -

    - {article.content} -

    - )} - - {article.createdAt && ( -

    - {new Date(article.createdAt).toLocaleDateString()} -

    - )} -
    - - ))} -
    - ) : ( -

    No articles found.

    +
    + +
    + + {status === 'error' && } + + {status === 'empty' && } + + {status === 'success' && ( + <> +
    + {articles.map((article: TArticle) => ( + +
    + + +
    +

    + {article.title || 'Untitled'} +

    + + {article.description && ( +

    + {article.description} +

    + )} + +
    + {article.author?.name && ( + + By {article.author.name} + + )} + {article.createdAt && ( + + {new Date(article.createdAt).toLocaleDateString()} + + )} +
    + + {article.category?.name && ( +
    + + {article.category.name} + +
    + )} +
    +
    + + ))} +
    + + {meta?.pagination && meta.pagination.pageCount > 1 && ( +
    + +
    + )} + )}
    diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi_.$articleId.tsx b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi_.$articleId.tsx index 9f8a589a..bb0fba38 100644 --- a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi_.$articleId.tsx +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/routes/demo/strapi_.$articleId.tsx @@ -1,16 +1,95 @@ -import { articles } from '@/lib/strapiClient' -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute, Link } from "@tanstack/react-router"; +import { strapiApi } from "@/data/loaders"; +import { StrapiImage } from "@/components/strapi-image"; +import { BlockRenderer } from "@/components/blocks"; +import type { TArticle } from "@/types/strapi"; -export const Route = createFileRoute('/demo/strapi_/$articleId')({ +export const Route = createFileRoute("/demo/strapi_/$articleId")({ component: RouteComponent, + errorComponent: ErrorComponent, loader: async ({ params }) => { - const { data: article } = await articles.findOne(params.articleId) - return article + try { + const response = await strapiApi.articles.getArticleByIdData({ + data: params.articleId, + }); + return { success: true, article: response.data }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to load article", + article: null, + }; + } }, -}) +}); + +function ErrorComponent({ error }: { error: Error }) { + return ( +
    +
    + + ← Back to Articles + +
    +

    Error Loading Article

    +

    {error.message}

    +
    +
    +
    + ); +} function RouteComponent() { - const article = Route.useLoaderData() + const { success, article, error } = Route.useLoaderData() as { + success: boolean; + article: TArticle | null; + error?: string; + }; + + // Show error state + if (!success || !article) { + return ( +
    +
    + + + + + Back to Articles + + +
    +
    +
    ⚠️
    +
    +

    + {error || "Article Not Found"} +

    +

    + Make sure the Strapi server is running and the article exists. +

    +
    +
    +
    +
    +
    + ); + } return (
    @@ -34,45 +113,58 @@ function RouteComponent() { Back to Articles -
    -

    - {article?.title || 'Untitled'} -

    +
    + - {article?.createdAt && ( -

    - Published on{' '} - {new Date(article?.createdAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} -

    - )} +
    +

    + {article.title || "Untitled"} +

    - {article?.description && ( -
    -

    - Description -

    -

    - {article?.description} -

    +
    + {article.author?.name && ( + + By{" "} + {article.author.name} + + )} + {article.createdAt && ( + + {new Date(article.createdAt).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} + + )}
    - )} - {article?.content && ( -
    -

    - Content -

    -
    - {article?.content} + {article.category?.name && ( +
    + + {article.category.name} +
    -
    - )} + )} + + {article.description && ( +
    +

    + {article.description} +

    +
    + )} + + {article.blocks && article.blocks.length > 0 && ( + + )} +
    - ) + ); } diff --git a/packages/create/src/frameworks/react/add-ons/strapi/assets/src/types/strapi.ts b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/types/strapi.ts new file mode 100644 index 00000000..a22f0a23 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/strapi/assets/src/types/strapi.ts @@ -0,0 +1,90 @@ +/** + * Strapi type definitions + * These types match the Strapi Cloud Template Blog schema + */ + +import type { Block } from "@/components/blocks"; + +// Base image type from Strapi media library +export type TImage = { + id: number; + documentId: string; + alternativeText: string | null; + url: string; +}; + +// Author content type +export type TAuthor = { + id: number; + documentId: string; + name: string; + email?: string; + createdAt: string; + updatedAt: string; + publishedAt: string; +}; + +// Category content type +export type TCategory = { + id: number; + documentId: string; + name: string; + slug: string; + description?: string; + createdAt: string; + updatedAt: string; + publishedAt: string; +}; + +// Article content type +export type TArticle = { + id: number; + documentId: string; + title: string; + description: string; + slug: string; + cover?: TImage; + author?: TAuthor; + category?: TCategory; + blocks?: Array; + createdAt: string; + updatedAt: string; + publishedAt: string; +}; + +// Strapi response wrappers +export type TStrapiResponseSingle = { + data: T; + meta?: { + pagination?: TStrapiPagination; + }; +}; + +export type TStrapiResponseCollection = { + data: Array; + meta?: { + pagination?: TStrapiPagination; + }; +}; + +export type TStrapiPagination = { + page: number; + pageSize: number; + pageCount: number; + total: number; +}; + +export type TStrapiError = { + status: number; + name: string; + message: string; + details?: Record>; +}; + +export type TStrapiResponse = { + data?: T; + error?: TStrapiError; + meta?: { + pagination?: TStrapiPagination; + }; +}; diff --git a/packages/create/src/frameworks/react/add-ons/strapi/info.json b/packages/create/src/frameworks/react/add-ons/strapi/info.json index 1761cd27..744b47cf 100644 --- a/packages/create/src/frameworks/react/add-ons/strapi/info.json +++ b/packages/create/src/frameworks/react/add-ons/strapi/info.json @@ -8,12 +8,21 @@ "color": "#4945FF", "priority": 110, "modes": ["file-router"], + "dependsOn": ["start"], + "nextSteps": "cd ../server\nnpm install\nnpm run develop\nCreate an admin at http://localhost:1337/admin\nGo to Content Manager > Article and publish articles", + "command": { + "command": "sh", + "args": [ + "-c", + "cd .. && git clone --depth 1 https://github.com/strapi/strapi-cloud-template-blog.git server && cd server && rm -rf .git .yarnrc.yml yarn.lock && cp .env.example .env" + ] + }, "routes": [ { "url": "/demo/strapi", - "name": "Strapi", - "path": "src/routes/demo.strapi.tsx", - "jsName": "StrapiDemo" + "name": "Strapi Articles", + "path": "src/routes/demo/strapi.tsx", + "jsName": "StrapiArticles" } ] } diff --git a/packages/create/src/frameworks/react/add-ons/strapi/package.json b/packages/create/src/frameworks/react/add-ons/strapi/package.json index 2f040cae..2038ac02 100644 --- a/packages/create/src/frameworks/react/add-ons/strapi/package.json +++ b/packages/create/src/frameworks/react/add-ons/strapi/package.json @@ -1,5 +1,8 @@ { "dependencies": { - "@strapi/client": "^1.5.0" + "@strapi/client": "^1.6.0", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0", + "use-debounce": "^10.0.0" } -} \ No newline at end of file +} diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index 56380cb4..b4b34ebc 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -37,6 +37,10 @@ export const AddOnBaseSchema = z.object({ link: z.string().optional(), license: z.string().optional(), warning: z.string().optional(), + // TODO: This property was added for add-ons that require post-creation setup steps + // (e.g., Strapi needs users to install/run a sibling server directory). + // Decide if this should be a core feature or if add-ons should document this in their README instead. + nextSteps: z.string().optional(), tailwind: z.boolean().optional().default(true), type: z.enum(['add-on', 'example', 'starter', 'toolchain', 'deployment']), category: z