mirror of
https://github.com/taogaetz/chefbible.git
synced 2025-12-06 11:47:24 -05:00
add markdown and docker
This commit is contained in:
parent
71ac76bc72
commit
32f5d216ba
95
.dockerignore
Normal file
95
.dockerignore
Normal 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
80
DEPLOYMENT.md
Normal 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
88
Dockerfile
Normal 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
18
docker-entrypoint.sh
Normal 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
331
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
|
||||
@ -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
13
server.mjs
Normal 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');
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
101
src/lib/utils/markdown.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
@ -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, '/');
|
||||
|
||||
6
src/routes/health/+server.ts
Normal file
6
src/routes/health/+server.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return new Response('OK', { status: 200 });
|
||||
};
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
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');
|
||||
}
|
||||
|
||||
cloudinary.config({
|
||||
// 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);
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
@ -42,6 +46,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
// Handle photo upload if provided
|
||||
if (photo && photo.size > 0) {
|
||||
try {
|
||||
if (cloudinary) {
|
||||
// Convert File to buffer
|
||||
const arrayBuffer = await photo.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
@ -66,6 +71,10 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
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');
|
||||
}
|
||||
|
||||
cloudinary.config({
|
||||
// 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);
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
@ -42,6 +46,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
// Handle photo upload if provided
|
||||
if (photo && photo.size > 0) {
|
||||
try {
|
||||
if (cloudinary) {
|
||||
// Convert File to buffer
|
||||
const arrayBuffer = await photo.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
@ -66,6 +71,10 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user