Skip to content

Commit

Permalink
feat(recipes): add react-router v7 example
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewlynch committed Dec 22, 2024
1 parent f323610 commit 3f01c70
Show file tree
Hide file tree
Showing 20 changed files with 964 additions and 49 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Available recipes include:

- [**next**](https://github.com/measuredco/puck/tree/main/recipes/next): Next.js 13 app example, using App Router and static page generation
- [**remix**](https://github.com/measuredco/puck/tree/main/recipes/remix): Remix Run v2 app example, using dynamic routes at root-level
- [**react-router**](https://github.com/measuredco/puck/tree/main/recipes/react-router): React Router v7 app example, using dynamic routes to create pages at any level

## Community

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@
],
"version": "0.17.1",
"engines": {
"node": ">=18.18"
"node": ">=20.0.0"
}
}
38 changes: 38 additions & 0 deletions packages/create-puck-app/templates/react-router/package.json.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "{{appName}}",
"version": "1.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
"dev": "react-router dev",
"start": "cross-env NODE_ENV=production react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@measured/puck": "{{puckVersion}}",
"@react-router/node": "^7.0.2",
"@react-router/serve": "^7.0.2",
"isbot": "^5.1.17",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.0.2"
},
"devDependencies": {
"@react-router/dev": "^7.0.2",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
"engines": {
"node": ">=22.0.0"
}
}
6 changes: 6 additions & 0 deletions recipes/react-router/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/
39 changes: 39 additions & 0 deletions recipes/react-router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `react-router` recipe

The `react-router` recipe showcases one of the most powerful ways to implement Puck using to provide an authoring tool for any route in your React Router app.

## Demonstrates

- React Router V7 (framework) implementation
- JSON database implementation
- Splat route to use puck for any route on the platform

## Usage

Run the generator and enter `react-router` when prompted

```
npx create-puck-app my-app
```

Start the server

```
yarn dev
```

Navigate to the homepage at http://localhost:5173/. To edit the homepage, access the Puck editor at http://localhost:5173/edit.

You can do this for any **base** route on the application, **even if the page doesn't exist**. For example, visit http://localhost:5173/hello-world and you'll receive a 404. You can author and publish a page by visiting http://localhost:5173/hello-world/edit. After publishing, go back to the original URL to see your page.

## Using this recipe

To adopt this recipe you will need to:

- **IMPORTANT** Add authentication to `/edit` routes. This can be done by modifying the [route module action](https://reactrouter.com/start/framework/route-module#action) in the splat route `/app/routes/puck-splat.tsx`. **If you don't do this, Puck will be completely public.**
- Integrate your database into the functions in `/lib/pages.server.ts`
- Implement a custom puck configuration in `/app/puck.config.tsx`

## License

MIT © [Measured Co.](https://github.com/measuredco)
12 changes: 12 additions & 0 deletions recipes/react-router/app/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

html,
body {
@apply bg-white dark:bg-gray-950;

@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
29 changes: 29 additions & 0 deletions recipes/react-router/app/lib/pages.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs/promises";
import type { Data } from "@measured/puck";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const databasePath = path.join(__dirname, "..", "..", "database.json");

export async function getPage(path: string) {
const pages = await readDatabase();
return pages[path];
}

export async function savePage(path: string, data: Data) {
const pages = await readDatabase();
pages[path] = data;
await fs.writeFile(databasePath, JSON.stringify(pages), { encoding: "utf8" });
}

async function readDatabase() {
try {
const file = await fs.readFile(databasePath, "utf8");
return JSON.parse(file) as Record<string, Data>;
} catch (error: unknown) {
console.error(error);
return {};
}
}
13 changes: 13 additions & 0 deletions recipes/react-router/app/lib/resolve-puck-path.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function resolvePuckPath(path: string = "") {
const url = new URL(path, "https://placeholder.com/");
const segments = url.pathname.split("/");
const isEditorRoute = segments.at(-1) === "edit";
const pathname = isEditorRoute
? segments.slice(0, -1).join("/")
: url.pathname;

return {
isEditorRoute,
path: new URL(pathname, "https://placeholder.com/").pathname,
};
}
23 changes: 23 additions & 0 deletions recipes/react-router/app/puck.config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Config } from "@measured/puck";

type Props = {
HeadingBlock: { title: string };
};

export const config: Config<Props> = {
components: {
HeadingBlock: {
fields: {
title: { type: "text" },
},
defaultProps: {
title: "Heading",
},
render: ({ title }) => (
<div style={{ padding: 64 }}>
<h1>{title}</h1>
</div>
),
},
},
};
76 changes: 76 additions & 0 deletions recipes/react-router/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";

import type { Route } from "./+types/root";
import stylesheet from "./app.css?url";

export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{ rel: "stylesheet", href: stylesheet },
];

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}

return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
3 changes: 3 additions & 0 deletions recipes/react-router/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { type RouteConfig, route } from "@react-router/dev/routes";

export default [route("*", "routes/puck-splat.tsx")] satisfies RouteConfig;
93 changes: 93 additions & 0 deletions recipes/react-router/app/routes/puck-splat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useFetcher, useLoaderData } from "react-router";
import type { Data } from "@measured/puck";
import { Puck, Render } from "@measured/puck";

import "@measured/puck/puck.css";

import type { Route } from "./+types/puck-splat";
import { config } from "~/puck.config";
import { resolvePuckPath } from "~/lib/resolve-puck-path.server";
import { getPage, savePage } from "~/lib/pages.server";

export async function loader({ params }: Route.LoaderArgs) {
const pathname = params["*"];
const { isEditorRoute, path } = resolvePuckPath(pathname);
let page = await getPage(path);

// Throw a 404 if we're not rendering the editor and data for the page does not exist
if (!isEditorRoute && !page) {
throw new Response("Not Found", { status: 404 });
}

// Empty shell for new pages
if (isEditorRoute && !page) {
page = {
content: [],
root: {
props: {
title: "",
},
},
};
}

return {
isEditorRoute,
path,
data: page,
};
}

export function meta({ data: loaderData }: Route.MetaArgs) {
return [
{
title: loaderData.isEditorRoute
? `Edit: ${loaderData.path}`
: loaderData.data.root.title,
},
];
}

export default function PuckSplatRoute({ loaderData }: Route.ComponentProps) {
return (
<div>
{loaderData.isEditorRoute ? (
<Editor />
) : (
<Render config={config} data={loaderData.data} />
)}
</div>
);
}

export async function action({ params, request }: Route.ActionArgs) {
const pathname = params["*"];
const { path } = resolvePuckPath(pathname);
const body = (await request.json()) as { data: Data };

await savePage(path, body.data);
}

function Editor() {
const loaderData = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();

return (
<Puck
config={config}
data={loaderData.data}
onPublish={async (data) => {
await fetcher.submit(
{
data,
},
{
action: "",
method: "post",
encType: "application/json",
},
);
}}
/>
);
}
1 change: 1 addition & 0 deletions recipes/react-router/database.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"/":{"content":[{"type":"HeadingBlock","props":{"title":"Edit this page by adding /edit to the end of the URL","id":"HeadingBlock-1694032984497"}}],"root":{"props":{"title":"Puck + React Router 7 demo"}},"zones":{}}}
34 changes: 34 additions & 0 deletions recipes/react-router/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "react-router-recipe",
"private": true,
"type": "module",
"version": "1.0.0",
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
"dev": "react-router dev",
"start": "cross-env NODE_ENV=production react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@measured/puck": "^0.17.1",
"@react-router/node": "^7.0.2",
"@react-router/serve": "^7.0.2",
"isbot": "^5.1.17",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.0.2"
},
"devDependencies": {
"@react-router/dev": "^7.0.2",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
}
Binary file added recipes/react-router/public/favicon.ico
Binary file not shown.
Loading

0 comments on commit 3f01c70

Please sign in to comment.