Initial commit: json-render crepes demo
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
k8s
|
||||
44
.gitea/workflows/build.yaml
Normal file
44
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.aymerick.fr
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.aymerick.fr/${{ gitea.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.aymerick.fr/${{ gitea.repository }}:buildcache
|
||||
cache-to: type=registry,ref=git.aymerick.fr/${{ gitea.repository }}:buildcache,mode=max
|
||||
59
.github/workflows/deploy.yml
vendored
Normal file
59
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Update deployment manifest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# Update image tag in deployment.yaml
|
||||
sed -i "s|image:.*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest|" k8s/deployment.yaml
|
||||
|
||||
# Commit and push if changes exist
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
git add k8s/deployment.yaml
|
||||
git diff --staged --quiet || (git commit -m "Update image tag to latest [skip ci]" && git push)
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
193
DEPLOYMENT_SUMMARY.md
Normal file
193
DEPLOYMENT_SUMMARY.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# 🥞 Crêpes Demo - Deployment Summary
|
||||
|
||||
## ✅ What's Built
|
||||
|
||||
A beautiful Next.js demo showcasing **json-render** architecture with French chef crêpe recipes:
|
||||
|
||||
### Features
|
||||
- 🎨 **4 Authentic Recipes** from legendary French chefs:
|
||||
- **Auguste Escoffier** - Crêpes Suzette (flambéed orange dessert)
|
||||
- **Olivier Roellinger** - Breton Buckwheat Galettes
|
||||
- **Pierre Hermé** - Salted Caramel Crêpes
|
||||
- **Joël Robuchon** - Crêpes Parmentier (potato & bacon)
|
||||
|
||||
- 💎 **Type-Safe Component Catalog** inspired by json-render:
|
||||
- `RecipeHeader` - Title, chef, timing info
|
||||
- `ChefInfo` - Bio with source links
|
||||
- `IngredientList` - Structured ingredients
|
||||
- `InstructionSteps` - Numbered cooking steps
|
||||
- `TipCard` - Chef's professional tips
|
||||
|
||||
- 🎯 **Production-Ready**:
|
||||
- Next.js 16 with TypeScript
|
||||
- Tailwind CSS styling
|
||||
- Docker multi-stage build
|
||||
- Kubernetes manifests
|
||||
- Flux GitOps config
|
||||
- GitHub Actions CI/CD workflow
|
||||
|
||||
## 🚀 Quick Deployment Options
|
||||
|
||||
### Option 1: Local Test (Fastest)
|
||||
```bash
|
||||
cd /home/openclaw/.openclaw/workspace/crepes-demo
|
||||
npm run dev
|
||||
# Visit http://localhost:3000
|
||||
```
|
||||
|
||||
### Option 2: Docker
|
||||
```bash
|
||||
cd /home/openclaw/.openclaw/workspace/crepes-demo
|
||||
docker build -t crepes-demo .
|
||||
docker run -p 3000:3000 crepes-demo
|
||||
```
|
||||
|
||||
### Option 3: Kubernetes + Flux (Full Production)
|
||||
|
||||
#### Prerequisites
|
||||
- Kubernetes cluster with Flux installed
|
||||
- Container registry (GHCR, Docker Hub, etc.)
|
||||
- Domain configured with Cloudflare
|
||||
- Ingress controller (nginx, traefik)
|
||||
- cert-manager for TLS
|
||||
|
||||
#### Deploy Script
|
||||
```bash
|
||||
cd /home/openclaw/.openclaw/workspace/crepes-demo
|
||||
|
||||
# Set your details
|
||||
export REGISTRY="ghcr.io/YOUR_USERNAME"
|
||||
export DOMAIN="crepes.yourdomain.com"
|
||||
|
||||
# Build, push, and update manifests
|
||||
./deploy.sh $REGISTRY $DOMAIN
|
||||
|
||||
# Push to Git
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial: French crêpes demo"
|
||||
git remote add origin https://github.com/YOUR_USERNAME/crepes-demo.git
|
||||
git push -u origin main
|
||||
|
||||
# Update Flux git URL in flux/gitrepository.yaml
|
||||
# Then apply to cluster:
|
||||
kubectl apply -f flux/gitrepository.yaml
|
||||
kubectl apply -f flux/kustomization.yaml
|
||||
|
||||
# Watch deployment
|
||||
flux get kustomizations -w
|
||||
```
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
crepes-demo/
|
||||
├── app/
|
||||
│ ├── page.tsx # Main page
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── globals.css # Global styles
|
||||
├── lib/
|
||||
│ ├── components.tsx # Reusable recipe components
|
||||
│ ├── recipes-simple.ts # Recipe data
|
||||
│ ├── catalog.ts # json-render catalog (reference)
|
||||
│ ├── registry.tsx # json-render registry (reference)
|
||||
│ └── recipes.ts # json-render specs (reference)
|
||||
├── k8s/
|
||||
│ ├── deployment.yaml # K8s Deployment
|
||||
│ ├── service.yaml # K8s Service
|
||||
│ ├── ingress.yaml # K8s Ingress (Cloudflare)
|
||||
│ └── kustomization.yaml # Kustomize config
|
||||
├── flux/
|
||||
│ ├── gitrepository.yaml # Flux GitRepository
|
||||
│ └── kustomization.yaml # Flux Kustomization
|
||||
├── .github/workflows/
|
||||
│ └── deploy.yml # GitHub Actions CI/CD
|
||||
├── Dockerfile # Multi-stage build
|
||||
├── deploy.sh # Automated deployment script
|
||||
├── README.md # Full documentation
|
||||
├── QUICKSTART.md # Quick deployment guide
|
||||
└── DEPLOYMENT_SUMMARY.md # This file
|
||||
```
|
||||
|
||||
## 🎨 Tech Stack
|
||||
|
||||
- **Framework**: Next.js 16 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: Tailwind CSS
|
||||
- **json-render**: Component catalog architecture (demonstrated but simplified for SSR)
|
||||
- **Container**: Docker multi-stage build
|
||||
- **Orchestration**: Kubernetes
|
||||
- **GitOps**: Flux CD
|
||||
- **CI/CD**: GitHub Actions
|
||||
- **CDN/DNS**: Cloudflare
|
||||
|
||||
## 🔧 Configuration Points
|
||||
|
||||
Before deploying to production, update:
|
||||
|
||||
1. **k8s/deployment.yaml**:
|
||||
- Line 22: `image: YOUR_REGISTRY/crepes-demo:latest`
|
||||
|
||||
2. **k8s/ingress.yaml**:
|
||||
- Lines 8, 15, 18: Replace `crepes.yourdomain.com` with your domain
|
||||
- Line 10: Verify `ingressClassName`
|
||||
- Line 7: Adjust cert-manager issuer if needed
|
||||
|
||||
3. **flux/gitrepository.yaml**:
|
||||
- Line 7: Update Git repository URL
|
||||
|
||||
4. **.github/workflows/deploy.yml**:
|
||||
- Already configured for GitHub Container Registry (GHCR)
|
||||
- Will auto-push on commits to `main`
|
||||
|
||||
## 📝 What json-render Brings
|
||||
|
||||
This demo uses **actual json-render** from Vercel Labs:
|
||||
|
||||
1. **Component Catalog** (`lib/catalog.ts`) - Defines allowed components with Zod schemas using `defineCatalog`
|
||||
2. **Type-Safe Registry** (`lib/registry.tsx`) - Maps component names to React implementations using `defineRegistry`
|
||||
3. **JSON Specs** (`lib/recipes.ts`) - Recipe definitions in json-render's spec format (`root` + `elements`)
|
||||
4. **Renderer Component** - Uses json-render's `<Renderer>` to transform specs into React components
|
||||
5. **Provider** - Wraps with ActionProvider, DataProvider, and VisibilityProvider for full functionality
|
||||
|
||||
**Key Point**: This uses the real json-render library. The `Renderer` component takes our JSON specs and registry, then renders them as React components. In production, AI would generate these specs from user prompts—safely constrained to your component catalog.
|
||||
|
||||
## 🌐 Public URL Setup (Cloudflare)
|
||||
|
||||
Once deployed to K8s with the provided ingress:
|
||||
|
||||
1. **Automatic** (with external-dns):
|
||||
- DNS records created automatically
|
||||
- Certificate issued by cert-manager
|
||||
- Cloudflare proxy enabled via annotations
|
||||
|
||||
2. **Manual**:
|
||||
- Go to Cloudflare dashboard
|
||||
- Add A/CNAME record for your domain → ingress IP
|
||||
- Enable Cloudflare proxy (orange cloud)
|
||||
- Set SSL/TLS to "Full (strict)"
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Test locally** first to verify everything works
|
||||
2. **Customize** the recipes or add new components
|
||||
3. **Deploy** using the deployment script
|
||||
4. **Monitor** via Flux and kubectl
|
||||
5. **Iterate** - Flux syncs automatically on git push
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **README.md** - Full detailed documentation
|
||||
- **QUICKSTART.md** - Fast deployment guide
|
||||
- **DEPLOYMENT_SUMMARY.md** - This overview (you are here)
|
||||
- [json-render GitHub](https://github.com/vercel-labs/json-render) - Original library
|
||||
|
||||
## 🎉 Ready to Go!
|
||||
|
||||
Your demo is built and ready. Test it locally, then deploy to your K8s cluster with Cloudflare for a public URL.
|
||||
|
||||
**Questions? Check the README or QUICKSTART files for detailed instructions.**
|
||||
|
||||
---
|
||||
|
||||
**Bon Appétit! 🇫🇷**
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
228
HOW_IT_WORKS.md
Normal file
228
HOW_IT_WORKS.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 🎯 How json-render Works in This Demo
|
||||
|
||||
## The Flow
|
||||
|
||||
```
|
||||
User Prompt (in production)
|
||||
↓
|
||||
AI generates JSON spec (constrained to catalog)
|
||||
↓
|
||||
json-render Renderer component
|
||||
↓
|
||||
Beautiful React UI
|
||||
```
|
||||
|
||||
## Step-by-Step Example
|
||||
|
||||
### 1. Define Your Component Catalog
|
||||
|
||||
`lib/catalog.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react";
|
||||
import { z } from "zod";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
RecipeHeader: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
chef: z.string(),
|
||||
prepTime: z.string(),
|
||||
}),
|
||||
description: "Header with recipe info",
|
||||
},
|
||||
IngredientList: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
ingredients: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
quantity: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
description: "List of ingredients",
|
||||
},
|
||||
// ... more components
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
**This is your guardrail**: AI can only use these components.
|
||||
|
||||
### 2. Map Components to React
|
||||
|
||||
`lib/registry.tsx`:
|
||||
|
||||
```typescript
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
RecipeHeader: ({ props }) => (
|
||||
<div className="bg-amber-50 p-8 rounded-2xl">
|
||||
<h1 className="text-4xl font-bold">{props.title}</h1>
|
||||
<span>👨🍳 Chef: {props.chef}</span>
|
||||
<span>⏱️ Prep: {props.prepTime}</span>
|
||||
</div>
|
||||
),
|
||||
IngredientList: ({ props }) => (
|
||||
<div className="bg-green-50 p-6">
|
||||
<h3>{props.title}</h3>
|
||||
<ul>
|
||||
{props.ingredients.map((ing, i) => (
|
||||
<li key={i}>
|
||||
{ing.quantity} {ing.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
// ... more implementations
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Create a JSON Spec
|
||||
|
||||
`lib/recipes.ts`:
|
||||
|
||||
```typescript
|
||||
const recipe = {
|
||||
root: "root", // Start rendering from this element
|
||||
elements: {
|
||||
root: {
|
||||
type: "RecipeCard", // Component from catalog
|
||||
props: {
|
||||
title: "Crêpes Suzette",
|
||||
description: "Classic French dessert"
|
||||
},
|
||||
children: ["header", "ingredients"] // Refs to other elements
|
||||
},
|
||||
header: {
|
||||
type: "RecipeHeader",
|
||||
props: {
|
||||
title: "Crêpes Suzette",
|
||||
chef: "Auguste Escoffier",
|
||||
prepTime: "45 minutes"
|
||||
}
|
||||
},
|
||||
ingredients: {
|
||||
type: "IngredientList",
|
||||
props: {
|
||||
title: "Ingredients",
|
||||
ingredients: [
|
||||
{ quantity: "200g", name: "Flour" },
|
||||
{ quantity: "4", name: "Eggs" },
|
||||
{ quantity: "500ml", name: "Milk" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**This is what AI generates** (but here we've pre-written it for the demo).
|
||||
|
||||
### 4. Render with json-render
|
||||
|
||||
`app/page.tsx`:
|
||||
|
||||
```typescript
|
||||
import { Renderer } from "@json-render/react";
|
||||
import { registry, Provider } from "@/lib/registry";
|
||||
import { recipes } from "@/lib/recipes";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
{recipes.map((recipe, index) => (
|
||||
<Provider key={index}>
|
||||
<Renderer spec={recipe} registry={registry} />
|
||||
</Provider>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Magic happens here**: The `Renderer` takes your spec + registry and outputs fully-styled React components!
|
||||
|
||||
## What Gets Rendered
|
||||
|
||||
The spec above becomes:
|
||||
|
||||
```jsx
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl">
|
||||
<h2>Crêpes Suzette</h2>
|
||||
<p>Classic French dessert</p>
|
||||
|
||||
<div className="bg-amber-50 p-8 rounded-2xl">
|
||||
<h1 className="text-4xl font-bold">Crêpes Suzette</h1>
|
||||
<span>👨🍳 Chef: Auguste Escoffier</span>
|
||||
<span>⏱️ Prep: 45 minutes</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-6">
|
||||
<h3>Ingredients</h3>
|
||||
<ul>
|
||||
<li>200g Flour</li>
|
||||
<li>4 Eggs</li>
|
||||
<li>500ml Milk</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
### Without json-render:
|
||||
- AI generates raw HTML/JSX → **Security risk** (XSS, code injection)
|
||||
- No type safety → **Runtime errors**
|
||||
- Unpredictable output → **Broken UIs**
|
||||
|
||||
### With json-render:
|
||||
- ✅ AI can only use predefined components (guardrailed)
|
||||
- ✅ Zod schemas enforce type safety (predictable)
|
||||
- ✅ Your React components control the rendering (safe)
|
||||
- ✅ Stream rendering as AI generates (fast)
|
||||
|
||||
## In Production with AI
|
||||
|
||||
```typescript
|
||||
// User types prompt
|
||||
const userPrompt = "Show me a recipe for chocolate chip cookies";
|
||||
|
||||
// AI generates spec (using your catalog as context)
|
||||
const aiGeneratedSpec = await ai.generate({
|
||||
prompt: userPrompt,
|
||||
catalog: catalog.prompt(), // Gives AI the component vocabulary
|
||||
});
|
||||
|
||||
// Render safely
|
||||
<Renderer spec={aiGeneratedSpec} registry={registry} />
|
||||
```
|
||||
|
||||
**Result**: AI-generated UIs that are:
|
||||
1. **Safe** - Can't inject malicious code
|
||||
2. **Type-safe** - Matches your schemas
|
||||
3. **Beautiful** - Uses your styled components
|
||||
4. **Fast** - Streams as AI generates
|
||||
|
||||
## The Power
|
||||
|
||||
You define the components once. Then:
|
||||
- AI can generate infinite variations
|
||||
- Users can describe UIs in natural language
|
||||
- Your app stays safe and consistent
|
||||
- No manual UI coding needed
|
||||
|
||||
**That's json-render.**
|
||||
|
||||
---
|
||||
|
||||
Check the full code in `lib/` to see it in action! 🚀
|
||||
188
QUICKSTART.md
Normal file
188
QUICKSTART.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 🚀 Quick Start Guide
|
||||
|
||||
Get your crêpes demo running in under 10 minutes!
|
||||
|
||||
## Option 1: Local Development (Fastest)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit http://localhost:3000 🎉
|
||||
|
||||
## Option 2: Docker (Easy)
|
||||
|
||||
```bash
|
||||
docker build -t crepes-demo .
|
||||
docker run -p 3000:3000 crepes-demo
|
||||
```
|
||||
|
||||
Visit http://localhost:3000 🎉
|
||||
|
||||
## Option 3: Kubernetes with Flux (Production)
|
||||
|
||||
### Prerequisites Checklist
|
||||
|
||||
- [ ] Kubernetes cluster running
|
||||
- [ ] Flux installed (`flux check`)
|
||||
- [ ] Container registry access (Docker Hub, GHCR, etc.)
|
||||
- [ ] Domain with Cloudflare
|
||||
- [ ] Ingress controller installed
|
||||
- [ ] cert-manager installed (for HTTPS)
|
||||
|
||||
### Deploy in 5 Steps
|
||||
|
||||
#### 1. Configure Your Details
|
||||
|
||||
```bash
|
||||
export REGISTRY="ghcr.io/YOUR_USERNAME" # Your container registry
|
||||
export DOMAIN="crepes.example.com" # Your domain
|
||||
```
|
||||
|
||||
#### 2. Use the Deploy Script
|
||||
|
||||
```bash
|
||||
./deploy.sh $REGISTRY $DOMAIN
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build the Docker image
|
||||
- Push to your registry
|
||||
- Update k8s manifests with your registry and domain
|
||||
|
||||
#### 3. Initialize Git Repository
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit: Crêpes demo"
|
||||
git remote add origin https://github.com/YOUR_USERNAME/crepes-demo.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
#### 4. Update Flux Configuration
|
||||
|
||||
Edit `flux/gitrepository.yaml` and update the Git URL:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
url: https://github.com/YOUR_USERNAME/crepes-demo
|
||||
```
|
||||
|
||||
#### 5. Deploy to Cluster
|
||||
|
||||
```bash
|
||||
# Apply Flux resources
|
||||
kubectl apply -f flux/gitrepository.yaml
|
||||
kubectl apply -f flux/kustomization.yaml
|
||||
|
||||
# Watch it deploy
|
||||
flux get kustomizations -w
|
||||
```
|
||||
|
||||
#### 6. Configure DNS
|
||||
|
||||
**Automatic (with external-dns):**
|
||||
DNS records are created automatically ✨
|
||||
|
||||
**Manual:**
|
||||
1. Go to Cloudflare dashboard
|
||||
2. Add an A/CNAME record for `crepes.example.com` pointing to your ingress IP
|
||||
3. Enable Cloudflare proxy (orange cloud)
|
||||
|
||||
### Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check pods are running
|
||||
kubectl get pods -l app=crepes-demo
|
||||
|
||||
# Check ingress
|
||||
kubectl get ingress crepes-demo
|
||||
|
||||
# Get your ingress IP
|
||||
kubectl get ingress crepes-demo -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
|
||||
```
|
||||
|
||||
Visit your domain: `https://crepes.example.com` 🎉
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Pods not starting?
|
||||
|
||||
```bash
|
||||
kubectl describe pod -l app=crepes-demo
|
||||
kubectl logs -l app=crepes-demo
|
||||
```
|
||||
|
||||
### Image pull errors?
|
||||
|
||||
- Check registry credentials
|
||||
- Verify image was pushed: `docker images | grep crepes-demo`
|
||||
- Check deployment image URL
|
||||
|
||||
### Ingress not working?
|
||||
|
||||
```bash
|
||||
# Check ingress controller logs
|
||||
kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller
|
||||
|
||||
# Verify ingress has address
|
||||
kubectl get ingress
|
||||
```
|
||||
|
||||
### Certificate issues?
|
||||
|
||||
```bash
|
||||
kubectl get certificate
|
||||
kubectl describe certificate crepes-demo-tls
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
```bash
|
||||
# Watch pods
|
||||
kubectl get pods -l app=crepes-demo -w
|
||||
|
||||
# View logs
|
||||
kubectl logs -f -l app=crepes-demo
|
||||
|
||||
# Check Flux sync
|
||||
flux get kustomizations
|
||||
flux logs
|
||||
```
|
||||
|
||||
## 🔄 Update the App
|
||||
|
||||
```bash
|
||||
# Make your changes
|
||||
git add .
|
||||
git commit -m "Update recipe"
|
||||
git push
|
||||
|
||||
# Flux syncs automatically every 1 minute
|
||||
# Or force immediate sync:
|
||||
flux reconcile kustomization crepes-demo
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
- **Customize recipes**: Edit `lib/recipes.ts`
|
||||
- **Add components**: Update `lib/catalog.ts` and `lib/registry.tsx`
|
||||
- **Change styling**: Modify Tailwind classes in components
|
||||
- **Add CI/CD**: Use the included GitHub Actions workflow
|
||||
- **Scale**: Increase replicas in `k8s/deployment.yaml`
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **Test locally first** before deploying to k8s
|
||||
2. **Use a staging environment** for major changes
|
||||
3. **Monitor Flux logs** during first deployment
|
||||
4. **Set up alerts** for deployment failures
|
||||
5. **Enable auto-image updates** in Flux for CI/CD
|
||||
|
||||
---
|
||||
|
||||
Need help? Check the full [README.md](README.md) for detailed documentation.
|
||||
|
||||
**Bon Appétit! 🥞🇫🇷**
|
||||
382
README.md
Normal file
382
README.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# 🥞 Les Crêpes de Maîtres
|
||||
|
||||
A beautiful demo showcasing **[json-render](https://github.com/vercel-labs/json-render)** with French chef crêpe recipes. Features recipes from legendary chefs like Auguste Escoffier, Olivier Roellinger, Pierre Hermé, and Joël Robuchon.
|
||||
|
||||
## 🎯 What is json-render?
|
||||
|
||||
**json-render** is a library from Vercel Labs that enables AI-generated UIs with guardrailed components. It provides:
|
||||
|
||||
- **Guardrailed** — AI can only use components in your catalog
|
||||
- **Predictable** — JSON output matches your schema, every time
|
||||
- **Fast** — Stream and render progressively as the model responds
|
||||
|
||||
This demo uses json-render's `Renderer` component with a type-safe catalog to render recipe specs as React components.
|
||||
|
||||
## ✨ How It Works
|
||||
|
||||
1. **Define Component Catalog** (`lib/catalog.ts`):
|
||||
```typescript
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
RecipeHeader: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
chef: z.string(),
|
||||
// ...
|
||||
}),
|
||||
description: "Header with recipe info"
|
||||
},
|
||||
// ...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
2. **Map to React Components** (`lib/registry.tsx`):
|
||||
```typescript
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
RecipeHeader: ({ props }) => (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
{/* ... */}
|
||||
</div>
|
||||
),
|
||||
// ...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
3. **Create JSON Specs** (`lib/recipes.ts`):
|
||||
```typescript
|
||||
const recipe = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "RecipeCard",
|
||||
props: { title: "Crêpes Suzette", /* ... */ },
|
||||
children: ["chef", "header", "ingredients", /* ... */]
|
||||
},
|
||||
chef: {
|
||||
type: "ChefInfo",
|
||||
props: { name: "Auguste Escoffier", /* ... */ }
|
||||
},
|
||||
// ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
4. **Render with json-render** (`app/page.tsx`):
|
||||
```typescript
|
||||
<Provider>
|
||||
<Renderer spec={recipe} registry={registry} />
|
||||
</Provider>
|
||||
```
|
||||
|
||||
**Result**: AI-generated JSON specs become beautiful, type-safe React UIs!
|
||||
|
||||
## 🚀 Quick Start (Local Development)
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Open http://localhost:3000
|
||||
```
|
||||
|
||||
## 🐳 Docker Build
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t crepes-demo:latest .
|
||||
|
||||
# Run locally
|
||||
docker run -p 3000:3000 crepes-demo:latest
|
||||
```
|
||||
|
||||
## ☸️ Kubernetes Deployment with Flux
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster with Flux installed
|
||||
- Container registry (Docker Hub, GitHub Container Registry, etc.)
|
||||
- Domain configured with Cloudflare
|
||||
- Ingress controller (nginx, traefik, etc.)
|
||||
- cert-manager for TLS certificates
|
||||
- external-dns for automatic DNS records (optional)
|
||||
|
||||
### Step 1: Build and Push Image
|
||||
|
||||
```bash
|
||||
# Tag your image
|
||||
docker tag crepes-demo:latest YOUR_REGISTRY/crepes-demo:latest
|
||||
|
||||
# Push to registry
|
||||
docker push YOUR_REGISTRY/crepes-demo:latest
|
||||
```
|
||||
|
||||
### Step 2: Update Configuration
|
||||
|
||||
**k8s/deployment.yaml:**
|
||||
- Update `image:` with your registry URL
|
||||
|
||||
**k8s/ingress.yaml:**
|
||||
- Update all instances of `crepes.yourdomain.com` with your actual domain
|
||||
- Adjust `ingressClassName` if not using nginx
|
||||
- Verify cert-manager issuer name
|
||||
|
||||
**flux/gitrepository.yaml:**
|
||||
- Update Git repository URL with your fork
|
||||
|
||||
### Step 3: Use the Deploy Script
|
||||
|
||||
```bash
|
||||
cd /home/openclaw/.openclaw/workspace/crepes-demo
|
||||
|
||||
# Set your configuration
|
||||
export REGISTRY="ghcr.io/YOUR_USERNAME"
|
||||
export DOMAIN="crepes.yourdomain.com"
|
||||
|
||||
# Run deployment script
|
||||
./deploy.sh $REGISTRY $DOMAIN
|
||||
```
|
||||
|
||||
### Step 4: Push to Git
|
||||
|
||||
```bash
|
||||
# Initialize git repository
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit: json-render crêpes demo"
|
||||
|
||||
# Push to your GitHub repository
|
||||
git remote add origin https://github.com/YOUR_USERNAME/crepes-demo.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### Step 5: Deploy Flux Resources
|
||||
|
||||
```bash
|
||||
# Apply Flux GitRepository and Kustomization
|
||||
kubectl apply -f flux/gitrepository.yaml
|
||||
kubectl apply -f flux/kustomization.yaml
|
||||
|
||||
# Watch deployment
|
||||
kubectl get kustomizations -n flux-system -w
|
||||
```
|
||||
|
||||
### Step 6: Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check pods
|
||||
kubectl get pods -l app=crepes-demo
|
||||
|
||||
# Check service
|
||||
kubectl get svc crepes-demo
|
||||
|
||||
# Check ingress
|
||||
kubectl get ingress crepes-demo
|
||||
|
||||
# Check Flux sync status
|
||||
flux get kustomizations
|
||||
```
|
||||
|
||||
### Step 7: Configure Cloudflare
|
||||
|
||||
If using external-dns with Cloudflare:
|
||||
|
||||
1. DNS records should be created automatically
|
||||
2. Verify in Cloudflare dashboard
|
||||
3. Ensure SSL/TLS mode is "Full (strict)" or "Full"
|
||||
|
||||
If configuring manually:
|
||||
|
||||
1. Create an A or CNAME record pointing to your ingress IP/hostname
|
||||
2. Enable Cloudflare proxy (orange cloud) for DDoS protection
|
||||
3. Configure SSL/TLS settings
|
||||
|
||||
## 🔄 Continuous Deployment
|
||||
|
||||
Flux will automatically:
|
||||
- Monitor your Git repository every 1 minute
|
||||
- Apply changes to the cluster
|
||||
- Check deployment health
|
||||
- Rollback on failures
|
||||
|
||||
To update the app:
|
||||
|
||||
```bash
|
||||
# Make changes
|
||||
git add .
|
||||
git commit -m "Update recipe"
|
||||
git push
|
||||
|
||||
# Flux will sync automatically, or force sync:
|
||||
flux reconcile kustomization crepes-demo
|
||||
```
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Adding New Recipes
|
||||
|
||||
1. Define your recipe spec in `lib/recipes.ts`:
|
||||
```typescript
|
||||
{
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "RecipeCard",
|
||||
props: { title: "My Recipe", /* ... */ },
|
||||
children: ["chef", "header", /* ... */]
|
||||
},
|
||||
// ... more elements
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. The spec will be automatically rendered using the existing component catalog!
|
||||
|
||||
### Adding New Components
|
||||
|
||||
1. Update component schema in `lib/catalog.ts`:
|
||||
```typescript
|
||||
MyNewComponent: {
|
||||
props: z.object({
|
||||
myProp: z.string(),
|
||||
}),
|
||||
description: "My new component"
|
||||
}
|
||||
```
|
||||
|
||||
2. Add React implementation in `lib/registry.tsx`:
|
||||
```typescript
|
||||
MyNewComponent: ({ props }) => (
|
||||
<div>{props.myProp}</div>
|
||||
)
|
||||
```
|
||||
|
||||
3. Use in recipe specs with `type: "MyNewComponent"`
|
||||
|
||||
### Styling
|
||||
|
||||
The app uses Tailwind CSS. Modify component styles in `lib/registry.tsx` or adjust the theme in `tailwind.config.ts`.
|
||||
|
||||
## 📚 Project Structure
|
||||
|
||||
```
|
||||
crepes-demo/
|
||||
├── app/
|
||||
│ ├── page.tsx # Main page with Renderer
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── globals.css # Global styles
|
||||
├── lib/
|
||||
│ ├── catalog.ts # json-render component catalog
|
||||
│ ├── registry.tsx # React component implementations + Provider
|
||||
│ └── recipes.ts # Recipe JSON specs
|
||||
├── k8s/
|
||||
│ ├── deployment.yaml # Kubernetes Deployment
|
||||
│ ├── service.yaml # Kubernetes Service
|
||||
│ ├── ingress.yaml # Kubernetes Ingress (Cloudflare)
|
||||
│ └── kustomization.yaml # Kustomize config
|
||||
├── flux/
|
||||
│ ├── gitrepository.yaml # Flux GitRepository
|
||||
│ └── kustomization.yaml # Flux Kustomization
|
||||
├── .github/workflows/
|
||||
│ └── deploy.yml # GitHub Actions CI/CD
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── deploy.sh # Automated deployment script
|
||||
├── README.md # Full documentation (this file)
|
||||
└── QUICKSTART.md # Quick deployment guide
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Pods not starting
|
||||
|
||||
```bash
|
||||
kubectl describe pod -l app=crepes-demo
|
||||
kubectl logs -l app=crepes-demo
|
||||
```
|
||||
|
||||
### Flux not syncing
|
||||
|
||||
```bash
|
||||
flux logs
|
||||
flux get sources git
|
||||
flux get kustomizations
|
||||
```
|
||||
|
||||
### Certificate issues
|
||||
|
||||
```bash
|
||||
kubectl describe certificate crepes-demo-tls
|
||||
kubectl describe certificaterequest
|
||||
kubectl logs -n cert-manager -l app=cert-manager
|
||||
```
|
||||
|
||||
### DNS not resolving
|
||||
|
||||
```bash
|
||||
# Check external-dns logs
|
||||
kubectl logs -n external-dns -l app=external-dns
|
||||
|
||||
# Verify ingress has external IP
|
||||
kubectl get ingress crepes-demo
|
||||
```
|
||||
|
||||
## 🌐 Architecture
|
||||
|
||||
```
|
||||
User Request
|
||||
↓
|
||||
Cloudflare (CDN, DDoS protection, SSL)
|
||||
↓
|
||||
Ingress Controller (nginx)
|
||||
↓
|
||||
Kubernetes Service
|
||||
↓
|
||||
Deployment (2 replicas)
|
||||
↓
|
||||
Next.js App (json-render Renderer)
|
||||
```
|
||||
|
||||
## 🎓 Learning json-render
|
||||
|
||||
This demo shows the core concepts:
|
||||
|
||||
1. **Catalog Definition**: Define allowed components with typed props (Zod schemas)
|
||||
2. **Registry**: Map component names to React implementations
|
||||
3. **Spec Format**: JSON tree structure with `root` + `elements`
|
||||
4. **Renderer**: Takes spec + registry, outputs React components
|
||||
5. **Provider**: Wraps Renderer with required contexts (Action, Data, Visibility)
|
||||
|
||||
**In production with AI**:
|
||||
- User types: "Show me a recipe for chocolate chip cookies"
|
||||
- AI generates a JSON spec using your catalog components
|
||||
- json-render's `Renderer` turns it into React instantly
|
||||
- **Safe**: AI can't inject arbitrary code, only use your components
|
||||
|
||||
## 📖 Additional Resources
|
||||
|
||||
- [json-render GitHub](https://github.com/vercel-labs/json-render) - Official repository
|
||||
- [Flux documentation](https://fluxcd.io/docs/) - GitOps toolkit
|
||||
- [Next.js deployment](https://nextjs.org/docs/deployment) - Deployment guides
|
||||
- [Cloudflare Pages/Workers](https://developers.cloudflare.com/) - Edge deployment
|
||||
|
||||
## 🍴 Recipe Credits
|
||||
|
||||
Recipes inspired by the legendary techniques of:
|
||||
- **Auguste Escoffier** - Father of modern French cuisine (Crêpes Suzette)
|
||||
- **Olivier Roellinger** - Three-Michelin-starred Breton chef (Buckwheat Galettes)
|
||||
- **Pierre Hermé** - "Picasso of Pastry" (Salted Caramel Crêpes)
|
||||
- **Joël Robuchon** - Most Michelin-starred chef in history (Crêpes Parmentier)
|
||||
|
||||
## 📜 License
|
||||
|
||||
MIT License - Feel free to use this demo for your own projects!
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ using json-render. Bon Appétit! 🇫🇷**
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
app/globals.css
Normal file
26
app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
21
app/layout.tsx
Normal file
21
app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Les Crêpes de Maîtres | Famous French Chef Recipes",
|
||||
description: "Discover authentic crêpe recipes from legendary French chefs. Powered by json-render.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
117
app/page.tsx
Normal file
117
app/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Renderer } from "@json-render/react";
|
||||
import { registry, Provider } from "@/lib/registry";
|
||||
import { recipes } from "@/lib/recipes";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function Home() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-orange-100 via-amber-50 to-yellow-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🥞</div>
|
||||
<p className="text-gray-600 text-lg">Loading recipes...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-orange-100 via-amber-50 to-yellow-100">
|
||||
<header className="bg-white shadow-lg sticky top-0 z-50 border-b-4 border-orange-400">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<h1 className="text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-600 text-center">
|
||||
🥞 Les Crêpes de Maîtres
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 mt-2 text-lg">
|
||||
Famous French Chefs' Crêpe Recipes
|
||||
</p>
|
||||
<p className="text-center text-gray-500 mt-1 text-sm">
|
||||
Powered by <span className="font-mono font-semibold">json-render</span> ✨
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-12 max-w-6xl">
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-6 rounded-lg shadow-md mb-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
ℹ️ About This Demo
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-2">
|
||||
This application demonstrates <strong>json-render</strong> by Vercel Labs —
|
||||
a library that enables AI-generated UIs with guardrailed components.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-2">
|
||||
Below, each recipe is defined as a <strong>JSON specification</strong> and
|
||||
rendered through the <code className="bg-gray-200 px-2 py-1 rounded">Renderer</code> component
|
||||
using our type-safe component catalog.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4">
|
||||
In production, AI would generate these specs from user prompts—safely constrained
|
||||
to only use components we've defined (RecipeHeader, ChefInfo, IngredientList, etc.).
|
||||
</p>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm mb-4">
|
||||
<h3 className="font-bold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<span className="text-green-600">✓</span> Guardrailed
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
AI can only use components in your catalog
|
||||
</p>
|
||||
<h3 className="font-bold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<span className="text-green-600">✓</span> Predictable
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
JSON output matches your schema, every time
|
||||
</p>
|
||||
<h3 className="font-bold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<span className="text-green-600">✓</span> Fast
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Stream and render progressively as the model responds
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-blue-200">
|
||||
<p className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span>🔗</span>
|
||||
<a
|
||||
href="https://github.com/vercel-labs/json-render"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-700 font-semibold"
|
||||
>
|
||||
github.com/vercel-labs/json-render
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipes.map((recipe, index) => (
|
||||
<div key={index} className="mb-8">
|
||||
<Provider>
|
||||
<Renderer spec={recipe as any} registry={registry} />
|
||||
</Provider>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
|
||||
<footer className="bg-gray-900 text-white py-8 mt-20">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<p className="text-lg font-semibold mb-2">🇫🇷 Bon Appétit!</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Built with Next.js, Tailwind CSS, and json-render
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-4">
|
||||
Recipe sources linked in each chef's information card
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
deploy.sh
Executable file
72
deploy.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}🥞 Crêpes Demo Deployment Script${NC}\n"
|
||||
|
||||
# Check if registry is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "${RED}Error: Please provide your container registry${NC}"
|
||||
echo -e "Usage: ./deploy.sh YOUR_REGISTRY [domain]"
|
||||
echo -e "Example: ./deploy.sh ghcr.io/username crepes.example.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGISTRY=$1
|
||||
DOMAIN=${2:-crepes.yourdomain.com}
|
||||
IMAGE_NAME="crepes-demo"
|
||||
TAG="latest"
|
||||
|
||||
echo -e "${YELLOW}Configuration:${NC}"
|
||||
echo -e " Registry: ${BLUE}${REGISTRY}${NC}"
|
||||
echo -e " Image: ${BLUE}${REGISTRY}/${IMAGE_NAME}:${TAG}${NC}"
|
||||
echo -e " Domain: ${BLUE}${DOMAIN}${NC}\n"
|
||||
|
||||
# Step 1: Build Docker image
|
||||
echo -e "${GREEN}Step 1: Building Docker image...${NC}"
|
||||
docker build -t ${IMAGE_NAME}:${TAG} .
|
||||
|
||||
# Step 2: Tag image
|
||||
echo -e "${GREEN}Step 2: Tagging image...${NC}"
|
||||
docker tag ${IMAGE_NAME}:${TAG} ${REGISTRY}/${IMAGE_NAME}:${TAG}
|
||||
|
||||
# Step 3: Push to registry
|
||||
echo -e "${GREEN}Step 3: Pushing to registry...${NC}"
|
||||
docker push ${REGISTRY}/${IMAGE_NAME}:${TAG}
|
||||
|
||||
# Step 4: Update manifests
|
||||
echo -e "${GREEN}Step 4: Updating Kubernetes manifests...${NC}"
|
||||
|
||||
# Update deployment.yaml
|
||||
sed -i.bak "s|image: .*crepes-demo.*|image: ${REGISTRY}/${IMAGE_NAME}:${TAG}|g" k8s/deployment.yaml
|
||||
|
||||
# Update ingress.yaml
|
||||
sed -i.bak "s|crepes\.yourdomain\.com|${DOMAIN}|g" k8s/ingress.yaml
|
||||
|
||||
# Clean up backup files
|
||||
rm -f k8s/*.bak
|
||||
|
||||
echo -e "${GREEN}Manifests updated!${NC}\n"
|
||||
|
||||
# Step 5: Prompt for Git operations
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo -e "1. Review the updated manifests in k8s/"
|
||||
echo -e "2. Commit and push to your Git repository:"
|
||||
echo -e " ${BLUE}git add .${NC}"
|
||||
echo -e " ${BLUE}git commit -m 'Update deployment configuration'${NC}"
|
||||
echo -e " ${BLUE}git push${NC}"
|
||||
echo -e "3. Apply Flux resources to your cluster:"
|
||||
echo -e " ${BLUE}kubectl apply -f flux/gitrepository.yaml${NC}"
|
||||
echo -e " ${BLUE}kubectl apply -f flux/kustomization.yaml${NC}"
|
||||
echo -e "4. Watch the deployment:"
|
||||
echo -e " ${BLUE}flux get kustomizations -w${NC}\n"
|
||||
|
||||
echo -e "${GREEN}✅ Build and push complete!${NC}"
|
||||
echo -e "${BLUE}Your app will be available at: https://${DOMAIN}${NC}"
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
47
k8s/deployment.yaml
Normal file
47
k8s/deployment.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: crepes-demo
|
||||
namespace: crepes-demo
|
||||
labels:
|
||||
app: crepes-demo
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: crepes-demo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: crepes-demo
|
||||
spec:
|
||||
containers:
|
||||
- name: crepes-demo
|
||||
image: git.aymerick.fr/aymerick/crepes-demo:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
25
k8s/ingress.yaml
Normal file
25
k8s/ingress.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: crepes-demo
|
||||
namespace: crepes-demo
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: crepes.aymerick.fr
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: crepes-demo
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- crepes.aymerick.fr
|
||||
secretName: crepes-aymerick-fr-tls
|
||||
8
k8s/kustomization.yaml
Normal file
8
k8s/kustomization.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: crepes-demo
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
4
k8s/namespace.yaml
Normal file
4
k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: crepes-demo
|
||||
16
k8s/service.yaml
Normal file
16
k8s/service.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: crepes-demo
|
||||
namespace: crepes-demo
|
||||
labels:
|
||||
app: crepes-demo
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: crepes-demo
|
||||
68
lib/catalog.ts
Normal file
68
lib/catalog.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react";
|
||||
import { z } from "zod";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
RecipeHeader: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
chef: z.string(),
|
||||
origin: z.string(),
|
||||
prepTime: z.string(),
|
||||
servings: z.number(),
|
||||
}),
|
||||
description: "Header with recipe title, chef name, origin, and key info",
|
||||
},
|
||||
ChefInfo: {
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
bio: z.string(),
|
||||
sourceUrl: z.string(),
|
||||
imageUrl: z.string().optional(),
|
||||
}),
|
||||
description: "Chef information card with bio and source link",
|
||||
},
|
||||
IngredientList: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
ingredients: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
quantity: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
description: "List of ingredients with quantities",
|
||||
},
|
||||
InstructionSteps: {
|
||||
props: z.object({
|
||||
steps: z.array(
|
||||
z.object({
|
||||
number: z.number(),
|
||||
instruction: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
description: "Step-by-step cooking instructions",
|
||||
},
|
||||
TipCard: {
|
||||
props: z.object({
|
||||
tip: z.string(),
|
||||
author: z.string(),
|
||||
}),
|
||||
description: "Chef's tip or pro advice",
|
||||
},
|
||||
RecipeCard: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
imageUrl: z.string().optional(),
|
||||
}),
|
||||
description: "Card container for a recipe",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
export type Catalog = typeof catalog;
|
||||
172
lib/components.tsx
Normal file
172
lib/components.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React from "react";
|
||||
|
||||
export interface RecipeHeaderProps {
|
||||
title: string;
|
||||
chef: string;
|
||||
origin: string;
|
||||
prepTime: string;
|
||||
servings: number;
|
||||
}
|
||||
|
||||
export function RecipeHeader({ title, chef, origin, prepTime, servings }: RecipeHeaderProps) {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-orange-50 p-8 rounded-2xl shadow-lg mb-8 border-2 border-amber-200">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-3">{title}</h2>
|
||||
<div className="flex flex-wrap gap-4 text-lg">
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
👨🍳 <strong>Chef:</strong> {chef}
|
||||
</span>
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
🇫🇷 <strong>Origin:</strong> {origin}
|
||||
</span>
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
⏱️ <strong>Prep:</strong> {prepTime}
|
||||
</span>
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
🍽️ <strong>Serves:</strong> {servings}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChefInfoProps {
|
||||
name: string;
|
||||
bio: string;
|
||||
sourceUrl: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export function ChefInfo({ name, bio, sourceUrl, imageUrl }: ChefInfoProps) {
|
||||
return (
|
||||
<div className="bg-blue-50 p-6 rounded-xl shadow-md mb-8 border-l-4 border-blue-500">
|
||||
<div className="flex items-start gap-4">
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
className="w-24 h-24 rounded-full object-cover shadow-lg"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{name}</h3>
|
||||
<p className="text-gray-700 mb-3">{bio}</p>
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 font-semibold underline"
|
||||
>
|
||||
📖 View Original Recipe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface IngredientListProps {
|
||||
title: string;
|
||||
ingredients: Array<{ quantity: string; name: string }>;
|
||||
}
|
||||
|
||||
export function IngredientList({ title, ingredients }: IngredientListProps) {
|
||||
return (
|
||||
<div className="bg-green-50 p-6 rounded-xl shadow-md mb-6 border-l-4 border-green-500">
|
||||
<h4 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
🥚 {title}
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{ingredients.map((ingredient, index) => (
|
||||
<li key={index} className="flex items-baseline gap-3 text-lg">
|
||||
<span className="text-green-600 font-bold">•</span>
|
||||
<span className="font-semibold text-gray-900 min-w-[120px]">
|
||||
{ingredient.quantity}
|
||||
</span>
|
||||
<span className="text-gray-700">{ingredient.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface InstructionStepsProps {
|
||||
steps: Array<{ number: number; instruction: string }>;
|
||||
}
|
||||
|
||||
export function InstructionSteps({ steps }: InstructionStepsProps) {
|
||||
return (
|
||||
<div className="bg-purple-50 p-6 rounded-xl shadow-md mb-6 border-l-4 border-purple-500">
|
||||
<h4 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
📝 Instructions
|
||||
</h4>
|
||||
<ol className="space-y-4">
|
||||
{steps.map((step) => (
|
||||
<li key={step.number} className="flex gap-4">
|
||||
<span className="flex-shrink-0 w-8 h-8 bg-purple-500 text-white rounded-full flex items-center justify-center font-bold">
|
||||
{step.number}
|
||||
</span>
|
||||
<p className="text-gray-700 text-lg pt-1">{step.instruction}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TipCardProps {
|
||||
tip: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export function TipCard({ tip, author }: TipCardProps) {
|
||||
return (
|
||||
<div className="bg-yellow-50 p-6 rounded-xl shadow-md mb-6 border-l-4 border-yellow-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-3xl">💡</span>
|
||||
<div>
|
||||
<p className="text-gray-800 text-lg mb-2 italic">“{tip}”</p>
|
||||
<p className="text-gray-600 font-semibold">— {author}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
chef: ChefInfoProps;
|
||||
header: RecipeHeaderProps;
|
||||
ingredientLists: IngredientListProps[];
|
||||
steps: InstructionStepsProps;
|
||||
tip: TipCardProps;
|
||||
}
|
||||
|
||||
export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
return (
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl mb-8 border border-gray-200">
|
||||
{recipe.imageUrl && (
|
||||
<img
|
||||
src={recipe.imageUrl}
|
||||
alt={recipe.title}
|
||||
className="w-full h-64 object-cover rounded-xl mb-6 shadow-md"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-3">{recipe.title}</h2>
|
||||
<p className="text-gray-600 text-lg mb-6">{recipe.description}</p>
|
||||
|
||||
<ChefInfo {...recipe.chef} />
|
||||
<RecipeHeader {...recipe.header} />
|
||||
|
||||
{recipe.ingredientLists.map((list, index) => (
|
||||
<IngredientList key={index} {...list} />
|
||||
))}
|
||||
|
||||
<InstructionSteps {...recipe.steps} />
|
||||
<TipCard {...recipe.tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
lib/recipes-simple.ts
Normal file
274
lib/recipes-simple.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { Recipe } from "./components";
|
||||
|
||||
export const recipes: Recipe[] = [
|
||||
{
|
||||
title: "Crêpes Suzette",
|
||||
description: "The iconic flambéed orange crêpe dessert, a timeless French classic",
|
||||
imageUrl: "https://images.unsplash.com/photo-1576284805570-ca5c04e3aa2c?w=800",
|
||||
chef: {
|
||||
name: "Auguste Escoffier",
|
||||
bio: "The legendary French chef who codified French cuisine and created many classic dishes. His influence on modern French cooking is immeasurable.",
|
||||
sourceUrl: "https://www.escoffier.edu/blog/culinary-arts/the-legendary-auguste-escoffier/",
|
||||
},
|
||||
header: {
|
||||
title: "Crêpes Suzette",
|
||||
chef: "Auguste Escoffier",
|
||||
origin: "Monte Carlo, 1895",
|
||||
prepTime: "45 minutes",
|
||||
servings: 4,
|
||||
},
|
||||
ingredientLists: [
|
||||
{
|
||||
title: "For the Crêpes",
|
||||
ingredients: [
|
||||
{ quantity: "200g", name: "All-purpose flour" },
|
||||
{ quantity: "4", name: "Large eggs" },
|
||||
{ quantity: "500ml", name: "Whole milk" },
|
||||
{ quantity: "50g", name: "Melted butter" },
|
||||
{ quantity: "1 pinch", name: "Salt" },
|
||||
{ quantity: "2 tbsp", name: "Sugar" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "For the Suzette Sauce",
|
||||
ingredients: [
|
||||
{ quantity: "100g", name: "Unsalted butter" },
|
||||
{ quantity: "100g", name: "Sugar" },
|
||||
{ quantity: "Zest of 2", name: "Oranges" },
|
||||
{ quantity: "150ml", name: "Fresh orange juice" },
|
||||
{ quantity: "50ml", name: "Grand Marnier" },
|
||||
{ quantity: "50ml", name: "Cognac" },
|
||||
],
|
||||
},
|
||||
],
|
||||
steps: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Whisk together flour, eggs, milk, melted butter, salt, and sugar until smooth. Let the batter rest for 30 minutes.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "Heat a non-stick pan over medium heat. Pour a thin layer of batter and swirl to coat. Cook for 1-2 minutes per side until golden.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "For the sauce: In a large pan, melt butter with sugar until caramelized. Add orange zest and juice, simmer for 2 minutes.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Fold each crêpe into quarters and arrange in the orange sauce. Heat through gently.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Add Grand Marnier and Cognac, ignite carefully to flambé. Serve immediately with the sauce.",
|
||||
},
|
||||
],
|
||||
},
|
||||
tip: {
|
||||
tip: "The flambé should be done tableside for maximum theatrical effect. Ensure your pan is hot before adding the alcohol for proper ignition.",
|
||||
author: "Auguste Escoffier",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Crêpes Bretonnes au Sarrasin",
|
||||
description: "Traditional Breton buckwheat crêpes with ham, egg, and Gruyère",
|
||||
imageUrl: "https://images.unsplash.com/photo-1505253468034-514d2507d914?w=800",
|
||||
chef: {
|
||||
name: "Olivier Roellinger",
|
||||
bio: "Three-Michelin-starred chef from Brittany, known for his innovative use of spices and celebration of Breton culinary traditions.",
|
||||
sourceUrl: "https://www.maisons-de-bricourt.com/en/",
|
||||
},
|
||||
header: {
|
||||
title: "Crêpes Bretonnes au Sarrasin",
|
||||
chef: "Olivier Roellinger",
|
||||
origin: "Brittany, France",
|
||||
prepTime: "30 minutes",
|
||||
servings: 6,
|
||||
},
|
||||
ingredientLists: [
|
||||
{
|
||||
title: "Ingredients",
|
||||
ingredients: [
|
||||
{ quantity: "250g", name: "Buckwheat flour" },
|
||||
{ quantity: "1", name: "Large egg" },
|
||||
{ quantity: "500ml", name: "Water" },
|
||||
{ quantity: "1 tsp", name: "Salt" },
|
||||
{ quantity: "30g", name: "Melted butter" },
|
||||
{ quantity: "6 slices", name: "Jambon de Paris (quality ham)" },
|
||||
{ quantity: "6", name: "Fresh eggs" },
|
||||
{ quantity: "200g", name: "Grated Gruyère cheese" },
|
||||
],
|
||||
},
|
||||
],
|
||||
steps: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Mix buckwheat flour with salt. Gradually whisk in water and egg until smooth. Add melted butter. Let rest 1 hour.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "Heat a large crêpe pan or flat griddle. Lightly oil the surface.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "Pour batter to form a thin crêpe. Let it set for 30 seconds.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Place a slice of ham in the center, crack an egg on top, and sprinkle with Gruyère. Season with pepper.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Fold the edges inward to form a square, leaving the egg yolk visible. Cook until egg white is set and yolk is still runny.",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
instruction: "Serve immediately with a glass of Breton cider.",
|
||||
},
|
||||
],
|
||||
},
|
||||
tip: {
|
||||
tip: "The buckwheat flour gives these galettes their distinctive nutty flavor. Use genuine buckwheat from Brittany if possible, and never skip the resting time for the batter.",
|
||||
author: "Olivier Roellinger",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Crêpes with Salted Caramel",
|
||||
description: "Modern French crêpes with homemade salted butter caramel sauce",
|
||||
imageUrl: "https://images.unsplash.com/photo-1519676867240-f03562e64548?w=800",
|
||||
chef: {
|
||||
name: "Pierre Hermé",
|
||||
bio: "Renowned pastry chef dubbed the 'Picasso of Pastry' by Vogue. Master of combining unexpected flavors and textures in French desserts.",
|
||||
sourceUrl: "https://www.pierreherme.com/",
|
||||
},
|
||||
header: {
|
||||
title: "Crêpes au Caramel Beurre Salé",
|
||||
chef: "Pierre Hermé",
|
||||
origin: "Paris, France",
|
||||
prepTime: "40 minutes",
|
||||
servings: 8,
|
||||
},
|
||||
ingredientLists: [
|
||||
{
|
||||
title: "For the Crêpes",
|
||||
ingredients: [
|
||||
{ quantity: "300g", name: "All-purpose flour" },
|
||||
{ quantity: "3", name: "Large eggs" },
|
||||
{ quantity: "600ml", name: "Whole milk" },
|
||||
{ quantity: "40g", name: "Melted butter" },
|
||||
{ quantity: "30g", name: "Sugar" },
|
||||
{ quantity: "1 pinch", name: "Fleur de sel" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "For the Salted Caramel",
|
||||
ingredients: [
|
||||
{ quantity: "200g", name: "Sugar" },
|
||||
{ quantity: "100g", name: "Salted butter (Breton preferred)" },
|
||||
{ quantity: "200ml", name: "Heavy cream" },
|
||||
{ quantity: "1 tsp", name: "Fleur de sel" },
|
||||
],
|
||||
},
|
||||
],
|
||||
steps: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Prepare crêpe batter: Whisk flour, eggs, milk, melted butter, sugar, and fleur de sel until smooth. Rest for 30 minutes.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "For the caramel: In a heavy saucepan, melt sugar over medium heat without stirring until it becomes deep amber.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "Remove from heat and carefully whisk in butter, then cream (it will bubble vigorously). Return to low heat and stir until smooth.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Add fleur de sel to taste. Let caramel cool slightly.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Cook crêpes in a hot pan until lightly golden on both sides.",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
instruction: "Fold crêpes into quarters, drizzle generously with warm salted caramel. Garnish with extra fleur de sel.",
|
||||
},
|
||||
],
|
||||
},
|
||||
tip: {
|
||||
tip: "The key to perfect caramel is patience. Don't rush the caramelization, and use quality salted butter from Brittany. The contrast between sweet and salty creates an unforgettable experience.",
|
||||
author: "Pierre Hermé",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Crêpes Parmentier",
|
||||
description: "Savory potato and bacon crêpes, rustic and comforting",
|
||||
imageUrl: "https://images.unsplash.com/photo-1525184782196-8e2ded604bf7?w=800",
|
||||
chef: {
|
||||
name: "Joël Robuchon",
|
||||
bio: "Holder of the most Michelin stars in the world (31 total). Known for elevating simple ingredients into extraordinary dishes through perfect technique.",
|
||||
sourceUrl: "https://www.joel-robuchon.com/",
|
||||
},
|
||||
header: {
|
||||
title: "Crêpes Parmentier",
|
||||
chef: "Joël Robuchon",
|
||||
origin: "France (modern bistro)",
|
||||
prepTime: "50 minutes",
|
||||
servings: 4,
|
||||
},
|
||||
ingredientLists: [
|
||||
{
|
||||
title: "Ingredients",
|
||||
ingredients: [
|
||||
{ quantity: "200g", name: "All-purpose flour" },
|
||||
{ quantity: "3", name: "Large eggs" },
|
||||
{ quantity: "400ml", name: "Whole milk" },
|
||||
{ quantity: "50g", name: "Melted butter" },
|
||||
{ quantity: "400g", name: "Yukon Gold potatoes" },
|
||||
{ quantity: "200g", name: "Quality bacon lardons" },
|
||||
{ quantity: "100g", name: "Comté cheese, grated" },
|
||||
{ quantity: "2", name: "Shallots, finely chopped" },
|
||||
{ quantity: "100ml", name: "Crème fraîche" },
|
||||
{ quantity: "To taste", name: "Salt, pepper, nutmeg" },
|
||||
],
|
||||
},
|
||||
],
|
||||
steps: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Make crêpe batter with flour, eggs, milk, and melted butter. Season lightly and rest for 30 minutes.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "Peel and dice potatoes into small cubes. Boil in salted water until just tender, about 8 minutes. Drain thoroughly.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "In a large pan, cook bacon lardons until crispy. Add shallots and cook until soft.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Add potatoes to the pan, season with salt, pepper, and a grating of nutmeg. Stir in crème fraîche.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Cook crêpes until golden. Fill each with the potato-bacon mixture and fold or roll.",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
instruction: "Place filled crêpes in a buttered baking dish, top with Comté cheese. Bake at 200°C for 10 minutes until bubbly and golden.",
|
||||
},
|
||||
],
|
||||
},
|
||||
tip: {
|
||||
tip: "The potatoes should be cooked al dente, not mushy. This is the Robuchon way—respect the texture of every ingredient. A quality Comté adds depth that's essential to the dish.",
|
||||
author: "Joël Robuchon",
|
||||
},
|
||||
},
|
||||
];
|
||||
392
lib/recipes.ts
Normal file
392
lib/recipes.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
export const recipes = [
|
||||
{
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "RecipeCard" as const,
|
||||
props: {
|
||||
title: "Crêpes Suzette",
|
||||
description: "The iconic flambéed orange crêpe dessert, a timeless French classic",
|
||||
imageUrl: "https://images.unsplash.com/photo-1576284805570-ca5c04e3aa2c?w=800",
|
||||
},
|
||||
children: [
|
||||
"chef",
|
||||
"header",
|
||||
"ingredients1",
|
||||
"ingredients2",
|
||||
"steps",
|
||||
"tip",
|
||||
],
|
||||
},
|
||||
chef: {
|
||||
type: "ChefInfo" as const,
|
||||
props: {
|
||||
name: "Auguste Escoffier",
|
||||
bio: "The legendary French chef who codified French cuisine and created many classic dishes. His influence on modern French cooking is immeasurable.",
|
||||
sourceUrl: "https://www.escoffier.edu/blog/culinary-arts/the-legendary-auguste-escoffier/",
|
||||
},
|
||||
},
|
||||
header: {
|
||||
type: "RecipeHeader" as const,
|
||||
props: {
|
||||
title: "Crêpes Suzette",
|
||||
chef: "Auguste Escoffier",
|
||||
origin: "Monte Carlo, 1895",
|
||||
prepTime: "45 minutes",
|
||||
servings: 4,
|
||||
},
|
||||
},
|
||||
ingredients1: {
|
||||
type: "IngredientList" as const,
|
||||
props: {
|
||||
title: "For the Crêpes",
|
||||
ingredients: [
|
||||
{ quantity: "200g", name: "All-purpose flour" },
|
||||
{ quantity: "4", name: "Large eggs" },
|
||||
{ quantity: "500ml", name: "Whole milk" },
|
||||
{ quantity: "50g", name: "Melted butter" },
|
||||
{ quantity: "1 pinch", name: "Salt" },
|
||||
{ quantity: "2 tbsp", name: "Sugar" },
|
||||
],
|
||||
},
|
||||
},
|
||||
ingredients2: {
|
||||
type: "IngredientList" as const,
|
||||
props: {
|
||||
title: "For the Suzette Sauce",
|
||||
ingredients: [
|
||||
{ quantity: "100g", name: "Unsalted butter" },
|
||||
{ quantity: "100g", name: "Sugar" },
|
||||
{ quantity: "Zest of 2", name: "Oranges" },
|
||||
{ quantity: "150ml", name: "Fresh orange juice" },
|
||||
{ quantity: "50ml", name: "Grand Marnier" },
|
||||
{ quantity: "50ml", name: "Cognac" },
|
||||
],
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
type: "InstructionSteps" as const,
|
||||
props: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Whisk together flour, eggs, milk, melted butter, salt, and sugar until smooth. Let the batter rest for 30 minutes.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "Heat a non-stick pan over medium heat. Pour a thin layer of batter and swirl to coat. Cook for 1-2 minutes per side until golden.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "For the sauce: In a large pan, melt butter with sugar until caramelized. Add orange zest and juice, simmer for 2 minutes.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Fold each crêpe into quarters and arrange in the orange sauce. Heat through gently.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Add Grand Marnier and Cognac, ignite carefully to flambé. Serve immediately with the sauce.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
tip: {
|
||||
type: "TipCard" as const,
|
||||
props: {
|
||||
tip: "The flambé should be done tableside for maximum theatrical effect. Ensure your pan is hot before adding the alcohol for proper ignition.",
|
||||
author: "Auguste Escoffier",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "RecipeCard" as const,
|
||||
props: {
|
||||
title: "Crêpes Bretonnes au Sarrasin",
|
||||
description: "Traditional Breton buckwheat crêpes with ham, egg, and Gruyère",
|
||||
imageUrl: "https://images.unsplash.com/photo-1505253468034-514d2507d914?w=800",
|
||||
},
|
||||
children: [
|
||||
"chef",
|
||||
"header",
|
||||
"ingredients",
|
||||
"steps",
|
||||
"tip",
|
||||
],
|
||||
},
|
||||
chef: {
|
||||
type: "ChefInfo" as const,
|
||||
props: {
|
||||
name: "Olivier Roellinger",
|
||||
bio: "Three-Michelin-starred chef from Brittany, known for his innovative use of spices and celebration of Breton culinary traditions.",
|
||||
sourceUrl: "https://www.maisons-de-bricourt.com/en/",
|
||||
},
|
||||
},
|
||||
header: {
|
||||
type: "RecipeHeader" as const,
|
||||
props: {
|
||||
title: "Crêpes Bretonnes au Sarrasin",
|
||||
chef: "Olivier Roellinger",
|
||||
origin: "Brittany, France",
|
||||
prepTime: "30 minutes",
|
||||
servings: 6,
|
||||
},
|
||||
},
|
||||
ingredients: {
|
||||
type: "IngredientList" as const,
|
||||
props: {
|
||||
title: "Ingredients",
|
||||
ingredients: [
|
||||
{ quantity: "250g", name: "Buckwheat flour" },
|
||||
{ quantity: "1", name: "Large egg" },
|
||||
{ quantity: "500ml", name: "Water" },
|
||||
{ quantity: "1 tsp", name: "Salt" },
|
||||
{ quantity: "30g", name: "Melted butter" },
|
||||
{ quantity: "6 slices", name: "Jambon de Paris (quality ham)" },
|
||||
{ quantity: "6", name: "Fresh eggs" },
|
||||
{ quantity: "200g", name: "Grated Gruyère cheese" },
|
||||
],
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
type: "InstructionSteps" as const,
|
||||
props: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Mix buckwheat flour with salt. Gradually whisk in water and egg until smooth. Add melted butter. Let rest 1 hour.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "Heat a large crêpe pan or flat griddle. Lightly oil the surface.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "Pour batter to form a thin crêpe. Let it set for 30 seconds.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Place a slice of ham in the center, crack an egg on top, and sprinkle with Gruyère. Season with pepper.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Fold the edges inward to form a square, leaving the egg yolk visible. Cook until egg white is set and yolk is still runny.",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
instruction: "Serve immediately with a glass of Breton cider.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
tip: {
|
||||
type: "TipCard" as const,
|
||||
props: {
|
||||
tip: "The buckwheat flour gives these galettes their distinctive nutty flavor. Use genuine buckwheat from Brittany if possible, and never skip the resting time for the batter.",
|
||||
author: "Olivier Roellinger",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "RecipeCard" as const,
|
||||
props: {
|
||||
title: "Crêpes with Salted Caramel",
|
||||
description: "Modern French crêpes with homemade salted butter caramel sauce",
|
||||
imageUrl: "https://images.unsplash.com/photo-1519676867240-f03562e64548?w=800",
|
||||
},
|
||||
children: [
|
||||
"chef",
|
||||
"header",
|
||||
"ingredients1",
|
||||
"ingredients2",
|
||||
"steps",
|
||||
"tip",
|
||||
],
|
||||
},
|
||||
chef: {
|
||||
type: "ChefInfo" as const,
|
||||
props: {
|
||||
name: "Pierre Hermé",
|
||||
bio: "Renowned pastry chef dubbed the 'Picasso of Pastry' by Vogue. Master of combining unexpected flavors and textures in French desserts.",
|
||||
sourceUrl: "https://www.pierreherme.com/",
|
||||
},
|
||||
},
|
||||
header: {
|
||||
type: "RecipeHeader" as const,
|
||||
props: {
|
||||
title: "Crêpes au Caramel Beurre Salé",
|
||||
chef: "Pierre Hermé",
|
||||
origin: "Paris, France",
|
||||
prepTime: "40 minutes",
|
||||
servings: 8,
|
||||
},
|
||||
},
|
||||
ingredients1: {
|
||||
type: "IngredientList" as const,
|
||||
props: {
|
||||
title: "For the Crêpes",
|
||||
ingredients: [
|
||||
{ quantity: "300g", name: "All-purpose flour" },
|
||||
{ quantity: "3", name: "Large eggs" },
|
||||
{ quantity: "600ml", name: "Whole milk" },
|
||||
{ quantity: "40g", name: "Melted butter" },
|
||||
{ quantity: "30g", name: "Sugar" },
|
||||
{ quantity: "1 pinch", name: "Fleur de sel" },
|
||||
],
|
||||
},
|
||||
},
|
||||
ingredients2: {
|
||||
type: "IngredientList" as const,
|
||||
props: {
|
||||
title: "For the Salted Caramel",
|
||||
ingredients: [
|
||||
{ quantity: "200g", name: "Sugar" },
|
||||
{ quantity: "100g", name: "Salted butter (Breton preferred)" },
|
||||
{ quantity: "200ml", name: "Heavy cream" },
|
||||
{ quantity: "1 tsp", name: "Fleur de sel" },
|
||||
],
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
type: "InstructionSteps" as const,
|
||||
props: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Prepare crêpe batter: Whisk flour, eggs, milk, melted butter, sugar, and fleur de sel until smooth. Rest for 30 minutes.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "For the caramel: In a heavy saucepan, melt sugar over medium heat without stirring until it becomes deep amber.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "Remove from heat and carefully whisk in butter, then cream (it will bubble vigorously). Return to low heat and stir until smooth.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Add fleur de sel to taste. Let caramel cool slightly.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Cook crêpes in a hot pan until lightly golden on both sides.",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
instruction: "Fold crêpes into quarters, drizzle generously with warm salted caramel. Garnish with extra fleur de sel.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
tip: {
|
||||
type: "TipCard" as const,
|
||||
props: {
|
||||
tip: "The key to perfect caramel is patience. Don't rush the caramelization, and use quality salted butter from Brittany. The contrast between sweet and salty creates an unforgettable experience.",
|
||||
author: "Pierre Hermé",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "RecipeCard" as const,
|
||||
props: {
|
||||
title: "Crêpes Parmentier",
|
||||
description: "Savory potato and bacon crêpes, rustic and comforting",
|
||||
imageUrl: "https://images.unsplash.com/photo-1525184782196-8e2ded604bf7?w=800",
|
||||
},
|
||||
children: [
|
||||
"chef",
|
||||
"header",
|
||||
"ingredients",
|
||||
"steps",
|
||||
"tip",
|
||||
],
|
||||
},
|
||||
chef: {
|
||||
type: "ChefInfo" as const,
|
||||
props: {
|
||||
name: "Joël Robuchon",
|
||||
bio: "Holder of the most Michelin stars in the world (31 total). Known for elevating simple ingredients into extraordinary dishes through perfect technique.",
|
||||
sourceUrl: "https://www.joel-robuchon.com/",
|
||||
},
|
||||
},
|
||||
header: {
|
||||
type: "RecipeHeader" as const,
|
||||
props: {
|
||||
title: "Crêpes Parmentier",
|
||||
chef: "Joël Robuchon",
|
||||
origin: "France (modern bistro)",
|
||||
prepTime: "50 minutes",
|
||||
servings: 4,
|
||||
},
|
||||
},
|
||||
ingredients: {
|
||||
type: "IngredientList" as const,
|
||||
props: {
|
||||
title: "Ingredients",
|
||||
ingredients: [
|
||||
{ quantity: "200g", name: "All-purpose flour" },
|
||||
{ quantity: "3", name: "Large eggs" },
|
||||
{ quantity: "400ml", name: "Whole milk" },
|
||||
{ quantity: "50g", name: "Melted butter" },
|
||||
{ quantity: "400g", name: "Yukon Gold potatoes" },
|
||||
{ quantity: "200g", name: "Quality bacon lardons" },
|
||||
{ quantity: "100g", name: "Comté cheese, grated" },
|
||||
{ quantity: "2", name: "Shallots, finely chopped" },
|
||||
{ quantity: "100ml", name: "Crème fraîche" },
|
||||
{ quantity: "To taste", name: "Salt, pepper, nutmeg" },
|
||||
],
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
type: "InstructionSteps" as const,
|
||||
props: {
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
instruction: "Make crêpe batter with flour, eggs, milk, and melted butter. Season lightly and rest for 30 minutes.",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
instruction: "Peel and dice potatoes into small cubes. Boil in salted water until just tender, about 8 minutes. Drain thoroughly.",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
instruction: "In a large pan, cook bacon lardons until crispy. Add shallots and cook until soft.",
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
instruction: "Add potatoes to the pan, season with salt, pepper, and a grating of nutmeg. Stir in crème fraîche.",
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
instruction: "Cook crêpes until golden. Fill each with the potato-bacon mixture and fold or roll.",
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
instruction: "Place filled crêpes in a buttered baking dish, top with Comté cheese. Bake at 200°C for 10 minutes until bubbly and golden.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
tip: {
|
||||
type: "TipCard" as const,
|
||||
props: {
|
||||
tip: "The potatoes should be cooked al dente, not mushy. This is the Robuchon way—respect the texture of every ingredient. A quality Comté adds depth that's essential to the dish.",
|
||||
author: "Joël Robuchon",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
127
lib/registry.tsx
Normal file
127
lib/registry.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { defineRegistry, DataProvider, VisibilityProvider, ActionProvider } from "@json-render/react";
|
||||
import { catalog } from "./catalog";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
RecipeHeader: ({ props }) => (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-orange-50 p-8 rounded-2xl shadow-lg mb-8 border-2 border-amber-200">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-3">{props.title}</h1>
|
||||
<div className="flex flex-wrap gap-4 text-lg">
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
👨🍳 <strong>Chef:</strong> {props.chef}
|
||||
</span>
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
🇫🇷 <strong>Origin:</strong> {props.origin}
|
||||
</span>
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
⏱️ <strong>Prep:</strong> {props.prepTime}
|
||||
</span>
|
||||
<span className="bg-white px-4 py-2 rounded-full shadow-sm">
|
||||
🍽️ <strong>Serves:</strong> {props.servings}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
ChefInfo: ({ props }) => (
|
||||
<div className="bg-blue-50 p-6 rounded-xl shadow-md mb-8 border-l-4 border-blue-500">
|
||||
<div className="flex items-start gap-4">
|
||||
{props.imageUrl && (
|
||||
<img
|
||||
src={props.imageUrl}
|
||||
alt={props.name}
|
||||
className="w-24 h-24 rounded-full object-cover shadow-lg"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{props.name}</h2>
|
||||
<p className="text-gray-700 mb-3">{props.bio}</p>
|
||||
<a
|
||||
href={props.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 font-semibold underline"
|
||||
>
|
||||
📖 View Original Recipe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
IngredientList: ({ props }) => (
|
||||
<div className="bg-green-50 p-6 rounded-xl shadow-md mb-6 border-l-4 border-green-500">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
🥚 {props.title}
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{props.ingredients.map((ingredient: any, index: number) => (
|
||||
<li key={index} className="flex items-baseline gap-3 text-lg">
|
||||
<span className="text-green-600 font-bold">•</span>
|
||||
<span className="font-semibold text-gray-900 min-w-[120px]">
|
||||
{ingredient.quantity}
|
||||
</span>
|
||||
<span className="text-gray-700">{ingredient.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
InstructionSteps: ({ props }) => (
|
||||
<div className="bg-purple-50 p-6 rounded-xl shadow-md mb-6 border-l-4 border-purple-500">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
📝 Instructions
|
||||
</h3>
|
||||
<ol className="space-y-4">
|
||||
{props.steps.map((step: any) => (
|
||||
<li key={step.number} className="flex gap-4">
|
||||
<span className="flex-shrink-0 w-8 h-8 bg-purple-500 text-white rounded-full flex items-center justify-center font-bold">
|
||||
{step.number}
|
||||
</span>
|
||||
<p className="text-gray-700 text-lg pt-1">{step.instruction}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
),
|
||||
TipCard: ({ props }) => (
|
||||
<div className="bg-yellow-50 p-6 rounded-xl shadow-md mb-6 border-l-4 border-yellow-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-3xl">💡</span>
|
||||
<div>
|
||||
<p className="text-gray-800 text-lg mb-2 italic">“{props.tip}”</p>
|
||||
<p className="text-gray-600 font-semibold">— {props.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
RecipeCard: ({ props, children }) => (
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl mb-8 border border-gray-200">
|
||||
{props.imageUrl && (
|
||||
<img
|
||||
src={props.imageUrl}
|
||||
alt={props.title}
|
||||
className="w-full h-64 object-cover rounded-xl mb-6 shadow-md"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-3">{props.title}</h2>
|
||||
<p className="text-gray-600 text-lg mb-6">{props.description}</p>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Provider wrapper for all required contexts
|
||||
export function Provider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ActionProvider>
|
||||
<DataProvider>
|
||||
<VisibilityProvider>
|
||||
{children}
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
</ActionProvider>
|
||||
);
|
||||
}
|
||||
19
next.config.ts
Normal file
19
next.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Disable static optimization for client-side json-render
|
||||
experimental: {
|
||||
optimizePackageImports: ["@json-render/react"],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6564
package-lock.json
generated
Normal file
6564
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "crepes-demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "^0.4.4",
|
||||
"@json-render/react": "^0.4.4",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user