add markdown and docker

This commit is contained in:
taogaetz 2025-09-08 14:23:31 -04:00
parent 71ac76bc72
commit 32f5d216ba
23 changed files with 1063 additions and 422 deletions

95
.dockerignore Normal file
View File

@ -0,0 +1,95 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
.svelte-kit
build
dist
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode
.idea
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Database files (will be mounted as volume)
prisma/database.db

80
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,80 @@
# Chef Bible - Docker Deployment
## Portainer Deployment
This application is designed to be deployed on Portainer with SQLite database persistence.
### Environment Variables
Set these environment variables in Portainer:
**Required:**
```
NODE_ENV=production
DATABASE_URL=file:/app/data/database.db
ORIGIN=https://birba.redbackpack.ca
```
**Optional (for photo uploads):**
```
CLOUDINARY_URL=cloudinary://api_key:api_secret@cloud_name
```
*Note: If CLOUDINARY_URL is not provided, photo uploads will be disabled but the application will still function normally.*
### Volume Mounts
Create a volume mount for database persistence:
- **Host Path**: `/var/lib/docker/volumes/chef-bible-data/_data`
- **Container Path**: `/app/data`
- **Type**: Bind Mount or Named Volume
### Port Configuration
- **Container Port**: 3000
- **Host Port**: 80 (or your preferred port)
- **Protocol**: HTTP
### Docker Build
To build the image locally:
```bash
docker build -t chef-bible .
```
### Database Initialization
The application will automatically:
1. Create the SQLite database on first run
2. Run Prisma migrations
3. Seed the database with initial recipes
### Health Check
The container includes a health check endpoint at `/health` that Portainer can use to monitor the application status.
### Security Notes
- The application runs as a non-root user (`sveltekit`)
- Database files are stored in a persistent volume
- Environment variables are used for configuration
- No sensitive data is hardcoded
### Backup
To backup the database:
```bash
# Copy the database file from the volume
docker cp <container_id>:/app/data/database.db ./backup-$(date +%Y%m%d).db
```
### Updates
To update the application:
1. Build new image with updated code
2. Deploy new container in Portainer
3. Database will persist through updates via volume mount

88
Dockerfile Normal file
View File

@ -0,0 +1,88 @@
# Use Node.js 20 Alpine for smaller image size
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
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 . .
# Accept build arguments for environment variables
ARG DATABASE_URL
ARG CLOUDINARY_URL
ARG MAGIC_LINK_TOKEN
# Set environment variables for build
ENV DATABASE_URL=$DATABASE_URL
ENV CLOUDINARY_URL=$CLOUDINARY_URL
ENV MAGIC_LINK_TOKEN=$MAGIC_LINK_TOKEN
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build
# Production image, copy all the files and run the app
FROM base AS runner
WORKDIR /app
# Accept build arguments for environment variables
ARG DATABASE_URL
ARG CLOUDINARY_URL
ARG MAGIC_LINK_TOKEN
ENV NODE_ENV=production
ENV PORT=3000
ENV DATABASE_URL=$DATABASE_URL
ENV CLOUDINARY_URL=$CLOUDINARY_URL
ENV MAGIC_LINK_TOKEN=$MAGIC_LINK_TOKEN
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 sveltekit
# Copy the built application
COPY --from=builder --chown=sveltekit:nodejs /app/build ./build
COPY --from=builder --chown=sveltekit:nodejs /app/server.mjs ./server.mjs
COPY --from=builder --chown=sveltekit:nodejs /app/package.json ./package.json
COPY --from=builder --chown=sveltekit:nodejs /app/package-lock.json ./package-lock.json
COPY --from=builder --chown=sveltekit:nodejs /app/prisma ./prisma
COPY --from=builder --chown=sveltekit:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=sveltekit:nodejs /app/docker-entrypoint.sh ./docker-entrypoint.sh
# Keep "type": "module" for ES modules
# Install only production dependencies
RUN npm ci --omit=dev && npm cache clean --force
# Create directory for SQLite database
RUN mkdir -p /app/data && chown sveltekit:nodejs /app/data
# Set the database URL to use the volume
ENV DATABASE_URL="file:/app/data/database.db"
# Make entrypoint script executable
RUN chmod +x /app/docker-entrypoint.sh
USER sveltekit
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.mjs"]

18
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Wait for database to be ready (if using external database)
# For SQLite, this is not needed, but good practice for future database changes
echo "Running database migrations..."
# Use the DATABASE_URL from environment or default to SQLite
if [ -z "$DATABASE_URL" ]; then
export DATABASE_URL="file:/app/data/database.db"
fi
npx prisma migrate deploy
echo "Seeding database..."
npx prisma db seed
echo "Starting application..."
exec "$@"

331
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,6 +44,7 @@
"dependencies": {
"@prisma/client": "^6.14.0",
"@prisma/extension-accelerate": "^2.0.2",
"@sveltejs/adapter-node": "^5.3.1",
"cloudinary": "^2.7.0",
"convert": "^5.12.0"
}

View File

@ -47,7 +47,7 @@ async function main() {
description: 'A rich and flavorful dressing made with vincotto, perfect for salads and marinades',
time: 'Quick',
station: 'Garde Manger',
instructions: '1. Combine all ingredients in a bowl\n2. Whisk until well combined\n3. Season to taste with salt and pepper\n4. Store in refrigerator for up to 1 week',
instructions: '',
ingredients: {
create: [
{ ingredientId: ingredientMap.get('Vincotto')!.id, quantity: 90, unit: 'ml' },
@ -68,7 +68,7 @@ async function main() {
description: 'A bright and citrusy dressing with charred orange notes',
time: 'Medium',
station: 'Garde Manger',
instructions: '1. Char oranges over open flame until blackened\n2. Juice the charred oranges\n3. Combine all ingredients in a bowl\n4. Whisk until emulsified\n5. Season to taste',
instructions: '',
ingredients: {
create: [
{ ingredientId: ingredientMap.get('Charred Orange')!.id, quantity: 5, unit: 'oranges' },
@ -91,7 +91,7 @@ async function main() {
description: 'A sweet and tangy vinegar solution for curing and marinating scallops',
time: 'Quick',
station: 'Garde Manger',
instructions: '1. Combine all ingredients in a saucepan\n2. Bring to a boil, stirring to dissolve sugar and salt\n3. Remove from heat and let cool\n4. Store in refrigerator for up to 2 weeks',
instructions: '',
ingredients: {
create: [
{ ingredientId: ingredientMap.get('Water')!.id, quantity: 250, unit: 'ml' },
@ -110,7 +110,7 @@ async function main() {
description: 'A spicy and flavorful spread made with nduja sausage',
time: 'Medium',
station: 'Garde Manger',
instructions: '1. Soften butter to room temperature\n2. Combine all ingredients in a food processor\n3. Pulse until well combined but still chunky\n4. Season to taste\n5. Store in refrigerator for up to 1 week',
instructions: '',
ingredients: {
create: [
{ ingredientId: ingredientMap.get('Nduja')!.id, quantity: 450, unit: 'g' },

13
server.mjs Normal file
View File

@ -0,0 +1,13 @@
// ES module server wrapper that provides __dirname and __filename for Prisma
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Provide __dirname and __filename for Prisma
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
global.__dirname = __dirname;
global.__filename = __filename;
// Import and start the server
import('./build/index.js');

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
%sveltekit.head%
</head>

View File

@ -1,8 +1,9 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// Check if user is authenticated using the new cookie API
const authenticated = event.cookies.get('chef_bible_auth') === 'authenticated';
// Check if user is authenticated using the chef token cookie
const chefToken = event.cookies.get('chef_token');
const authenticated = !!chefToken;
// Add authentication status to locals for use in load functions
event.locals.authenticated = authenticated;

101
src/lib/utils/markdown.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* Simple markdown parser for basic formatting
* Supports: newlines, bold text (**text**), and links ([text](url))
*/
export interface MarkdownNode {
type: 'text' | 'bold' | 'link' | 'linebreak';
content?: string;
url?: string;
}
export function parseMarkdown(text: string): MarkdownNode[] {
if (!text) return [];
const nodes: MarkdownNode[] = [];
let currentIndex = 0;
while (currentIndex < text.length) {
// Check for line breaks first
if (text[currentIndex] === '\n') {
nodes.push({ type: 'linebreak' });
currentIndex++;
continue;
}
// Check for bold text (**text**)
if (text.slice(currentIndex, currentIndex + 2) === '**') {
const endIndex = text.indexOf('**', currentIndex + 2);
if (endIndex !== -1) {
const content = text.slice(currentIndex + 2, endIndex);
nodes.push({ type: 'bold', content });
currentIndex = endIndex + 2;
continue;
}
}
// Check for links ([text](url))
if (text[currentIndex] === '[') {
const linkEnd = text.indexOf(']', currentIndex);
if (linkEnd !== -1 && text[linkEnd + 1] === '(') {
const urlStart = linkEnd + 2;
const urlEnd = text.indexOf(')', urlStart);
if (urlEnd !== -1) {
const linkText = text.slice(currentIndex + 1, linkEnd);
const url = text.slice(urlStart, urlEnd);
nodes.push({ type: 'link', content: linkText, url });
currentIndex = urlEnd + 1;
continue;
}
}
}
// Regular text - collect until we hit a special character
let textEnd = currentIndex;
while (textEnd < text.length &&
text[textEnd] !== '\n' &&
text[textEnd] !== '*' &&
text[textEnd] !== '[') {
textEnd++;
}
if (textEnd > currentIndex) {
const content = text.slice(currentIndex, textEnd);
nodes.push({ type: 'text', content });
currentIndex = textEnd;
} else {
// Single character that doesn't match any pattern
nodes.push({ type: 'text', content: text[currentIndex] });
currentIndex++;
}
}
return nodes;
}
export function renderMarkdownToHTML(text: string): string {
const nodes = parseMarkdown(text);
return nodes.map(node => {
switch (node.type) {
case 'linebreak':
return '<br>';
case 'bold':
return `<strong>${escapeHtml(node.content || '')}</strong>`;
case 'link':
return `<a href="${escapeHtml(node.url || '')}" target="_blank" rel="noopener noreferrer" class="link link-primary">${escapeHtml(node.content || '')}</a>`;
case 'text':
default:
return escapeHtml(node.content || '');
}
}).join('');
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@ -47,19 +47,6 @@
© 2025 Birbante - Italian Apericena & Dolce Vita
</div>
<div class="flex items-center gap-4">
<a href="/chef-access" class="btn btn-ghost btn-sm">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 12l2 2 4-4M5 12h14" />
</svg>
Chef Access
</a>
{#if data.authenticated}
<LogoutButton />
{/if}

View File

@ -1,40 +0,0 @@
import type { PageServerLoad } from './$types';
import { validateMagicLinkToken } from '$lib/auth';
import { redirect } from '@sveltejs/kit';
import { MAGIC_LINK_TOKEN } from '$env/static/private';
import { dev } from '$app/environment';
export const load: PageServerLoad = async ({ url, cookies }) => {
const token = url.searchParams.get('token');
// If no token provided, just show the page normally
if (!token) {
return {
authenticated: false,
message: 'No authentication token provided',
magicLinkToken: MAGIC_LINK_TOKEN
};
}
// Validate the token
if (validateMagicLinkToken(token, MAGIC_LINK_TOKEN)) {
// Set the authentication cookie using the new API
cookies.set('chef_bible_auth', 'authenticated', {
path: '/',
httpOnly: true,
sameSite: 'lax', // Changed from 'strict' to 'lax' for mobile Safari compatibility
secure: !dev, // Secure in production, not secure in development
maxAge: 30 * 24 * 60 * 60 // 30 days in seconds
});
// Redirect to home page with success message
throw redirect(302, '/?auth=success');
} else {
// Invalid token
return {
authenticated: false,
message: 'Invalid authentication token',
magicLinkToken: MAGIC_LINK_TOKEN
};
}
};

View File

@ -1,129 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import { generateMagicLink } from '$lib/auth';
let { data }: { data: PageData } = $props();
// Generate the magic link for the current domain using the token from server
const magicLink = generateMagicLink(window.location.origin, data.magicLinkToken);
</script>
<svelte:head>
<title>Chef Access - Chef Bible</title>
<meta name="description" content="Access to add and edit recipes for kitchen staff training" />
</svelte:head>
<main class="m-0 min-h-screen bg-gradient-to-br from-base-200 to-base-300 p-0">
<div class="mx-auto max-w-4xl p-10 lg:p-6">
<div class="card bg-white shadow-lg">
<div class="card-body p-8">
<h2 class="mb-6 card-title text-3xl font-bold text-base-content">Kitchen Staff Access</h2>
<div class="prose max-w-none">
<p class="mb-6 text-lg text-base-content/70">
Scan the QR code below to get access to create and edit recipes. This access will last
for 30 days.
</p>
<div class="my-8 flex justify-center">
<div class="rounded-lg border-4 border-base-200 bg-white p-6">
<!-- QR Code Placeholder - In production, you'd generate an actual QR code -->
<div
class="flex h-64 w-64 items-center justify-center rounded-lg bg-base-100 text-center"
>
<div>
<div class="mb-4 text-4xl">📱</div>
<div class="text-sm text-base-content/60">QR Code</div>
<div class="text-xs text-base-content/40">Scan with your phone</div>
</div>
</div>
</div>
</div>
<div class="alert alert-info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
<span>
<strong>Magic Link:</strong>
<a href={magicLink} class="link break-all link-primary">{magicLink}</a>
</span>
</div>
<div class="mt-8 rounded-lg border border-base-200 bg-base-100 p-6">
<h3 class="mb-4 text-xl font-bold">What You Can Do With Access:</h3>
<ul class="space-y-2 text-base-content/70">
<li class="flex items-center gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-success"
>
<path d="M12 5v14M5 12h14" />
</svg>
Create new recipes with ingredients, instructions, and photos
</li>
<li class="flex items-center gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-success"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit existing recipes to update ingredients or instructions
</li>
<li class="flex items-center gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-success"
>
<path
d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
Delete recipes when they're no longer needed
</li>
<li class="flex items-center gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-success"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
Manage recipe details like cooking time and station
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</main>

View File

@ -20,8 +20,12 @@ export const load: PageServerLoad = async ({ url, cookies, setHeaders }) => {
});
// Set a client-side cookie for UI state
setHeaders({
'set-cookie': `chef_authenticated=true; Path=/; Max-Age=${60 * 60 * 24 * 30}; SameSite=Lax`
cookies.set('chef_authenticated', 'true', {
path: '/',
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 days
});
throw redirect(302, '/');

View File

@ -0,0 +1,6 @@
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return new Response('OK', { status: 200 });
};

View File

@ -3,12 +3,20 @@ import { redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
export const POST: RequestHandler = async ({ cookies }) => {
// Clear the authentication cookie using the new API
cookies.set('chef_bible_auth', '', {
// Clear the chef authentication cookies
cookies.set('chef_token', '', {
path: '/',
httpOnly: true,
sameSite: 'lax', // Changed from 'strict' to 'lax' for mobile Safari compatibility
secure: !dev, // Secure in production, not secure in development
sameSite: 'lax',
secure: !dev,
maxAge: 0 // Expire immediately
});
cookies.set('chef_authenticated', '', {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: !dev,
maxAge: 0 // Expire immediately
});
@ -17,12 +25,20 @@ export const POST: RequestHandler = async ({ cookies }) => {
};
export const GET: RequestHandler = async ({ cookies }) => {
// Clear the authentication cookie using the new API
cookies.set('chef_bible_auth', '', {
// Clear the chef authentication cookies
cookies.set('chef_token', '', {
path: '/',
httpOnly: true,
sameSite: 'lax', // Changed from 'strict' to 'lax' for mobile Safari compatibility
secure: !dev, // Secure in production, not secure in development
sameSite: 'lax',
secure: !dev,
maxAge: 0 // Expire immediately
});
cookies.set('chef_authenticated', '', {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: !dev,
maxAge: 0 // Expire immediately
});

View File

@ -1,6 +1,7 @@
<script lang="ts">
import type { PageProps } from './$types';
import { goto } from '$app/navigation';
import { renderMarkdownToHTML } from '$lib/utils/markdown';
let { data }: PageProps = $props();
const { recipe } = data;
@ -76,9 +77,11 @@
{recipe.name}
</h1>
{#if recipe.description}
<p class="mb-6 text-lg leading-relaxed text-base-content/70">
{recipe.description}
</p>
<div
class="prose-lg mb-6 prose max-w-none text-lg leading-relaxed text-base-content/70"
>
{@html renderMarkdownToHTML(recipe.description)}
</div>
{/if}
{#if data.authenticated}
@ -200,7 +203,7 @@
<div class="card-body p-8">
<h2 class="mb-6 card-title text-2xl font-bold text-base-content">Instructions</h2>
<div class="prose-lg prose max-w-none leading-relaxed text-base-content">
{recipe.instructions}
{@html renderMarkdownToHTML(recipe.instructions)}
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown';
let { data }: { data: PageData } = $props();
@ -8,11 +9,15 @@
let lastError = $state<string | null>(null);
let lastSuccess = $state<string | null>(null);
let ingredientsText = $state('');
let descriptionText = $state('');
let instructionsText = $state('');
let formSubmitted = $state(false);
let showDeleteConfirm = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
null
);
let showMarkdownHelp = $state(false);
let showIngredientHelp = $state(false);
// Initialize form with existing data
$effect(() => {
@ -27,6 +32,10 @@
return line;
});
ingredientsText = ingredientLines.join('\n');
// Initialize description and instructions
descriptionText = data.recipe.description || '';
instructionsText = data.recipe.instructions || '';
}
});
@ -137,9 +146,19 @@
// count / generic pieces stay as-is
clove: { canonical: 'clove', system: 'count' },
cloves: { canonical: 'clove', system: 'count' },
piece: { canonical: 'pc', system: 'count' },
pieces: { canonical: 'pc', system: 'count' },
pc: { canonical: 'pc', system: 'count' }
piece: { canonical: 'piece', system: 'count' },
pieces: { canonical: 'piece', system: 'count' },
pc: { canonical: 'piece', system: 'count' },
can: { canonical: 'can', system: 'count' },
cans: { canonical: 'can', system: 'count' },
unit: { canonical: 'unit', system: 'count' },
units: { canonical: 'unit', system: 'count' },
bunch: { canonical: 'bunch', system: 'count' },
bunches: { canonical: 'bunch', system: 'count' },
head: { canonical: 'head', system: 'count' },
heads: { canonical: 'head', system: 'count' },
part: { canonical: 'part', system: 'count' },
parts: { canonical: 'part', system: 'count' }
};
function parseIngredientLine(
@ -165,7 +184,7 @@
if (parts.length >= 3) {
const secondPart = parts[1];
// Check if second part looks like a unit (short, alphanumeric, and in our known units)
if (secondPart.length <= 4 && /^[a-zA-Z]+$/.test(secondPart)) {
if (secondPart.length <= 6 && /^[a-zA-Z]+$/.test(secondPart)) {
const unitLower = secondPart.toLowerCase();
// Only treat as unit if it's in our known unit aliases
if (unitAliases[unitLower]) {
@ -318,8 +337,10 @@
// Create FormData with parsed ingredients
const formData = new FormData(form);
// Remove the raw ingredients textarea
// Remove the raw ingredients textarea and update with state values
formData.delete('ingredients');
formData.set('description', descriptionText);
formData.set('instructions', instructionsText);
// Add parsed ingredients as JSON
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
@ -506,12 +527,14 @@
class="textarea-bordered textarea w-full"
bind:value={ingredientsText}
></textarea>
<div class="label">
<span class="label-text-alt text-sm text-base-content/60">
<strong>Pattern:</strong> [quantity] [unit] ingredient name[, prep notes]<br />
</span>
</div>
<button
type="button"
class="cursor-help text-sm underline decoration-dotted"
onclick={() => (showIngredientHelp = true)}
>
Ingredient formatting help
</button>
{#if ingredientsText}
<div class="mt-3 rounded-lg bg-base-200 p-3">
<div class="mb-2 text-sm font-semibold text-base-content/70">Live Preview:</div>
@ -541,25 +564,62 @@
{/if}
</label>
<div
class="tooltip tooltip-top"
data-tip="tsp, tbsp, cup(s), ml, l, g, kg, oz, lb, clove, piece, pc"
>
<strong class="cursor-help underline decoration-dotted">Valid units</strong>
</div>
<label class="form-control">
<span class="label"><span class="label-text font-bold">Description</span></span>
<textarea name="description" rows="3" class="textarea-bordered textarea w-full"
>{data.recipe?.description || ''}</textarea
>
<span class="label">
<span class="label-text font-bold">Description</span>
<button
type="button"
class="cursor-help text-sm underline decoration-dotted"
onclick={() => (showMarkdownHelp = true)}
>
?
</button>
</span>
<textarea
name="description"
rows="3"
class="textarea-bordered textarea w-full"
bind:value={descriptionText}
placeholder="Enter a description for your recipe"
></textarea>
{#if descriptionText}
<div class="mt-3 rounded-lg bg-base-200 p-3" onclick={(e) => e.stopPropagation()}>
<div class="mb-2 text-sm font-semibold text-base-content/70">Preview:</div>
<div class="prose-sm prose max-w-none text-base-content">
{@html renderMarkdownToHTML(descriptionText)}
</div>
</div>
{/if}
</label>
<label class="form-control">
<span class="label"><span class="label-text font-bold">Instructions</span></span>
<textarea name="instructions" rows="6" class="textarea-bordered textarea w-full"
>{data.recipe?.instructions || ''}</textarea
>
<span class="label">
<span class="label-text font-bold">Instructions</span>
<button
type="button"
class="cursor-help text-sm underline decoration-dotted"
onclick={() => (showMarkdownHelp = true)}
>
?
</button>
</span>
<textarea
name="instructions"
rows="6"
class="textarea-bordered textarea w-full"
bind:value={instructionsText}
placeholder="Enter step-by-step instructions"
></textarea>
{#if instructionsText}
<div class="mt-3 rounded-lg bg-base-200 p-3" onclick={(e) => e.stopPropagation()}>
<div class="mb-2 text-sm font-semibold text-base-content/70">Preview:</div>
<div class="prose-sm prose max-w-none text-base-content">
{@html renderMarkdownToHTML(instructionsText)}
</div>
</div>
{/if}
</label>
<div class="mt-2 flex flex-col gap-3 sm:flex-row sm:justify-end">
@ -654,6 +714,82 @@
</div>
</div>
{/if}
<!-- Markdown Help Modal -->
{#if showMarkdownHelp}
<div class="modal-open modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Markdown Formatting Help</h3>
<div class="py-4">
<div class="space-y-3">
<div>
<strong>Bold Text:</strong>
<div class="mt-1 rounded bg-base-200 p-2 font-mono text-sm">**bold text**</div>
</div>
<div>
<strong>Links:</strong>
<div class="mt-1 rounded bg-base-200 p-2 font-mono text-sm">
[link text](https://example.com)
</div>
</div>
<div>
<strong>Line Breaks:</strong>
<div class="mt-1 text-sm text-base-content/70">
Simply press Enter in the textarea - line breaks are preserved automatically
</div>
</div>
</div>
</div>
<div class="modal-action">
<button class="btn" onclick={() => (showMarkdownHelp = false)}>Close</button>
</div>
</div>
</div>
{/if}
<!-- Ingredient Help Modal -->
{#if showIngredientHelp}
<div class="modal-open modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Ingredient Formatting Help</h3>
<div class="py-4">
<div class="space-y-4">
<div>
<strong>Pattern:</strong>
<div class="mt-1 rounded bg-base-200 p-2 font-mono text-sm">
[quantity] [unit] ingredient name[, prep notes]
</div>
</div>
<div>
<strong>Valid Units:</strong>
<div class="mt-1 rounded bg-base-200 p-2 text-sm">
<div class="grid grid-cols-2 gap-1">
<div><strong>Volume:</strong> tsp, tbsp, cup(s), ml, l</div>
<div><strong>Mass:</strong> g, kg, oz, lb</div>
<div><strong>Count:</strong> clove, piece, pc, can, unit</div>
<div><strong>Other:</strong> bunch, head, part</div>
</div>
</div>
</div>
<div>
<strong>Examples:</strong>
<div class="mt-1 space-y-1 text-sm">
<div class="rounded bg-base-200 p-2 font-mono">2 cups flour, sifted</div>
<div class="rounded bg-base-200 p-2 font-mono">3g salt</div>
<div class="rounded bg-base-200 p-2 font-mono">1 bunch fresh basil</div>
<div class="rounded bg-base-200 p-2 font-mono">2 cans diced tomatoes</div>
</div>
</div>
</div>
</div>
<div class="modal-action">
<button class="btn" onclick={() => (showIngredientHelp = false)}>Close</button>
</div>
</div>
</div>
{/if}
</div>
<style>

View File

@ -1,17 +1,21 @@
import type { RequestHandler } from './$types';
import prisma from '$lib/server/prisma';
import { v2 as cloudinary } from 'cloudinary';
import { CLOUDINARY_URL } from '$env/static/private';
// Configure Cloudinary using the URL
if (!CLOUDINARY_URL) {
throw new Error('Missing CLOUDINARY_URL environment variable');
// Configure Cloudinary conditionally
let cloudinary: any = null;
try {
const { CLOUDINARY_URL } = await import('$env/static/private');
if (CLOUDINARY_URL) {
const { v2 } = await import('cloudinary');
cloudinary = v2;
cloudinary.config({
url: CLOUDINARY_URL
});
}
} catch (error) {
console.warn('Cloudinary not configured:', error);
}
cloudinary.config({
url: CLOUDINARY_URL
});
export const POST: RequestHandler = async ({ request, params }) => {
const formData = await request.formData();
@ -42,30 +46,35 @@ export const POST: RequestHandler = async ({ request, params }) => {
// Handle photo upload if provided
if (photo && photo.size > 0) {
try {
// Convert File to buffer
const arrayBuffer = await photo.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (cloudinary) {
// Convert File to buffer
const arrayBuffer = await photo.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to Cloudinary
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
cloudinary.uploader.upload_stream(
{
folder: 'chef-bible/recipes',
resource_type: 'auto',
transformation: [
{ width: 800, height: 600, crop: 'limit' },
{ quality: 'auto:good' }
]
},
(error, result) => {
if (error) reject(error);
else if (result) resolve(result as { secure_url: string });
else reject(new Error('No result from Cloudinary'));
}
).end(buffer);
});
// Upload to Cloudinary
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
cloudinary.uploader.upload_stream(
{
folder: 'chef-bible/recipes',
resource_type: 'auto',
transformation: [
{ width: 800, height: 600, crop: 'limit' },
{ quality: 'auto:good' }
]
},
(error, result) => {
if (error) reject(error);
else if (result) resolve(result as { secure_url: string });
else reject(new Error('No result from Cloudinary'));
}
).end(buffer);
});
photoUrl = result.secure_url;
photoUrl = result.secure_url;
} else {
// Cloudinary not configured, skip photo upload
console.warn('Cloudinary not configured, skipping photo upload');
}
} catch (error) {
console.error('Failed to upload photo to Cloudinary:', error);
return new Response(JSON.stringify({ type: 'error', message: 'Failed to upload photo' }), { status: 500 });

View File

@ -1,5 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown';
let { data }: { data: PageData } = $props();
@ -7,10 +8,14 @@
let lastError = $state<string | null>(null);
let lastSuccess = $state<string | null>(null);
let ingredientsText = $state('');
let descriptionText = $state('');
let instructionsText = $state('');
let formSubmitted = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
null
);
let showIngredientHelp = $state(false);
let showMarkdownHelp = $state(false);
// Parsing functions for live preview
function parseMixedNumber(input: string): number | null {
@ -274,8 +279,10 @@
// Create FormData with parsed ingredients
const formData = new FormData(form);
// Remove the raw ingredients textarea
// Remove the raw ingredients textarea and update with state values
formData.delete('ingredients');
formData.set('description', descriptionText);
formData.set('instructions', instructionsText);
// Add parsed ingredients as JSON
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
@ -462,11 +469,14 @@
class="textarea-bordered textarea w-full"
bind:value={ingredientsText}
></textarea>
<div class="label">
<span class="label-text-alt text-sm text-base-content/60">
<strong>Pattern:</strong> [quantity] [unit] ingredient name[, prep notes]<br />
</span>
</div>
<button
type="button"
class="cursor-help text-sm underline decoration-dotted"
onclick={() => (showIngredientHelp = true)}
>
Ingredient formatting help
</button>
{#if ingredientsText}
<div class="mt-3 rounded-lg bg-base-200 p-3">
@ -496,20 +506,62 @@
</div>
{/if}
</label>
<div
class="tooltip tooltip-top"
data-tip="tsp, tbsp, cup(s), ml, l, g, kg, oz, lb, clove, piece, pc"
>
<strong class="cursor-help underline decoration-dotted">Valid units</strong>
</div>
<label class="form-control">
<span class="label"><span class="label-text font-bold">Description</span></span>
<textarea name="description" rows="3" class="textarea-bordered textarea w-full"></textarea>
<span class="label">
<span class="label-text font-bold">Description</span>
<button
type="button"
class="cursor-help text-sm underline decoration-dotted"
onclick={() => (showMarkdownHelp = true)}
>
?
</button>
</span>
<textarea
name="description"
rows="3"
class="textarea-bordered textarea w-full"
bind:value={descriptionText}
placeholder="Enter a description for your recipe"
></textarea>
{#if descriptionText}
<div class="mt-3 rounded-lg bg-base-200 p-3" onclick={(e) => e.stopPropagation()}>
<div class="mb-2 text-sm font-semibold text-base-content/70">Preview:</div>
<div class="prose-sm prose max-w-none text-base-content">
{@html renderMarkdownToHTML(descriptionText)}
</div>
</div>
{/if}
</label>
<label class="form-control">
<span class="label"><span class="label-text font-bold">Instructions</span></span>
<textarea name="instructions" rows="6" class="textarea-bordered textarea w-full"></textarea>
<span class="label">
<span class="label-text font-bold">Instructions</span>
<button
type="button"
class="cursor-help text-sm underline decoration-dotted"
onclick={() => (showMarkdownHelp = true)}
>
?
</button>
</span>
<textarea
name="instructions"
rows="6"
class="textarea-bordered textarea w-full"
bind:value={instructionsText}
placeholder="Enter step-by-step instructions"
></textarea>
{#if instructionsText}
<div class="mt-3 rounded-lg bg-base-200 p-3" onclick={(e) => e.stopPropagation()}>
<div class="mb-2 text-sm font-semibold text-base-content/70">Preview:</div>
<div class="prose-sm prose max-w-none text-base-content">
{@html renderMarkdownToHTML(instructionsText)}
</div>
</div>
{/if}
</label>
<div class="mt-2 flex flex-col gap-3 sm:flex-row sm:justify-end">
@ -582,6 +634,82 @@
</div>
{/if}
<!-- Ingredient Help Modal -->
{#if showIngredientHelp}
<div class="modal-open modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Ingredient Formatting Help</h3>
<div class="py-4">
<div class="space-y-4">
<div>
<strong>Pattern:</strong>
<div class="mt-1 rounded bg-base-200 p-2 font-mono text-sm">
[quantity] [unit] ingredient name[, prep notes]
</div>
</div>
<div>
<strong>Valid Units:</strong>
<div class="mt-1 rounded bg-base-200 p-2 text-sm">
<div class="grid grid-cols-2 gap-1">
<div><strong>Volume:</strong> tsp, tbsp, cup(s), ml, l</div>
<div><strong>Mass:</strong> g, kg, oz, lb</div>
<div><strong>Count:</strong> clove, piece, pc, can, unit</div>
<div><strong>Other:</strong> bunch, head, part</div>
</div>
</div>
</div>
<div>
<strong>Examples:</strong>
<div class="mt-1 space-y-1 text-sm">
<div class="rounded bg-base-200 p-2 font-mono">2 cups flour, sifted</div>
<div class="rounded bg-base-200 p-2 font-mono">3g salt</div>
<div class="rounded bg-base-200 p-2 font-mono">1 bunch fresh basil</div>
<div class="rounded bg-base-200 p-2 font-mono">2 cans diced tomatoes</div>
</div>
</div>
</div>
</div>
<div class="modal-action">
<button class="btn" onclick={() => (showIngredientHelp = false)}>Close</button>
</div>
</div>
</div>
{/if}
<!-- Markdown Help Modal -->
{#if showMarkdownHelp}
<div class="modal-open modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Markdown Formatting Help</h3>
<div class="py-4">
<div class="space-y-3">
<div>
<strong>Bold Text:</strong>
<div class="mt-1 rounded bg-base-200 p-2 font-mono text-sm">**bold text**</div>
</div>
<div>
<strong>Links:</strong>
<div class="mt-1 rounded bg-base-200 p-2 font-mono text-sm">
[link text](https://example.com)
</div>
</div>
<div>
<strong>Line Breaks:</strong>
<div class="mt-1 text-sm text-base-content/70">
Simply press Enter in the textarea - line breaks are preserved automatically
</div>
</div>
</div>
</div>
<div class="modal-action">
<button class="btn" onclick={() => (showMarkdownHelp = false)}>Close</button>
</div>
</div>
</div>
{/if}
<style>
/* Utilities provided by Tailwind/daisyUI; no component-scoped CSS needed */
</style>

View File

@ -1,17 +1,21 @@
import type { RequestHandler } from './$types';
import prisma from '$lib/server/prisma';
import { v2 as cloudinary } from 'cloudinary';
import { CLOUDINARY_URL } from '$env/static/private';
// Configure Cloudinary using the URL
if (!CLOUDINARY_URL) {
throw new Error('Missing CLOUDINARY_URL environment variable');
// Configure Cloudinary conditionally
let cloudinary: any = null;
try {
const { CLOUDINARY_URL } = await import('$env/static/private');
if (CLOUDINARY_URL) {
const { v2 } = await import('cloudinary');
cloudinary = v2;
cloudinary.config({
url: CLOUDINARY_URL
});
}
} catch (error) {
console.warn('Cloudinary not configured:', error);
}
cloudinary.config({
url: CLOUDINARY_URL
});
export const POST: RequestHandler = async ({ request }) => {
const formData = await request.formData();
@ -42,30 +46,35 @@ export const POST: RequestHandler = async ({ request }) => {
// Handle photo upload if provided
if (photo && photo.size > 0) {
try {
// Convert File to buffer
const arrayBuffer = await photo.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (cloudinary) {
// Convert File to buffer
const arrayBuffer = await photo.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to Cloudinary
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
cloudinary.uploader.upload_stream(
{
folder: 'chef-bible/recipes',
resource_type: 'auto',
transformation: [
{ width: 800, height: 600, crop: 'limit' },
{ quality: 'auto:good' }
]
},
(error, result) => {
if (error) reject(error);
else if (result) resolve(result as { secure_url: string });
else reject(new Error('No result from Cloudinary'));
}
).end(buffer);
});
// Upload to Cloudinary
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
cloudinary.uploader.upload_stream(
{
folder: 'chef-bible/recipes',
resource_type: 'auto',
transformation: [
{ width: 800, height: 600, crop: 'limit' },
{ quality: 'auto:good' }
]
},
(error, result) => {
if (error) reject(error);
else if (result) resolve(result as { secure_url: string });
else reject(new Error('No result from Cloudinary'));
}
).end(buffer);
});
photoUrl = result.secure_url;
photoUrl = result.secure_url;
} else {
// Cloudinary not configured, skip photo upload
console.warn('Cloudinary not configured, skipping photo upload');
}
} catch (error) {
console.error('Failed to upload photo to Cloudinary:', error);
return new Response(JSON.stringify({ type: 'error', message: 'Failed to upload photo' }), { status: 500 });

View File

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
@ -8,10 +8,16 @@ const config = {
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
// Use adapter-node for Docker deployments
adapter: adapter({
out: 'build',
precompress: false,
envPrefix: '',
polyfill: true,
esbuild: {
format: 'cjs'
}
}),
},
};