Compare commits

..

No commits in common. "bd451c6cbb1b38af4488efc328212ec2cecf1dce" and "081145f900ff5dd41e9b951d3689c986e4d2e833" have entirely different histories.

25 changed files with 275 additions and 1022 deletions

View file

@ -1,39 +0,0 @@
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:22-bullseye
steps:
- name: Install Docker
run: |
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
- name: checkout
uses: actions/checkout@v4
- name: Login to forgejo
uses: docker/login-action@v3
with:
registry: forgejo.fredzernia.com
username: ${{ github.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: build and push backend
uses: docker/build-push-action@v6
with:
context: backend/.
push: true
tags: forgejo.fredzernia.com/${{ env.FORGEJO_REPOSITORY }}_backend:latest
- name: build and push frontend
uses: docker/build-push-action@v6
with:
context: frontend/.
push: true
tags: forgejo.fredzernia.com/${{ env.FORGEJO_REPOSITORY }}_frontend:latest

26
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,26 @@
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
env:
NODE_ENV: dev
container:
image: node:22-bullseye
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Frontend
working-directory: frontend
run: |
npm ci
npm run ci
- name: Build Backend
working-directory: backend
run: |
npm ci
npm run ci

View file

@ -19,27 +19,4 @@
- Tailwind CSS - Tailwind CSS
#### Infra/CI: I have a production build of the app hosted in docker served with caddy on a vps running nixos that you can access here: https://recipe-app.fredzernia.com
This Forgejo instance is hosted on a VPS running NixOS\
The Forgejo Runner is hosted in a LXC container running on a local nix server\
The runner builds the containers and pushes them to this Forgejo registry\
In the frontend container I bundle the compiled src with Caddy to run as a standalone app\
The exposed frontend port can then be served via reverse proxy
#### Try it out:
https://recipe-app.fredzernia.com
#### Run it yourself:
```
mkdir recipe_app && cd recipe_app
wget https://forgejo.fredzernia.com/fred/recipe_app/raw/branch/main/docker-compose.yaml
wget https://forgejo.fredzernia.com/fred/recipe_app/raw/branch/main/example.env
mv example.env .env # Change these values if you want
docker compose pull
docker compose up db -d
docker compose run backend npx prisma migrate deploy
docker compose up -d
```

View file

@ -1,27 +1,15 @@
FROM node:22-slim AS base FROM node:22-slim
WORKDIR /app WORKDIR /usr/src/app
COPY package*.json tsconfig*.json prisma.config.ts ./ COPY package*.json ./
COPY prisma ./prisma
COPY src ./src
RUN apt-get update -y && \ RUN apt-get update -y && apt-get install -y openssl
apt-get install -y openssl && \
rm -rf /var/lib/apt/lists/*
RUN npm install --omit=dev RUN if [ "$NODE_ENV" = "dev" ]; then npm install; else npm install --omit=dev; fi
RUN npm run prisma:generate
FROM base AS builder COPY . .
RUN npm install
RUN npm run build
FROM base
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "prod"] CMD npm run $NODE_ENV

View file

@ -1,11 +0,0 @@
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 3000
CMD ["npm", "run", "dev"]

File diff suppressed because it is too large Load diff

View file

@ -4,30 +4,28 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "nodemon ./src/index.ts", "dev": "nodemon ./src/index.ts",
"build": "tsc", "production": "npx tsc && node ./dist/index.js",
"prisma:generate": "prisma generate", "ci": "tsc"
"prod": "node ./dist/index.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "7.2.0", "@prisma/client": "^6.14.0",
"@prisma/client": "7.2.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"express": "^5.1.0", "express": "^5.1.0",
"pg": "^8.17.2", "knex": "^3.1.0",
"prisma": "7.2.0" "pg": "^8.16.3",
"prisma": "^6.14.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.9.2",
"ts-node": "^10.9.2",
"@types/node": "^24.2.1",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.2.1", "nodemon": "^3.1.10"
"@types/pg": "^8.16.0",
"nodemon": "^3.1.10",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
} }
} }

View file

@ -1,12 +0,0 @@
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: process.env['DATABASE_URL'],
},
});

View file

@ -1,5 +1,6 @@
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL")
} }
generator client { generator client {

View file

@ -1,12 +1,7 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import RecipeModel from "../models/recipeModel"; import RecipeModel from "../models/recipeModel";
import { PrismaClient } from "@prisma/client";
let model: RecipeModel; const model = new RecipeModel();
export const initializeController = (prisma: PrismaClient) => {
model = new RecipeModel(prisma);
};
export const test = async (req: Request, res: Response): Promise<void> => { export const test = async (req: Request, res: Response): Promise<void> => {
console.log("test"); console.log("test");
@ -51,6 +46,9 @@ export const getRecipeById = async (
}; };
export const addRecipe = async (req: Request, res: Response): Promise<void> => { export const addRecipe = async (req: Request, res: Response): Promise<void> => {
if (process.env.NODE_ENV === "demo") {
return;
}
try { try {
console.log(req.body); console.log(req.body);
const createdRecipe = await model.addRecipe(req.body); const createdRecipe = await model.addRecipe(req.body);
@ -70,6 +68,9 @@ export const updateRecipe = async (
): Promise<void> => { ): Promise<void> => {
console.log(req.body); console.log(req.body);
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (process.env.NODE_ENV === "demo") {
return;
}
try { try {
const updatedRecipe = await model.updateRecipe(req.body, id); const updatedRecipe = await model.updateRecipe(req.body, id);
res.status(201).json(updatedRecipe); res.status(201).json(updatedRecipe);
@ -83,6 +84,9 @@ export const updateRecipe = async (
}; };
export const setStars = async (req: Request, res: Response): Promise<void> => { export const setStars = async (req: Request, res: Response): Promise<void> => {
if (process.env.NODE_ENV === "demo") {
return;
}
const id = parseInt(req.body.id, 10); const id = parseInt(req.body.id, 10);
const stars = parseInt(req.body.stars, 10); const stars = parseInt(req.body.stars, 10);
try { try {
@ -101,6 +105,9 @@ export const deleteRecipe = async (
req: Request, req: Request,
res: Response, res: Response,
): Promise<void> => { ): Promise<void> => {
if (process.env.NODE_ENV === "demo") {
return;
}
const id = parseInt(req.body.id, 10); const id = parseInt(req.body.id, 10);
try { try {
await model.deleteRecipe(id); await model.deleteRecipe(id);

View file

@ -1,35 +1,24 @@
import "dotenv/config";
import express, { Express } from "express"; import express, { Express } from "express";
import cors from "cors"; import cors from "cors";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import appRoutes from "./routes/appRoutes"; import appRoutes from "./routes/appRoutes";
import { initializeController } from "./controllers/recipeController";
const app: Express = express(); const app: Express = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
const prisma = new PrismaClient();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
const prisma = new PrismaClient({ adapter });
initializeController(prisma);
function setupMiddleware(app: Express) { function setupMiddleware(app: Express) {
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use("/api", appRoutes); app.use("/api", appRoutes);
} }
setupMiddleware(app); setupMiddleware(app);
// Start server
async function startServer() { async function startServer() {
try { try {
app.listen(port, () => { app.listen(port);
console.log(`Server is running on http://localhost:${port}`); console.log(`Server is running on http://localhost:${port}`);
});
} catch (error) { } catch (error) {
console.error("Error starting the server:", error); console.error("Error starting the server:", error);
} }

View file

@ -6,8 +6,8 @@ const logger = new Logger();
class RecipeModel { class RecipeModel {
private prisma: PrismaClient; private prisma: PrismaClient;
constructor(prisma: PrismaClient) { constructor() {
this.prisma = prisma; this.prisma = new PrismaClient();
} }
async getAllRecipes(): Promise<any[]> { async getAllRecipes(): Promise<any[]> {
@ -40,18 +40,11 @@ class RecipeModel {
prep_minutes: recipe.prep_minutes, prep_minutes: recipe.prep_minutes,
cook_minutes: recipe.cook_minutes, cook_minutes: recipe.cook_minutes,
}, },
ingredients: recipe.recipeIngredients.map( ingredients: recipe.recipeIngredients.map((ing) => ing.raw),
(ing: { raw: string | null }) => ing.raw, steps: recipe.recipeSteps.map((step) => ({
), step_number: step.step_number,
steps: recipe.recipeSteps.map( instruction: step.instruction,
(step: { })),
step_number: number | null;
instruction: string | null;
}) => ({
step_number: step.step_number ?? 0, // Default to 0 if null
instruction: step.instruction ?? "", // Default to empty string if null
}),
),
}; };
logger.info("recipe page view", { logger.info("recipe page view", {
recipe_id: data.details.id, recipe_id: data.details.id,

View file

@ -1,43 +0,0 @@
services:
db:
container_name: recipes_db_dev
image: docker.io/library/postgres:17
restart: unless-stopped
env_file:
- .env
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes:
- ./db:/var/lib/postgresql/data
backend:
container_name: recipes_backend_dev
restart: unless-stopped
build:
context: ./backend
dockerfile: Dockerfile.dev
ports:
- "${BACKEND_PORT}:3000"
volumes:
- ./backend:/app
- ./logs:/logs
environment:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DATABASE_URL=${DATABASE_URL}
frontend:
container_name: recipes_frontend_dev
restart: unless-stopped
environment:
- NODE_ENV=dev
build:
context: ./frontend
dockerfile: Dockerfile.dev
ports:
- "${FRONTEND_PORT}:80"
volumes:
- ./frontend:/app

View file

@ -1,34 +1,47 @@
services: services:
db: db:
container_name: recipes_db container_name: recipes_postgres_${ID}
image: docker.io/library/postgres:17 image: docker.io/library/postgres:17
restart: unless-stopped # restart: always
env_file: env_file:
- .env - .env
environment: environment:
- POSTGRES_USER=${DB_USER} - POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME} - POSTGRES_DB=${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes: volumes:
- ./db:/var/lib/postgresql/data - ./db:/var/lib/postgresql/data
backend: backend:
container_name: recipes_backend image: recipes_backend
image: forgejo.fredzernia.com/fred/recipe_app_backend:latest container_name: recipes_backend_${ID}
restart: unless-stopped
build: build:
context: ./backend context: ./backend
args:
NODE_ENV: ${NODE_ENV}
ports:
- "${BACKEND_PORT}:3000"
volumes: volumes:
- ./backend:/usr/src/app
- ./logs:/logs - ./logs:/logs
environment: environment:
- NODE_ENV=${NODE_ENV}
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${PRISMA_DB_URL}
frontend: frontend:
container_name: recipes_frontend image: recipes_frontend
image: forgejo.fredzernia.com/fred/recipe_app_frontend:latest container_name: recipes_frontend_${ID}
restart: unless-stopped
build: build:
context: ./frontend context: ./backend
args:
NODE_ENV: ${NODE_ENV}
ports: ports:
- "${FRONTEND_PORT}:80" - "${FRONTEND_PORT}:80"
volumes:
- ./frontend:/usr/src/app
- "$FRONTEND_BUILD_DIR:/usr/src/app/dist"
environment:
- NODE_ENV=${NODE_ENV}

View file

@ -1,5 +0,0 @@
DB_USER=recipe_app
DB_PASSWORD=password
DB_NAME=recipe_app
FRONTEND_PORT=8080
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/

View file

@ -1,13 +0,0 @@
:80 {
# Backend
handle /api/* {
reverse_proxy recipes_backend:3000
}
# Frontend
handle {
root * /srv/
try_files {path} /index.html
file_server
}
}

View file

@ -1,15 +1,13 @@
FROM node:22-alpine AS builder FROM node:22-alpine
WORKDIR /app WORKDIR /usr/src/app
COPY package*.json tsconfig*.json postcss.config.js tailwind.config.js eslint.config.js vite.config.ts index.html ./
COPY src ./src
RUN apk update && apk upgrade COPY package*.json ./
RUN npm install
RUN npm run build
FROM caddy:2.10 RUN if [ "$NODE_ENV" = "dev" ]; then npm install; else npm install --omit=dev; fi
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=builder /app/dist /srv COPY . .
EXPOSE 80 EXPOSE 80
CMD npm run $NODE_ENV

View file

@ -1,11 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install;
EXPOSE 80
CMD ["npm", "run", "dev"]

View file

@ -87,7 +87,6 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
@ -1472,7 +1471,6 @@
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.10.0" "undici-types": "~7.10.0"
} }
@ -1483,7 +1481,6 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -1544,7 +1541,6 @@
"integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.36.0", "@typescript-eslint/scope-manager": "8.36.0",
"@typescript-eslint/types": "8.36.0", "@typescript-eslint/types": "8.36.0",
@ -1796,7 +1792,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1997,7 +1992,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@ -2032,9 +2026,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001766", "version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2326,7 +2320,6 @@
"integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3399,7 +3392,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -3576,7 +3568,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -3586,7 +3577,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@ -4104,7 +4094,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -4164,7 +4153,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -4257,7 +4245,6 @@
"integrity": "sha512-y2L5oJZF7bj4c0jgGYgBNSdIu+5HF+m68rn2cQXFbGoShdhV1phX9rbnxy9YXj82aS8MMsCLAAFkRxZeWdldrQ==", "integrity": "sha512-y2L5oJZF7bj4c0jgGYgBNSdIu+5HF+m68rn2cQXFbGoShdhV1phX9rbnxy9YXj82aS8MMsCLAAFkRxZeWdldrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.4.6",
@ -4348,7 +4335,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View file

@ -5,7 +5,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --port 80", "dev": "vite --host 0.0.0.0 --port 80",
"build": "tsc -b && vite build" "production": "npx tsc -b && vite build",
"ci": "tsc -b && vite build",
"lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"react": "^19.1.0", "react": "^19.1.0",
@ -13,8 +15,8 @@
"react-router-dom": "^7.6.3" "react-router-dom": "^7.6.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0",
"@types/node": "^24.2.0", "@types/node": "^24.2.0",
"@eslint/js": "^9.29.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",

View file

@ -0,0 +1,28 @@
import { Link } from 'react-router-dom';
interface DemoModalProps {
isOpen: boolean;
onClose: () => void;
closeModal: () => void;
}
const DemoModal = ({ isOpen, onClose, closeModal }: DemoModalProps) => {
if (!isOpen) return null;
return (
<div className={`z-50 modal-overlay fixed top-0 left-0 w-full h-full bg-black bg-opacity-50 flex justify-center items-center`} onClick={onClose}>
<div className="modal-content bg-[var(--color-primaryBg)] p-12 rounded-md shadow-md" onClick={(e) => e.stopPropagation()}>
<div className="modal-msg">
<p>Thanks for checking out my app! Database write operations are disabled in demo mode.</p>
<p><a className="text-[var(--color-textLink)]" href="mailto:access@fredzernia.com">access@fredzernia.com</a> to request access to the production build</p>
<p>Find out more about this app <Link to={'/about'} className="text-blue-600">here</Link></p>
</div>
<div className="modal-buttons">
<button className="bg-[var(--color-buttonBg)] rounded-md m-4 pt-1 pb-1 pr-2 pl-2" onClick={closeModal}>OK</button>
</div>
</div>
</div>
);
};
export default DemoModal;

View file

@ -1,12 +1,20 @@
import { useState } from "react";
import { addRecipe } from "../services/frontendApi.js"; import { addRecipe } from "../services/frontendApi.js";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import RecipeForm from "../components/RecipeForm.tsx"; import RecipeForm from "../components/RecipeForm.tsx";
import DemoModal from "../components/DemoModal.tsx";
import { type Recipe } from "../types/Recipe.ts" import { type Recipe } from "../types/Recipe.ts"
function AddRecipe() { function AddRecipe() {
const [showDemoModal, setShowDemoModal] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const addRecipeForm = async (recipeData: Recipe) => { const addRecipeForm = async (recipeData: Recipe) => {
if (process.env.NODE_ENV === "demo") {
setShowDemoModal(true);
return;
}
const data = await addRecipe(recipeData); const data = await addRecipe(recipeData);
navigate(`/recipe/${data.id}`); navigate(`/recipe/${data.id}`);
}; };
@ -14,6 +22,13 @@ function AddRecipe() {
return ( return (
<div className="add-recipe-card page-outer"> <div className="add-recipe-card page-outer">
<RecipeForm onSubmit={addRecipeForm} /> <RecipeForm onSubmit={addRecipeForm} />
{showDemoModal && (
<DemoModal
isOpen={showDemoModal}
onClose={() => setShowDemoModal(false)}
closeModal={() => setShowDemoModal(false)}
/>
)}
</div> </div>
); );
} }

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { getRecipeById, updateRecipe } from "../services/frontendApi.js"; import { getRecipeById, updateRecipe } from "../services/frontendApi.js";
import RecipeForm from "../components/RecipeForm.tsx"; import RecipeForm from "../components/RecipeForm.tsx";
import DemoModal from "../components/DemoModal.tsx";
import { type Recipe } from "../types/Recipe.ts" import { type Recipe } from "../types/Recipe.ts"
function EditRecipe() { function EditRecipe() {
@ -9,6 +10,7 @@ function EditRecipe() {
const navigate = useNavigate(); const navigate = useNavigate();
const [recipe, setRecipe] = useState<Recipe>(); const [recipe, setRecipe] = useState<Recipe>();
const [showDemoModal, setShowDemoModal] = useState(false);
useEffect(() => { useEffect(() => {
const fetchRecipe = async () => { const fetchRecipe = async () => {
@ -20,6 +22,11 @@ function EditRecipe() {
}, [id]); }, [id]);
const updateRecipeForm = async (recipeData: Recipe) => { const updateRecipeForm = async (recipeData: Recipe) => {
if (process.env.NODE_ENV === "demo") {
setShowDemoModal(true);
return;
}
await updateRecipe(id, recipeData); await updateRecipe(id, recipeData);
navigate(`/recipe/${id}`); navigate(`/recipe/${id}`);
}; };
@ -29,6 +36,13 @@ function EditRecipe() {
{recipe && ( {recipe && (
<RecipeForm onSubmit={updateRecipeForm} initialData={recipe} /> <RecipeForm onSubmit={updateRecipeForm} initialData={recipe} />
)} )}
{showDemoModal && (
<DemoModal
isOpen={showDemoModal}
onClose={() => setShowDemoModal(false)}
closeModal={() => setShowDemoModal(false)}
/>
)}
</div> </div>
); );
} }

View file

@ -7,6 +7,7 @@ import {
} from "../services/frontendApi.js"; } from "../services/frontendApi.js";
import { type Recipe } from "../types/Recipe"; import { type Recipe } from "../types/Recipe";
import Modal from "../components/Modal.tsx"; import Modal from "../components/Modal.tsx";
import DemoModal from "../components/DemoModal.tsx";
import StarRating from "../components/StarRating.tsx"; import StarRating from "../components/StarRating.tsx";
import TimeDisplay from "../components/TimeDisplay.tsx"; import TimeDisplay from "../components/TimeDisplay.tsx";
@ -21,6 +22,7 @@ function RecipePage() {
const [stars, setStars] = useState<number>(0); const [stars, setStars] = useState<number>(0);
const [initialStars, setInitialStars] = useState<number | null>(null); const [initialStars, setInitialStars] = useState<number | null>(null);
const [showConfirmModal, setShowConfirmModal] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false);
const [showDemoModal, setShowDemoModal] = useState(false);
const { id } = useParams(); const { id } = useParams();
const isWebSource = const isWebSource =
recipe && recipe.details && recipe.details.author recipe && recipe.details && recipe.details.author
@ -36,8 +38,13 @@ function RecipePage() {
}; };
const confirmDelete = () => { const confirmDelete = () => {
if (process.env.NODE_ENV === "demo") {
closeModal();
setShowDemoModal(true);
} else {
handleDelete(recipe.details.id); handleDelete(recipe.details.id);
closeModal(); closeModal();
}
}; };
useEffect(() => { useEffect(() => {
@ -213,6 +220,13 @@ function RecipePage() {
confirmAction={confirmDelete} confirmAction={confirmDelete}
cancelAction={closeModal} cancelAction={closeModal}
/> />
{showDemoModal && (
<DemoModal
isOpen={showDemoModal}
onClose={() => setShowDemoModal(false)}
closeModal={() => setShowDemoModal(false)}
/>
)}
</div> </div>
); );
} }

View file

@ -1,14 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = [
pkgs.prisma-engines
pkgs.prisma
];
shellHook = ''
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig"
export PRISMA_SCHEMA_ENGINE_BINARY="${pkgs.prisma-engines}/bin/schema-engine"
export PRISMA_QUERY_ENGINE_BINARY="${pkgs.prisma-engines}/bin/query-engine"
export PRISMA_QUERY_ENGINE_LIBRARY="${pkgs.prisma-engines}/lib/libquery_engine.node"
export PRISMA_FMT_BINARY="${pkgs.prisma-engines}/bin/prisma-fmt"
'';
}