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